"""Element specs — typed, named-parameter element identifiers.
Each spec class is a thin frozen dataclass that carries (a) the
canonical neutral kernel name (``"HEX8"`` / ``"HEX20"`` / …) and (b)
any kernel-formulation knobs as **named, typed parameters** rather
than opaque string-valued sentinels in a material dict.
Usage::
from femorph_solver import ELEMENTS
model.assign(ELEMENTS.HEX8, {"EX": 2.1e11, "PRXY": 0.30})
model.assign(
ELEMENTS.HEX8(integration="enhanced_strain"),
{"EX": 2.1e11, "PRXY": 0.30},
)
model.assign(ELEMENTS.QUAD4_PLANE(mode="strain"), {...})
model.assign(ELEMENTS.PYR13(pyramid_rule="keast"), {...})
``Model.assign`` accepts either the spec **class** (default-configured)
or an instance with explicit knobs. The class form is the
zero-config shorthand; the instance form is used when any kernel
knob is non-default.
Internals
---------
The spec carries a private ``_kernel_name`` ClassVar matching the
neutral key under which the kernel registers with
:func:`femorph_solver.elements.get`, plus a private
``_material_flags()`` method that produces the underscore-prefixed
sentinel-keys the kernel actually consults at integration time
(``"_HEX8_INTEGRATION"``, ``"_PYR13_PYRAMID_RULE"``, …). Users
never see those sentinel keys; they are an implementation detail
of how the spec talks to the kernel.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import ClassVar, Literal
def _validate_literal(value: str, allowed: tuple[str, ...], param: str, kernel: str) -> None:
"""Reject a value that isn't one of the documented options.
Raised at instantiation time so the user sees the error at the
``ELEMENTS.HEX8(integration="bogus")`` call rather than deep in
``Model.assign`` or — worse — silently inside ``ke()``.
"""
if value not in allowed:
raise ValueError(
f"{kernel}: unknown {param}={value!r}; expected one of "
f"{', '.join(repr(v) for v in allowed)}"
)
# ---------------------------------------------------------------------
# 3D solids
# ---------------------------------------------------------------------
@dataclass(frozen=True)
class HEX8:
"""Trilinear 8-node hexahedron (kernel: ``HEX8``).
Parameters
----------
integration :
``"full"`` (default) — Hughes B-bar with selective reduced
integration on the volumetric component. Cures volumetric
locking at near-incompressible Poisson ratios; shear locking
on slender bending-dominated geometries is left intact (use
``"enhanced_strain"`` for that case).
``"enhanced_strain"`` — Simo-Rifai 9-parameter enhanced
assumed strain with Pian-Tong Jacobian scaling. Cures both
shear and volumetric locking; recovers >95 % of the
Euler-Bernoulli tip deflection on a slender single-element-
thick cantilever.
``"plain_gauss"`` — vanilla 2×2×2 Gauss with no B-bar / EAS
treatment. Locks volumetrically at near-incompressible
Poisson and shear-locks on bending-dominated geometries —
useful only for cross-solver comparison against codes that
ship the unaltered standard hex.
"""
integration: Literal["full", "enhanced_strain", "plain_gauss"] = "full"
_kernel_name: ClassVar[str] = "HEX8"
def __post_init__(self) -> None:
_validate_literal(
self.integration,
("full", "enhanced_strain", "plain_gauss"),
"integration",
"HEX8",
)
def _material_flags(self) -> dict[str, str]:
# Default integration is implicit at the kernel side
# (``material.get("_HEX8_INTEGRATION", "full")``); skip writing
# the default so the material dict stays minimal.
if self.integration == "full":
return {}
return {"_HEX8_INTEGRATION": self.integration}
@dataclass(frozen=True)
class HEX20:
"""Quadratic 20-node hexahedron (kernel: ``HEX20``).
Parameters
----------
integration :
``"reduced"`` (default) — 2×2×2 Gauss (8 points). Six
hourglass modes per element; caller's responsibility to
stabilise at mesh level (any connected mesh with shared
midsides absorbs them automatically).
``"full"`` — 3×3×3 Gauss (27 points). Fully ranked
single-element K_e at the cost of 3.4× more flops.
"""
integration: Literal["reduced", "full"] = "reduced"
_kernel_name: ClassVar[str] = "HEX20"
def __post_init__(self) -> None:
_validate_literal(self.integration, ("reduced", "full"), "integration", "HEX20")
def _material_flags(self) -> dict[str, str]:
if self.integration == "reduced":
return {}
return {"_HEX20_INTEGRATION": self.integration}
@dataclass(frozen=True)
class TET10:
"""Quadratic 10-node tetrahedron (kernel: ``TET10``).
No formulation knobs — Keast 4-point rule, fully integrated.
"""
_kernel_name: ClassVar[str] = "TET10"
def _material_flags(self) -> dict[str, str]:
return {}
@dataclass(frozen=True)
class WEDGE15:
"""15-node degenerate wedge (kernel: ``WEDGE15``).
Parameters
----------
integration :
``"bedrosian"`` (default) — Bedrosian 9-point prism rule with
symmetric collocation; the canonical degenerate-quadratic-hex
wedge formulation in the literature.
``"hex_fold"`` — degenerate-from-HEX20 fold via the 8-corner →
6-corner node-collapse, exact for uniform strain fields and
cheaper than Bedrosian; useful for parity tests against
foreign decks that emit hex-folded wedges.
"""
integration: Literal["bedrosian", "hex_fold"] = "bedrosian"
_kernel_name: ClassVar[str] = "WEDGE15"
def __post_init__(self) -> None:
_validate_literal(self.integration, ("bedrosian", "hex_fold"), "integration", "WEDGE15")
def _material_flags(self) -> dict[str, str]:
if self.integration == "bedrosian":
return {}
return {"_WEDGE15_INTEGRATION": self.integration}
@dataclass(frozen=True)
class PYR13:
"""13-node degenerate pyramid (kernel: ``PYR13``).
Parameters
----------
integration :
``"bedrosian"`` (default) — Bedrosian symmetric pyramid rule.
``"hex_fold"`` — degenerate-from-HEX20 fold via the 5-corner
node-collapse; useful for parity against foreign decks that
emit hex-folded pyramids.
pyramid_rule :
``"reduced"`` (default) — 2×2×2 Duffy rule (8 points),
sufficient for the under-integrated pyramid quadrature
commonly seen in foreign decks.
``"keast"`` — Keast 5-point pyramid rule, slightly tighter
polynomial precision but rarely encountered in foreign decks.
``"consistent"`` — 27-point Duffy rule for the full pyramid
quadrature; mathematically more accurate but ~13 % apart from
the reduced rule in Frobenius norm on a single element.
"""
integration: Literal["bedrosian", "hex_fold"] = "bedrosian"
pyramid_rule: Literal["reduced", "keast", "consistent"] = "reduced"
_kernel_name: ClassVar[str] = "PYR13"
def __post_init__(self) -> None:
_validate_literal(self.integration, ("bedrosian", "hex_fold"), "integration", "PYR13")
_validate_literal(
self.pyramid_rule,
("reduced", "keast", "consistent"),
"pyramid_rule",
"PYR13",
)
def _material_flags(self) -> dict[str, str]:
flags: dict[str, str] = {}
if self.integration != "bedrosian":
flags["_PYR13_INTEGRATION"] = self.integration
if self.pyramid_rule != "reduced":
flags["_PYR13_PYRAMID_RULE"] = self.pyramid_rule
return flags
# ---------------------------------------------------------------------
# Beams / trusses / springs / masses
# ---------------------------------------------------------------------
@dataclass(frozen=True)
class BEAM2:
"""Two-node prismatic beam (kernel: ``BEAM2``).
Hermite cubic shape functions; full Euler-Bernoulli theory.
No formulation knobs in this release — Timoshenko shear flexibility
lands later.
"""
_kernel_name: ClassVar[str] = "BEAM2"
def _material_flags(self) -> dict[str, str]:
return {}
@dataclass(frozen=True)
class TRUSS2:
"""Two-node axial-only truss (kernel: ``TRUSS2``).
Single axial DOF per node; no formulation knobs.
"""
_kernel_name: ClassVar[str] = "TRUSS2"
def _material_flags(self) -> dict[str, str]:
return {}
@dataclass(frozen=True)
class SPRING:
"""Two-node linear spring (kernel: ``SPRING``).
Stiffness ``k`` from real-constant set; no formulation knobs.
"""
_kernel_name: ClassVar[str] = "SPRING"
def _material_flags(self) -> dict[str, str]:
return {}
@dataclass(frozen=True)
class POINT_MASS: # noqa: N801 — public spelling matches ELEMENTS namespace
"""Single-node concentrated mass (kernel: ``POINT_MASS``).
Mass ``m`` from real-constant set; no formulation knobs.
"""
_kernel_name: ClassVar[str] = "POINT_MASS"
def _material_flags(self) -> dict[str, str]:
return {}
# ---------------------------------------------------------------------
# Shells / 2D
# ---------------------------------------------------------------------
@dataclass(frozen=True)
class QUAD4_SHELL: # noqa: N801 — public spelling matches ELEMENTS namespace
"""Four-node MITC4 shell (kernel: ``QUAD4_SHELL``).
Mixed-Interpolation-of-Tensorial-Components (MITC4) formulation;
no formulation knobs in this release.
"""
_kernel_name: ClassVar[str] = "QUAD4_SHELL"
def _material_flags(self) -> dict[str, str]:
return {}
@dataclass(frozen=True)
class QUAD4_PLANE: # noqa: N801 — public spelling matches ELEMENTS namespace
"""Four-node 2D plane element (kernel: ``QUAD4_PLANE``).
Parameters
----------
mode :
``"stress"`` (default) — plane-stress assumption, ``σ_zz = 0``.
``"strain"`` — plane-strain assumption, ``ε_zz = 0``.
"""
mode: Literal["stress", "strain"] = "stress"
_kernel_name: ClassVar[str] = "QUAD4_PLANE"
def __post_init__(self) -> None:
_validate_literal(self.mode, ("stress", "strain"), "mode", "QUAD4_PLANE")
def _material_flags(self) -> dict[str, str]:
if self.mode == "stress":
return {}
return {"_QUAD4_PLANE_MODE": self.mode}
# ---------------------------------------------------------------------
# Public namespace
# ---------------------------------------------------------------------
class _ElementsNamespace:
"""Singleton accessor for every shipped element spec.
Exposed as :data:`femorph_solver.ELEMENTS` and
:data:`femorph_solver.elements.ELEMENTS`. Each attribute is an
:class:`ElementSpec` *class* — call it (with optional kwargs) to
get a configured instance, or pass it bare to ``Model.assign`` for
the default configuration.
"""
HEX8 = HEX8
HEX20 = HEX20
TET10 = TET10
WEDGE15 = WEDGE15
PYR13 = PYR13
BEAM2 = BEAM2
TRUSS2 = TRUSS2
QUAD4_SHELL = QUAD4_SHELL
QUAD4_PLANE = QUAD4_PLANE
SPRING = SPRING
POINT_MASS = POINT_MASS
def __repr__(self) -> str:
names = sorted(
n
for n in vars(type(self))
if not n.startswith("_") and isinstance(getattr(type(self), n), type)
)
return f"<femorph_solver.ELEMENTS: {', '.join(names)}>"
ELEMENTS = _ElementsNamespace()
# ``ElementSpec`` is the structural type :meth:`Model.assign` checks for
# at runtime. We don't define an ABC — every spec is just a frozen
# dataclass with a ``_kernel_name`` ClassVar and a ``_material_flags()``
# method. Use this tuple for ``isinstance`` checks.
_SPEC_CLASSES: tuple[type, ...] = (
HEX8,
HEX20,
TET10,
WEDGE15,
PYR13,
BEAM2,
TRUSS2,
QUAD4_SHELL,
QUAD4_PLANE,
SPRING,
POINT_MASS,
)
[docs]
def is_element_spec(obj: object) -> bool:
"""Return ``True`` if ``obj`` is an element-spec **class** or **instance**.
Used by :meth:`Model.assign` to detect the spec form vs. the
legacy string / :class:`~femorph_solver.ElementType` form.
"""
if isinstance(obj, type):
return obj in _SPEC_CLASSES
return isinstance(obj, _SPEC_CLASSES)
# ``"HEX8"`` → :class:`HEX8`, etc. Used by :mod:`femorph_solver.interop`
# readers (NASTRAN BDF, Abaqus INP, OptiStruct FEM) which translate a
# foreign deck's element name into femorph-solver's neutral kernel name
# before passing into :meth:`Model.assign`. Boundary-only — keep the
# spec / class form for everything else.
_BY_NAME: dict[str, type] = {cls._kernel_name: cls for cls in _SPEC_CLASSES}
def spec_by_name(name: str) -> type:
"""Return the spec class registered under the neutral kernel name.
>>> spec_by_name("HEX8")
<class 'femorph_solver.elements._specs.HEX8'>
Used by :mod:`femorph_solver.interop` readers — they translate a
foreign deck's element name into a neutral kernel name and then
pass the spec class to :meth:`Model.assign`. User-facing code
should reach for :data:`ELEMENTS` directly instead.
"""
try:
return _BY_NAME[name]
except KeyError as exc:
raise KeyError(
f"unknown element kernel {name!r}; expected one of: {sorted(_BY_NAME)}"
) from exc
__all__ = [
"BEAM2",
"ELEMENTS",
"HEX8",
"HEX20",
"POINT_MASS",
"PYR13",
"QUAD4_PLANE",
"QUAD4_SHELL",
"SPRING",
"TET10",
"TRUSS2",
"WEDGE15",
"is_element_spec",
"spec_by_name",
]