Source code for femorph_solver.elements._specs

"""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", ]