"""Degenerate Hex20 shapes — 15-node wedge and 13-node pyramid.
These are the wedge and pyramid topologies expressed as VTK
``QUADRATIC_WEDGE`` (15 pts) and ``QUADRATIC_PYRAMID`` (13 pts).
Foreign-deck readers (CDB, BDF, INP) translate their respective
catalogue spellings (collapsed-corner Hex20, NASTRAN ``CPENTA15`` /
``CPYRAM``, Abaqus ``C3D15`` / ``C3D13``) at the boundary.
Two implementations live here:
* ``Wedge15`` — dedicated 15-node quadratic serendipity wedge kernel
with its own shape functions and Gauss rules (9-pt for stiffness,
21-pt for consistent mass). Numerically equivalent to the
20-node-hex collapse on the meshes we have tested (the wedge
formulation is mathematically the same hex with K=L and O=P
collapsed — the dedicated kernel is just the compact path).
* ``Pyr13`` — currently a hex-fold wrapper ``K = Tᵀ K_hex T`` pending
a dedicated 13-node pyramid implementation. Pyramids are the long
pole on cross-solver parity studies (~5e-4 rel modal error, all
from this path); closing the gap requires proper apex-singular
shape functions (Bedrosian / Zgainski-Bossavit / Wachspress) plus
a pyramid-domain Gauss rule — tracked as a dedicated follow-up.
References
----------
* Degenerate hex → wedge / pyramid topology collapse: Zienkiewicz,
O.C. and Taylor, R.L. *The Finite Element Method: Its Basis and
Fundamentals*, 7th ed., Butterworth-Heinemann, 2013, §8.8.1
(discussion of collapsed hex nodes producing wedge / pyramid
shapes).
* **15-node serendipity wedge shape functions** (base corners on
barycentric ``(ξ₁, ξ₂, ξ₃) = L₁, L₂, L₃`` with ``ξ₃ = 1 − ξ₁ − ξ₂``,
quadratic through thickness in ``ζ``): Bedrosian, G., "Shape
functions and integration formulas for three-dimensional finite
element analysis," *IJNME* 35 (1992), pp. 95–108. Equivalent
derivation in Zienkiewicz & Taylor §8.8.3 Table 8.8-1.
* **9-point wedge Gauss rule** (3-point triangular rule × 3-point
Gauss-Legendre through thickness): triangular rule from Dunavant,
D.A., "High degree efficient symmetrical Gaussian quadrature
rules for the triangle," *IJNME* 21 (1985), pp. 1129–1148
(degree-4 triangle rule on 3 symmetric points); tensor-product
with the 1-D Gauss-Legendre rule follows Zienkiewicz & Taylor
§5.10.
* **21-point wedge mass rule** (6-point triangular rule × 3-point
through-thickness, integrates the degree-4 Bᵀ B polynomial on
the wedge): Dunavant degree-4 triangle rule × Gauss-Legendre 3.
* **13-node pyramid shape functions** (``Pyr13``, currently a
hex-fold wrapper pending a dedicated kernel): Bedrosian 1992
(as above) for the apex-singular polynomial basis, alternative
formulations in Zgainski, F.X., Coulomb, J.L., and Marechal, Y.,
"A new family of finite elements: the pyramidal elements,"
*IEEE Trans. Magn.* 32 (1996), pp. 1393–1396 and Wachspress,
E.L., *A Rational Finite Element Basis*, Academic Press, 1975.
"""
from __future__ import annotations
import numpy as np
from femorph_solver.elements._base import ElementBase
from femorph_solver.elements._registry import register
from femorph_solver.elements.hex8 import _elastic_matrix
from femorph_solver.elements.hex20 import Hex20
try:
from femorph_solver import _elements as _EXT
except ImportError: # pragma: no cover
_EXT = None
# =============================================================================
# 15-node wedge — dedicated shape functions + Gauss rules
# =============================================================================
_WEDGE_N = 15
_WEDGE_NDOF = 3 * _WEDGE_N # 45
def _wedge_shape_and_derivs(xi1: float, xi2: float, zeta: float) -> tuple[np.ndarray, np.ndarray]:
"""Return ``(N (15,), dN/dξ (15, 3))`` at one reference point.
``dN`` columns are ``∂/∂ξ₁``, ``∂/∂ξ₂``, ``∂/∂ζ`` with ``ξ₃`` eliminated
via ``ξ₃ = 1 − ξ₁ − ξ₂`` (chain-rule for the ξ₃ corner / mid-edges
that involve ξ₃).
Node ordering matches the VTK_QUADRATIC_WEDGE convention emitted
by foreign-deck readers from a collapsed-corner quadratic hex: the
*degenerate* base corner (collapsed K=L in the original hex slot
layout) lands at VTK index 0, so the barycentric-coord mapping runs
VTK[0] ↔ ξ₃ corner VTK[1] ↔ ξ₂ corner VTK[2] ↔ ξ₁ corner
for the base, with the top triangle, base/top midsides, and vertical
midsides following the same flipped orientation. Keeping the
orientation this way makes the Jacobian positive on the physical
wedges that mapdl-archive actually produces.
"""
xi3 = 1.0 - xi1 - xi2
one_m_zeta = 1.0 - zeta
one_p_zeta = 1.0 + zeta
one_m_zeta_sq = 1.0 - zeta * zeta
N = np.empty(_WEDGE_N, dtype=np.float64)
dN = np.empty((_WEDGE_N, 3), dtype=np.float64)
# --- Base corners (ζ = -1) ---------------------------------------------
# VTK[0] = ξ₃ corner (degenerate K=L on the hex side). ξ₃ = 1 − ξ₁ − ξ₂,
# so ∂/∂ξ₁ and ∂/∂ξ₂ pick up -1 from the chain rule.
N[0] = 0.5 * xi3 * (2.0 * xi3 - 1.0) * one_m_zeta - 0.5 * xi3 * one_m_zeta_sq
d_xi3 = 0.5 * (4.0 * xi3 - 1.0) * one_m_zeta - 0.5 * one_m_zeta_sq
dN[0, 0] = -d_xi3
dN[0, 1] = -d_xi3
dN[0, 2] = -0.5 * xi3 * (2.0 * xi3 - 1.0) + zeta * xi3
# VTK[1] = ξ₂ corner
N[1] = 0.5 * xi2 * (2.0 * xi2 - 1.0) * one_m_zeta - 0.5 * xi2 * one_m_zeta_sq
dN[1, 0] = 0.0
dN[1, 1] = 0.5 * (4.0 * xi2 - 1.0) * one_m_zeta - 0.5 * one_m_zeta_sq
dN[1, 2] = -0.5 * xi2 * (2.0 * xi2 - 1.0) + zeta * xi2
# VTK[2] = ξ₁ corner
N[2] = 0.5 * xi1 * (2.0 * xi1 - 1.0) * one_m_zeta - 0.5 * xi1 * one_m_zeta_sq
dN[2, 0] = 0.5 * (4.0 * xi1 - 1.0) * one_m_zeta - 0.5 * one_m_zeta_sq
dN[2, 1] = 0.0
dN[2, 2] = -0.5 * xi1 * (2.0 * xi1 - 1.0) + zeta * xi1
# --- Top corners (ζ = +1) ----------------------------------------------
N[3] = 0.5 * xi3 * (2.0 * xi3 - 1.0) * one_p_zeta - 0.5 * xi3 * one_m_zeta_sq
d_xi3 = 0.5 * (4.0 * xi3 - 1.0) * one_p_zeta - 0.5 * one_m_zeta_sq
dN[3, 0] = -d_xi3
dN[3, 1] = -d_xi3
dN[3, 2] = 0.5 * xi3 * (2.0 * xi3 - 1.0) + zeta * xi3
N[4] = 0.5 * xi2 * (2.0 * xi2 - 1.0) * one_p_zeta - 0.5 * xi2 * one_m_zeta_sq
dN[4, 0] = 0.0
dN[4, 1] = 0.5 * (4.0 * xi2 - 1.0) * one_p_zeta - 0.5 * one_m_zeta_sq
dN[4, 2] = 0.5 * xi2 * (2.0 * xi2 - 1.0) + zeta * xi2
N[5] = 0.5 * xi1 * (2.0 * xi1 - 1.0) * one_p_zeta - 0.5 * xi1 * one_m_zeta_sq
dN[5, 0] = 0.5 * (4.0 * xi1 - 1.0) * one_p_zeta - 0.5 * one_m_zeta_sq
dN[5, 1] = 0.0
dN[5, 2] = 0.5 * xi1 * (2.0 * xi1 - 1.0) + zeta * xi1
# --- Base midsides (ζ = -1) --------------------------------------------
# VTK[6] = midside on edge (VTK[0], VTK[1]) = (ξ₃, ξ₂) edge.
N[6] = 2.0 * xi3 * xi2 * one_m_zeta
dN[6, 0] = -2.0 * xi2 * one_m_zeta
dN[6, 1] = 2.0 * (xi3 - xi2) * one_m_zeta
dN[6, 2] = -2.0 * xi3 * xi2
# VTK[7] = midside on edge (VTK[1], VTK[2]) = (ξ₂, ξ₁) edge.
N[7] = 2.0 * xi2 * xi1 * one_m_zeta
dN[7, 0] = 2.0 * xi2 * one_m_zeta
dN[7, 1] = 2.0 * xi1 * one_m_zeta
dN[7, 2] = -2.0 * xi2 * xi1
# VTK[8] = midside on edge (VTK[2], VTK[0]) = (ξ₁, ξ₃) edge.
N[8] = 2.0 * xi1 * xi3 * one_m_zeta
dN[8, 0] = 2.0 * (xi3 - xi1) * one_m_zeta
dN[8, 1] = -2.0 * xi1 * one_m_zeta
dN[8, 2] = -2.0 * xi1 * xi3
# --- Top midsides (ζ = +1) ---------------------------------------------
N[9] = 2.0 * xi3 * xi2 * one_p_zeta
dN[9, 0] = -2.0 * xi2 * one_p_zeta
dN[9, 1] = 2.0 * (xi3 - xi2) * one_p_zeta
dN[9, 2] = 2.0 * xi3 * xi2
N[10] = 2.0 * xi2 * xi1 * one_p_zeta
dN[10, 0] = 2.0 * xi2 * one_p_zeta
dN[10, 1] = 2.0 * xi1 * one_p_zeta
dN[10, 2] = 2.0 * xi2 * xi1
N[11] = 2.0 * xi1 * xi3 * one_p_zeta
dN[11, 0] = 2.0 * (xi3 - xi1) * one_p_zeta
dN[11, 1] = -2.0 * xi1 * one_p_zeta
dN[11, 2] = 2.0 * xi1 * xi3
# --- Vertical midsides (ζ = 0) -----------------------------------------
# VTK[12] = vertical at VTK[0] = ξ₃ column
N[12] = xi3 * one_m_zeta_sq
dN[12, 0] = -one_m_zeta_sq
dN[12, 1] = -one_m_zeta_sq
dN[12, 2] = -2.0 * xi3 * zeta
# VTK[13] = vertical at VTK[1] = ξ₂ column
N[13] = xi2 * one_m_zeta_sq
dN[13, 0] = 0.0
dN[13, 1] = one_m_zeta_sq
dN[13, 2] = -2.0 * xi2 * zeta
# VTK[14] = vertical at VTK[2] = ξ₁ column
N[14] = xi1 * one_m_zeta_sq
dN[14, 0] = one_m_zeta_sq
dN[14, 1] = 0.0
dN[14, 2] = -2.0 * xi1 * zeta
return N, dN
def _line_gauss_3pt() -> tuple[np.ndarray, np.ndarray]:
"""3-pt Gauss-Legendre on [-1, +1] (degree 5 exact)."""
sqrt35 = np.sqrt(3.0 / 5.0)
pts = np.array([-sqrt35, 0.0, +sqrt35])
wts = np.array([5.0 / 9.0, 8.0 / 9.0, 5.0 / 9.0])
return pts, wts
def _tri_gauss_3pt() -> tuple[np.ndarray, np.ndarray]:
"""3-pt barycentric triangle Gauss (degree 2 exact).
Weights sum to ½ (reference-triangle area).
"""
pts = np.array(
[
[2.0 / 3.0, 1.0 / 6.0],
[1.0 / 6.0, 2.0 / 3.0],
[1.0 / 6.0, 1.0 / 6.0],
]
)
wts = np.array([1.0 / 6.0, 1.0 / 6.0, 1.0 / 6.0])
return pts, wts
def _tri_gauss_7pt() -> tuple[np.ndarray, np.ndarray]:
"""7-pt barycentric triangle Gauss (degree 5 exact, Radon/Hammer).
Required for the consistent-mass integrand ``N_i · N_j`` (degree 4
in area coords for 15-node serendipity) — 3-pt under-integrates and
leaves M rank-deficient. Weights sum to ½.
"""
sqrt15 = np.sqrt(15.0)
a1 = (6.0 - sqrt15) / 21.0
b1 = (9.0 + 2.0 * sqrt15) / 21.0
a2 = (6.0 + sqrt15) / 21.0
b2 = (9.0 - 2.0 * sqrt15) / 21.0
w_center = 9.0 / 80.0
w1 = (155.0 - sqrt15) / 2400.0
w2 = (155.0 + sqrt15) / 2400.0
pts = np.array(
[
[1.0 / 3.0, 1.0 / 3.0],
[b1, a1],
[a1, b1],
[a1, a1],
[b2, a2],
[a2, b2],
[a2, a2],
]
)
wts = np.array([w_center, w1, w1, w1, w2, w2, w2])
return pts, wts
def _product_wedge_rule(
tri_pts: np.ndarray, tri_wts: np.ndarray, line_pts: np.ndarray, line_wts: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""Tensor-product triangle × line → prism Gauss rule."""
n = len(tri_wts) * len(line_wts)
pts = np.empty((n, 3), dtype=np.float64)
wts = np.empty(n, dtype=np.float64)
i = 0
for (x1, x2), tw in zip(tri_pts, tri_wts, strict=True):
for zeta, lw in zip(line_pts, line_wts, strict=True):
pts[i] = (x1, x2, zeta)
wts[i] = tw * lw
i += 1
return pts, wts
# Stiffness: 3-pt triangle × 3-pt line = 9 pts on the wedge.
_WEDGE_GAUSS_POINTS, _WEDGE_GAUSS_WEIGHTS = _product_wedge_rule(
*_tri_gauss_3pt(), *_line_gauss_3pt()
)
# Mass: 7-pt triangle × 3-pt line = 21 pts (exact for N_i·N_j = degree 4 in
# area × degree 4 in ζ) — required to keep consistent mass SPD.
_WEDGE_MASS_GAUSS_POINTS, _WEDGE_MASS_GAUSS_WEIGHTS = _product_wedge_rule(
*_tri_gauss_7pt(), *_line_gauss_3pt()
)
def _wedge_b_matrix(dN_dx: np.ndarray) -> np.ndarray:
"""Return the 6×45 strain-displacement matrix from ``dN/dx (15, 3)``."""
B = np.zeros((6, _WEDGE_NDOF), dtype=np.float64)
for i in range(_WEDGE_N):
bx, by, bz = dN_dx[i]
c = 3 * i
B[0, c + 0] = bx
B[1, c + 1] = by
B[2, c + 2] = bz
B[3, c + 0] = by
B[3, c + 1] = bx
B[4, c + 1] = bz
B[4, c + 2] = by
B[5, c + 0] = bz
B[5, c + 2] = bx
return B
def _wedge_shape_matrix(N: np.ndarray) -> np.ndarray:
"""Return the 3×45 shape-function matrix ``Φ`` with three rows per DOF."""
Phi = np.zeros((3, _WEDGE_NDOF), dtype=np.float64)
for i in range(_WEDGE_N):
Phi[0, 3 * i + 0] = N[i]
Phi[1, 3 * i + 1] = N[i]
Phi[2, 3 * i + 2] = N[i]
return Phi
def _wedge_ke(coords: np.ndarray, E: float, nu: float) -> np.ndarray:
"""Assemble Ke for a 15-node wedge (9-pt Gauss)."""
C = _elastic_matrix(E, nu)
K = np.zeros((_WEDGE_NDOF, _WEDGE_NDOF), dtype=np.float64)
for gp, w in zip(_WEDGE_GAUSS_POINTS, _WEDGE_GAUSS_WEIGHTS, strict=True):
_, dN_dxi = _wedge_shape_and_derivs(*gp)
J = dN_dxi.T @ coords
det_J = float(np.linalg.det(J))
if det_J <= 0:
raise ValueError(f"WEDGE15: non-positive Jacobian {det_J:.3e} — check node ordering")
dN_dx = np.linalg.solve(J, dN_dxi.T).T
B = _wedge_b_matrix(dN_dx)
K += B.T @ C @ B * det_J * w
return K
def _wedge_me(coords: np.ndarray, rho: float) -> np.ndarray:
"""Assemble consistent mass for a 15-node wedge (21-pt Gauss).
3-pt triangle under-integrates ``N_i · N_j`` (degree 4 in area) and
leaves M rank-deficient; 7-pt triangle × 3-pt line fixes that.
"""
M = np.zeros((_WEDGE_NDOF, _WEDGE_NDOF), dtype=np.float64)
for gp, w in zip(_WEDGE_MASS_GAUSS_POINTS, _WEDGE_MASS_GAUSS_WEIGHTS, strict=True):
N, dN_dxi = _wedge_shape_and_derivs(*gp)
J = dN_dxi.T @ coords
det_J = float(np.linalg.det(J))
Phi = _wedge_shape_matrix(N)
M += rho * (Phi.T @ Phi) * det_J * w
return M
# =============================================================================
# 13-node pyramid — dedicated Bedrosian serendipity kernel
# =============================================================================
# Peterson / LibMesh / Bedrosian node numbering on the reference pyramid with
# square base (ξ, η) ∈ [-1, 1]² at ζ = 0 and apex at (0, 0, 1) (1-indexed):
# 1:( 1, 1, 0) 2:( 0, 1, 0) 3:(-1, 1, 0) 4:(-1, 0, 0)
# 5:(-1,-1, 0) 6:( 0,-1, 0) 7:( 1,-1, 0) 8:( 1, 0, 0)
# 9:(.5,.5,.5) 10:(-.5,.5,.5) 11:(-.5,-.5,.5) 12:(.5,-.5,.5)
# 13:( 0, 0, 1) # apex
# VTK_QUADRATIC_PYRAMID layout (0-indexed): corners 0-3 CCW at ζ=0, apex 4,
# base midsides 5-8 (edges 0-1, 1-2, 2-3, 3-0), apex midsides 9-12
# (edges 0-4, 1-4, 2-4, 3-4).
_PETERSON_TO_VTK = np.array([0, 5, 1, 6, 2, 7, 3, 8, 9, 10, 11, 12, 4], dtype=np.int64)
_VTK_TO_PETERSON = np.argsort(_PETERSON_TO_VTK)
def _pyramid_shape(xi: float, eta: float, zeta: float) -> np.ndarray:
"""13-node Bedrosian shape functions in Peterson order.
Inputs are scalar floats; the `s = 1 − ζ` rational factor has a
removable singularity at the apex (ζ=1) that's outside any
interior Gauss point, so we don't handle the apex limit here.
"""
s = 1.0 - zeta
N = np.empty(13, dtype=np.float64)
N[0] = 0.25 * (xi + eta - 1.0) * ((1 + xi) * (1 + eta) - zeta + xi * eta * zeta / s)
N[1] = 0.5 * (1 + xi - zeta) * (1 - xi - zeta) * (1 + eta - zeta) / s
N[2] = 0.25 * (-xi + eta - 1.0) * ((1 - xi) * (1 + eta) - zeta - xi * eta * zeta / s)
N[3] = 0.5 * (1 + eta - zeta) * (1 - eta - zeta) * (1 - xi - zeta) / s
N[4] = 0.25 * (-xi - eta - 1.0) * ((1 - xi) * (1 - eta) - zeta + xi * eta * zeta / s)
N[5] = 0.5 * (1 + xi - zeta) * (1 - xi - zeta) * (1 - eta - zeta) / s
N[6] = 0.25 * (xi - eta - 1.0) * ((1 + xi) * (1 - eta) - zeta - xi * eta * zeta / s)
N[7] = 0.5 * (1 + eta - zeta) * (1 - eta - zeta) * (1 + xi - zeta) / s
N[8] = zeta * (1 + xi - zeta) * (1 + eta - zeta) / s
N[9] = zeta * (1 - xi - zeta) * (1 + eta - zeta) / s
N[10] = zeta * (1 - xi - zeta) * (1 - eta - zeta) / s
N[11] = zeta * (1 + xi - zeta) * (1 - eta - zeta) / s
N[12] = zeta * (2 * zeta - 1)
return N
def _pyramid_shape_and_derivs(xi: float, eta: float, zeta: float) -> tuple[np.ndarray, np.ndarray]:
"""Analytical (Peterson-ordered) shape functions + gradients."""
s = 1.0 - zeta
s2 = s * s
N = _pyramid_shape(xi, eta, zeta)
dN = np.empty((13, 3), dtype=np.float64)
# Base corners (Peterson 1, 3, 5, 7):
dN[0, 0] = (
-eta * eta
- 2 * eta * xi
+ 2 * eta * zeta
- eta
+ 2 * xi * zeta
- 2 * xi
- zeta * zeta
+ zeta
) / (4 * (zeta - 1))
dN[0, 1] = (
-2 * eta * xi + 2 * eta * zeta - 2 * eta - xi * xi + 2 * xi * zeta - xi - zeta * zeta + zeta
) / (4 * (zeta - 1))
dN[0, 2] = (-(eta + xi - 1) * (-eta * xi * zeta + eta * xi * (zeta - 1) + (zeta - 1) ** 2)) / (
4 * (zeta - 1) ** 2
)
dN[2, 0] = (
eta * eta
- 2 * eta * xi
- 2 * eta * zeta
+ eta
+ 2 * xi * zeta
- 2 * xi
+ zeta * zeta
- zeta
) / (4 * (zeta - 1))
dN[2, 1] = (
2 * eta * xi + 2 * eta * zeta - 2 * eta - xi * xi - 2 * xi * zeta + xi - zeta * zeta + zeta
) / (4 * (zeta - 1))
dN[2, 2] = ((-eta + xi + 1) * (eta * xi * zeta - eta * xi * (zeta - 1) + (zeta - 1) ** 2)) / (
4 * (zeta - 1) ** 2
)
dN[4, 0] = (
eta * eta
+ 2 * eta * xi
+ 2 * eta * zeta
- eta
+ 2 * xi * zeta
- 2 * xi
+ zeta * zeta
- zeta
) / (4 * (zeta - 1))
dN[4, 1] = (
2 * eta * xi + 2 * eta * zeta - 2 * eta + xi * xi + 2 * xi * zeta - xi + zeta * zeta - zeta
) / (4 * (zeta - 1))
dN[4, 2] = ((eta + xi + 1) * (-eta * xi * zeta + eta * xi * (zeta - 1) + (zeta - 1) ** 2)) / (
4 * (zeta - 1) ** 2
)
dN[6, 0] = (
-eta * eta
+ 2 * eta * xi
- 2 * eta * zeta
+ eta
+ 2 * xi * zeta
- 2 * xi
- zeta * zeta
+ zeta
) / (4 * (zeta - 1))
dN[6, 1] = (
-2 * eta * xi + 2 * eta * zeta - 2 * eta + xi * xi - 2 * xi * zeta + xi + zeta * zeta - zeta
) / (4 * (zeta - 1))
dN[6, 2] = ((eta - xi + 1) * (eta * xi * zeta - eta * xi * (zeta - 1) + (zeta - 1) ** 2)) / (
4 * (zeta - 1) ** 2
)
# Base midsides (Peterson 2, 4, 6, 8):
dN[1, 0] = xi * (eta - zeta + 1) / (zeta - 1)
dN[1, 1] = (xi - zeta + 1) * (xi + zeta - 1) / (2 * (zeta - 1))
dN[1, 2] = (
-eta * xi * xi
- eta * zeta * zeta
+ 2 * eta * zeta
- eta
+ 2 * zeta**3
- 6 * zeta * zeta
+ 6 * zeta
- 2
) / (2 * s2)
dN[3, 0] = -(eta - zeta + 1) * (eta + zeta - 1) / (2 * zeta - 2)
dN[3, 1] = eta * (-xi - zeta + 1) / (zeta - 1)
dN[3, 2] = (
eta * eta * xi
+ xi * zeta * zeta
- 2 * xi * zeta
+ xi
+ 2 * zeta**3
- 6 * zeta * zeta
+ 6 * zeta
- 2
) / (2 * s2)
dN[5, 0] = xi * (-eta - zeta + 1) / (zeta - 1)
dN[5, 1] = -(xi - zeta + 1) * (xi + zeta - 1) / (2 * zeta - 2)
dN[5, 2] = (
eta * xi * xi
+ eta * zeta * zeta
- 2 * eta * zeta
+ eta
+ 2 * zeta**3
- 6 * zeta * zeta
+ 6 * zeta
- 2
) / (2 * s2)
dN[7, 0] = (eta - zeta + 1) * (eta + zeta - 1) / (2 * (zeta - 1))
dN[7, 1] = eta * (xi - zeta + 1) / (zeta - 1)
dN[7, 2] = (
-eta * eta * xi
- xi * zeta * zeta
+ 2 * xi * zeta
- xi
+ 2 * zeta**3
- 6 * zeta * zeta
+ 6 * zeta
- 2
) / (2 * s2)
# Apex midsides (Peterson 9, 10, 11, 12):
dN[8, 0] = zeta * (-eta + zeta - 1) / (zeta - 1)
dN[8, 1] = zeta * (-xi + zeta - 1) / (zeta - 1)
dN[8, 2] = (
zeta * (eta - zeta + 1) * (xi - zeta + 1)
+ (zeta - 1)
* (zeta * (eta - zeta + 1) + zeta * (xi - zeta + 1) - (eta - zeta + 1) * (xi - zeta + 1))
) / s2
dN[9, 0] = zeta * (eta - zeta + 1) / (zeta - 1)
dN[9, 1] = zeta * (xi + zeta - 1) / (zeta - 1)
dN[9, 2] = (
-zeta * (eta - zeta + 1) * (xi + zeta - 1)
+ (zeta - 1)
* (zeta * (eta - zeta + 1) - zeta * (xi + zeta - 1) + (eta - zeta + 1) * (xi + zeta - 1))
) / s2
dN[10, 0] = zeta * (-eta - zeta + 1) / (zeta - 1)
dN[10, 1] = zeta * (-xi - zeta + 1) / (zeta - 1)
dN[10, 2] = (
zeta * (eta + zeta - 1) * (xi + zeta - 1)
- (zeta - 1)
* (zeta * (eta + zeta - 1) + zeta * (xi + zeta - 1) + (eta + zeta - 1) * (xi + zeta - 1))
) / s2
dN[11, 0] = zeta * (eta + zeta - 1) / (zeta - 1)
dN[11, 1] = zeta * (xi - zeta + 1) / (zeta - 1)
dN[11, 2] = (
-zeta * (eta + zeta - 1) * (xi - zeta + 1)
+ (zeta - 1)
* (-zeta * (eta + zeta - 1) + zeta * (xi - zeta + 1) + (eta + zeta - 1) * (xi - zeta + 1))
) / s2
# Apex (Peterson 13):
dN[12, 0] = 0.0
dN[12, 1] = 0.0
dN[12, 2] = 4 * zeta - 1
return N, dN
def _pyramid_shape_vtk(xi: float, eta: float, zeta: float) -> tuple[np.ndarray, np.ndarray]:
"""Peterson shape functions remapped to VTK_QUADRATIC_PYRAMID order."""
N_pet, dN_pet = _pyramid_shape_and_derivs(xi, eta, zeta)
N_vtk = np.empty(13, dtype=np.float64)
dN_vtk = np.empty((13, 3), dtype=np.float64)
for pet_idx, vtk_idx in enumerate(_PETERSON_TO_VTK):
N_vtk[vtk_idx] = N_pet[pet_idx]
dN_vtk[vtk_idx] = dN_pet[pet_idx]
return N_vtk, dN_vtk
def _pyramid_duffy_rule(n_base: int = 3, n_apex: int = 3) -> tuple[np.ndarray, np.ndarray]:
"""Collapsed-hex (Duffy) Gauss rule on the reference pyramid.
Unit cube (u, v, w) ∈ [0, 1]³ → pyramid via
ξ = (2u − 1)(1 − w), η = (2v − 1)(1 − w), ζ = w
with Jacobian 4(1 − w)² (the "4" from the (2u-1) and (2v-1) scaling
to [-1, 1]²). Product of `n_base`-pt Gauss-Legendre in u and v
with `n_apex`-pt GL in w — simple and exact for degree-(2n-1)
polynomials on the reference pyramid when `n_base = n_apex = n`.
"""
def gl_01(n: int) -> tuple[np.ndarray, np.ndarray]:
pts, wts = np.polynomial.legendre.leggauss(n)
return 0.5 * (pts + 1.0), 0.5 * wts
u_pts, u_wts = gl_01(n_base)
v_pts, v_wts = u_pts, u_wts
w_pts, w_wts = gl_01(n_apex)
pts = np.empty((n_base * n_base * n_apex, 3), dtype=np.float64)
wts = np.empty(n_base * n_base * n_apex, dtype=np.float64)
i = 0
for u, wu in zip(u_pts, u_wts, strict=True):
for v, wv in zip(v_pts, v_wts, strict=True):
for w, ww in zip(w_pts, w_wts, strict=True):
pts[i] = ((2 * u - 1) * (1 - w), (2 * v - 1) * (1 - w), w)
wts[i] = wu * wv * ww * 4.0 * (1 - w) ** 2
i += 1
return pts, wts
_PYRAMID_GAUSS_POINTS, _PYRAMID_GAUSS_WEIGHTS = _pyramid_duffy_rule(3, 3)
# Reduced 2×2×2 Duffy rule (8 points): the under-integrated pyramid
# quadrature commonly seen in foreign decks that emit hex-folded
# pyramids; sufficient for parity tests against those decks but
# diverges from the consistent 27-pt rule by ~13 % on a single element.
_PYRAMID_REDUCED_POINTS, _PYRAMID_REDUCED_WEIGHTS = _pyramid_duffy_rule(2, 2)
def _pyramid_b_matrix(dN_dx: np.ndarray) -> np.ndarray:
"""Return the 6×39 strain-displacement matrix from ``dN/dx (13, 3)``."""
B = np.zeros((6, 39), dtype=np.float64)
for i in range(13):
bx, by, bz = dN_dx[i]
col = 3 * i
B[0, col + 0] = bx
B[1, col + 1] = by
B[2, col + 2] = bz
B[3, col + 0] = by
B[3, col + 1] = bx
B[4, col + 1] = bz
B[4, col + 2] = by
B[5, col + 0] = bz
B[5, col + 2] = bx
return B
def _pyramid_ke(coords: np.ndarray, E: float, nu: float, rule: str = "reduced") -> np.ndarray:
"""Bedrosian 13-node pyramid stiffness.
``rule="reduced"`` uses the 2×2×2 Duffy rule (8 points), the
under-integrated rule used for parity against foreign decks.
``rule="consistent"`` uses 27-pt Duffy (degree-5 exact in ξ/η ×
degree-5 in ζ); mathematically a more accurate integration of
the quartic ``Bᵀ C B`` integrand but ~13 % apart from the
reduced rule in Frobenius norm.
"""
if rule == "reduced":
pts, wts = _PYRAMID_REDUCED_POINTS, _PYRAMID_REDUCED_WEIGHTS
elif rule == "consistent":
pts, wts = _PYRAMID_GAUSS_POINTS, _PYRAMID_GAUSS_WEIGHTS
else:
raise ValueError(f"unknown rule {rule!r}; expected 'reduced' or 'consistent'")
C = _elastic_matrix(E, nu)
K = np.zeros((39, 39), dtype=np.float64)
for gp, w in zip(pts, wts, strict=True):
_, dN_dxi = _pyramid_shape_vtk(*gp)
J = dN_dxi.T @ coords
det_J = float(np.linalg.det(J))
if det_J <= 0:
raise ValueError(f"PYR13: non-positive Jacobian {det_J:.3e} — check node ordering")
dN_dx = np.linalg.solve(J, dN_dxi.T).T
B = _pyramid_b_matrix(dN_dx)
K += B.T @ C @ B * det_J * w
return K
def _pyramid_me(coords: np.ndarray, rho: float, rule: str = "reduced") -> np.ndarray:
"""Bedrosian 13-node pyramid consistent mass.
Same rule options as :func:`_pyramid_ke`. Default ``"reduced"``
uses the 2×2×2 Duffy rule (the under-integrated rule used for
parity against foreign decks); ``"consistent"`` uses 27-pt Duffy.
"""
if rule == "reduced":
pts, wts = _PYRAMID_REDUCED_POINTS, _PYRAMID_REDUCED_WEIGHTS
elif rule == "consistent":
pts, wts = _PYRAMID_GAUSS_POINTS, _PYRAMID_GAUSS_WEIGHTS
else:
raise ValueError(f"unknown rule {rule!r}; expected 'reduced' or 'consistent'")
M = np.zeros((39, 39), dtype=np.float64)
Phi = np.zeros((3, 39), dtype=np.float64)
for gp, w in zip(pts, wts, strict=True):
N, dN_dxi = _pyramid_shape_vtk(*gp)
J = dN_dxi.T @ coords
det_J = float(np.linalg.det(J))
for i in range(13):
Phi[0, 3 * i + 0] = N[i]
Phi[1, 3 * i + 1] = N[i]
Phi[2, 3 * i + 2] = N[i]
M += rho * (Phi.T @ Phi) * det_J * w
return M
# =============================================================================
# 13-node pyramid — hex-fold legacy path (kept as opt-in for regressions)
# =============================================================================
# 20-slot hex slot → 13-node pyramid index (M=N=O=P collapse to apex).
# VTK_QUADRATIC_PYRAMID order: 4 base corners, apex (idx 4), 4 base
# midsides (0-1, 1-2, 2-3, 3-0), 4 base-to-apex midsides (0-4, 1-4, 2-4, 3-4).
_PYRAMID_SLOT_MAP = np.array(
[
0, # I
1, # J
2, # K
3, # L
4, # M = apex
4, # N = apex
4, # O = apex
4, # P = apex
5, # Q=IJ base 0-1
6, # R=JK base 1-2
7, # S=KL base 2-3
8, # T=LI base 3-0
4, # U=MN collapsed apex
4, # V=NO collapsed apex
4, # W=OP collapsed apex
4, # X=PM collapsed apex
9, # Y=IM base-to-apex 0-4
10, # Z=JN base-to-apex 1-4 (N=M, so 1→apex)
11, # A=KO base-to-apex 2-4
12, # B=LP base-to-apex 3-4
],
dtype=np.int64,
)
def _fold_matrix(slot_map: np.ndarray, n_compact: int) -> np.ndarray:
"""Return the sparse 60×(3*n_compact) indicator ``T`` for 3-DOF folding."""
T = np.zeros((60, 3 * n_compact), dtype=np.float64)
for hex_slot, compact_slot in enumerate(slot_map):
for d in range(3):
T[3 * hex_slot + d, 3 * compact_slot + d] = 1.0
return T
_T_PYRAMID = _fold_matrix(_PYRAMID_SLOT_MAP, 13)
def _expand_to_hex(coords_compact: np.ndarray, slot_map: np.ndarray) -> np.ndarray:
"""Duplicate compact coords back to the 20-node hex layout."""
return coords_compact[slot_map]
def _fold(K_hex: np.ndarray, T: np.ndarray) -> np.ndarray:
"""Apply the slot-collapse fold ``Tᵀ K T`` (exact, no approximation)."""
return T.T @ K_hex @ T
# =============================================================================
# Element classes
# =============================================================================
[docs]
@register
class Wedge15(ElementBase):
"""15-node degenerate Hex20 wedge (K=L, O=P collapse).
Dedicated quadratic serendipity kernel with shape functions in area
coords ``(ξ₁, ξ₂, ζ)``, stiffness at 9-pt Gauss (3-pt triangle × 3-pt
line), consistent mass at 21-pt Gauss (7-pt triangle × 3-pt line).
"""
name = "WEDGE15"
n_nodes = _WEDGE_N
n_dof_per_node = 3
vtk_cell_type = 26 # VTK_QUADRATIC_WEDGE
[docs]
@staticmethod
def ke(
coords: np.ndarray,
material: dict[str, float],
real: np.ndarray,
) -> np.ndarray:
coords = np.ascontiguousarray(coords, dtype=np.float64)
if coords.shape != (_WEDGE_N, 3):
raise ValueError(f"Wedge15 expects (15, 3) coords, got {coords.shape}")
E = float(material["EX"])
nu = float(material["PRXY"])
return _wedge_ke(coords, E, nu)
[docs]
@staticmethod
def me(
coords: np.ndarray,
material: dict[str, float],
real: np.ndarray,
lumped: bool = False,
) -> np.ndarray:
coords = np.ascontiguousarray(coords, dtype=np.float64)
if coords.shape != (_WEDGE_N, 3):
raise ValueError(f"Wedge15 expects (15, 3) coords, got {coords.shape}")
rho = float(material["DENS"])
M = _wedge_me(coords, rho)
if lumped:
M = np.diag(M.sum(axis=1))
return M
[docs]
@register
class Pyr13(ElementBase):
"""13-node degenerate Hex20 pyramid.
Default: dedicated Bedrosian apex-singular serendipity kernel
integrated with a 2×2×2 Duffy collapsed-hex Gauss rule. The
2×2×2 rule (not 3×3×3) is the under-integrated quadrature that
matches the foreign-deck convention; the higher-order
``"consistent"`` rule is mathematically more accurate but ~13 %
apart in Frobenius norm on a single element.
The hex-fold wrapper (``T^T · K_20-node · T``) is retained as
``material["_PYR13_INTEGRATION"] = "hex_fold"`` for parity tests
against foreign decks that emit hex-folded pyramids.
"""
name = "PYR13"
n_nodes = 13
n_dof_per_node = 3
vtk_cell_type = 27 # VTK_QUADRATIC_PYRAMID
[docs]
@staticmethod
def ke(
coords: np.ndarray,
material: dict[str, float],
real: np.ndarray,
) -> np.ndarray:
coords = np.ascontiguousarray(coords, dtype=np.float64)
if coords.shape != (13, 3):
raise ValueError(f"Pyr13 expects (13, 3) coords, got {coords.shape}")
formulation = material.get("_PYR13_INTEGRATION", "bedrosian")
if formulation == "bedrosian":
E = float(material["EX"])
nu = float(material["PRXY"])
rule = material.get("_PYR13_PYRAMID_RULE", "reduced")
# C++ kernel implements the reduced 2×2×2 Duffy rule only —
# higher-order ``consistent`` stays on the Python path.
if rule == "reduced" and _EXT is not None:
return _EXT.solid186p_ke(coords, E, nu)
return _pyramid_ke(coords, E, nu, rule=rule)
if formulation == "hex_fold":
coords_hex = _expand_to_hex(coords, _PYRAMID_SLOT_MAP)
K_hex = Hex20.ke(coords_hex, material, real)
return _fold(K_hex, _T_PYRAMID)
raise ValueError(
f"PYR13: unknown _PYR13_INTEGRATION {formulation!r}; expected 'bedrosian' or 'hex_fold'"
)
[docs]
@staticmethod
def me(
coords: np.ndarray,
material: dict[str, float],
real: np.ndarray,
lumped: bool = False,
) -> np.ndarray:
coords = np.ascontiguousarray(coords, dtype=np.float64)
if coords.shape != (13, 3):
raise ValueError(f"Pyr13 expects (13, 3) coords, got {coords.shape}")
formulation = material.get("_PYR13_INTEGRATION", "bedrosian")
if formulation == "bedrosian":
rho = float(material["DENS"])
rule = material.get("_PYR13_PYRAMID_RULE", "reduced")
if rule == "reduced" and _EXT is not None:
M = _EXT.solid186p_me(coords, rho)
else:
M = _pyramid_me(coords, rho, rule=rule)
elif formulation == "hex_fold":
coords_hex = _expand_to_hex(coords, _PYRAMID_SLOT_MAP)
M_hex = Hex20.me(coords_hex, material, real, lumped=False)
M = _fold(M_hex, _T_PYRAMID)
else:
raise ValueError(
f"PYR13: unknown _PYR13_INTEGRATION {formulation!r}; "
f"expected 'bedrosian' or 'hex_fold'"
)
if lumped:
M = np.diag(M.sum(axis=1))
return M
@staticmethod
def ke_batch(
coords: np.ndarray,
material: dict[str, float],
real: np.ndarray,
) -> np.ndarray | None:
if _EXT is None:
return None
formulation = material.get("_PYR13_INTEGRATION", "bedrosian")
if formulation != "bedrosian":
return None
if material.get("_PYR13_PYRAMID_RULE", "reduced") != "reduced":
return None
coords = np.ascontiguousarray(coords, dtype=np.float64)
return _EXT.solid186p_ke_batch(coords, float(material["EX"]), float(material["PRXY"]))
@staticmethod
def me_batch(
coords: np.ndarray,
material: dict[str, float],
real: np.ndarray,
lumped: bool = False,
) -> np.ndarray | None:
if _EXT is None or lumped:
return None
formulation = material.get("_PYR13_INTEGRATION", "bedrosian")
if formulation != "bedrosian":
return None
if material.get("_PYR13_PYRAMID_RULE", "reduced") != "reduced":
return None
coords = np.ascontiguousarray(coords, dtype=np.float64)
return _EXT.solid186p_me_batch(coords, float(material["DENS"]))