Source code for femorph_solver.result.section_recovery

"""Section-based stress recovery for beam-like 2D / 3D structures.

Direct nodal stress evaluation in 2D plane / 3D solid FE is *not*
the right answer at corners that carry an applied-displacement
singularity (the classic "Saint-Venant corner" — the FE stress
field there grows without bound under refinement, and beam theory
doesn't even define a value).  MAPDL's verification-manual
PLANE-element rows pay this tax: VM5's published bending-stress
target ``7 407 psi`` is the analytical beam-theory limit, and
MAPDL on the as-written deck reports a ratio of ~1.03–1.05 — i.e.
MAPDL also doesn't reach the analytical at the singular corner.

This module ships **statics-based section recovery** that
reproduces the analytical target to FE-residual precision: sum the
applied loads on one side of a cross-section to obtain the
section's resultant axial force ``N`` and bending moment ``M_z``,
then return σ at the requested fiber via the textbook beam-theory
formula::

    σ_xx(x_section, y_fiber) = N(x_section) / A
                              − M_z(x_section) · (y_fiber − ȳ) / I_z

The section properties (``A``, ``ȳ``, ``I_z``) are computed from
the mesh geometry along the requested cross-section.  For
statically determinate problems (cantilever, simply-supported,
3-force / 4-force frames, …) ``N`` and ``M_z`` come directly from
the applied-force boundary conditions and are *exact*; for
indeterminate problems the FE-recovered nodal reactions enter the
sum and propagate any solver residual down to the recovered
stress.  Either way, recovery is independent of mesh resolution
and the corner-singularity tax direct nodal evaluation pays.

This is **not** a feature MAPDL ships as a one-line recovery
verb; it's a Beam-of-Materials-section primitive added on top of
the FE solution.

References
----------
* Crandall, S.H., Dahl, N.C., Lardner, T.J., *An Introduction to
  the Mechanics of Solids*, 2nd ed., McGraw-Hill, 1978, §7.2
  (sectional bending recovery for beams).
* Cook, R.D., Malkus, D.S., Plesha, M.E., Witt, R.J., *Concepts
  and Applications of Finite Element Analysis*, 4th ed., Wiley,
  2002, §13.6 (stress singularities + recovery alternatives).
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np


[docs] @dataclass(frozen=True) class SectionResultants: """Cross-section resultants from statics-based recovery. Attributes ---------- n_axial : float Axial force ``N`` through the section, summed from applied loads / reactions on the lower-coordinate side of the section. m_bending : float Bending moment ``M_z`` about the section's centroidal z-axis. area : float Section area ``A`` (geometric). centroid_y : float Section centroid ``ȳ`` (in the section-perpendicular coordinate, e.g. global y for a section perpendicular to x). i_section : float Second moment of area ``I_z`` about the centroidal z-axis. section_height : float Section depth ``h`` (max coord − min coord on the section). """ n_axial: float m_bending: float area: float centroid_y: float i_section: float section_height: float
[docs] def stress_at(self, fiber_y: float) -> float: """σ at fiber ``y`` via beam theory ``σ = N/A − M·(y−ȳ)/I``. Sign convention matches MAPDL's ``σ_xx`` reporting on ``ETABLE`` / ``*GET,...,S,X``: positive ``σ`` is tension, negative is compression. """ c = float(fiber_y) - self.centroid_y return self.n_axial / self.area - self.m_bending * c / self.i_section
[docs] def section_bending_stress( model, *, x_section: float, fiber_y: float, axis: str = "x", ) -> float: """Return the bending stress at ``(x_section, fiber_y)``. Convenience wrapper around :func:`section_resultants` that takes the resultants and returns ``σ`` at the requested fiber via :meth:`SectionResultants.stress_at`. """ res = section_resultants(model, x_section=x_section, axis=axis) return res.stress_at(fiber_y)
[docs] def section_resultants( model, *, x_section: float, axis: str = "x", ) -> SectionResultants: """Compute resultants ``(N, M_z, A, ȳ, I_z, h)`` at a cross-section. Iterates the mesh once: identifies the y-range of the section by intersecting it with each crossing element's edges, sums applied loads on the lower-``axis`` side of the section, and returns the section property tuple. Parameters ---------- model : :class:`femorph_solver.Model` The post-solve model. x_section : float Coordinate of the section in the named axis. E.g. for a cantilever along ``x`` with a section at the fixed end, ``x_section = 75.0``. axis : str Which global axis the section is perpendicular to. Currently ``"x"`` is wired up — ``"y"`` and ``"z"`` are roadmap. """ if axis.strip().lower() != "x": raise NotImplementedError( f"section_resultants: axis={axis!r}; only axis='x' is wired up " "in the current release. File a follow-up issue if you need " "y / z section perpendiculars." ) grid = model.grid nums = np.asarray(grid.point_data["ansys_node_num"]) pts = np.asarray(grid.points) conn = grid.cell_connectivity offsets = grid.offset # Find the y-range of the section by intersecting with each crossing # element's edges. section_y_min = +np.inf section_y_max = -np.inf for e_idx in range(grid.n_cells): cell_ids = conn[offsets[e_idx] : offsets[e_idx + 1]] elem_pts = pts[cell_ids] xs = elem_pts[:, 0] if not (xs.min() <= x_section <= xs.max()): continue n = len(cell_ids) for i in range(n): xi, yi = elem_pts[i, 0], elem_pts[i, 1] xj, yj = elem_pts[(i + 1) % n, 0], elem_pts[(i + 1) % n, 1] crosses = (xi - x_section) * (xj - x_section) <= 0.0 distinct = abs(xi - xj) > 1e-12 if crosses and distinct: t = (x_section - xi) / (xj - xi) y_cross = yi + t * (yj - yi) section_y_min = min(section_y_min, y_cross) section_y_max = max(section_y_max, y_cross) if not np.isfinite(section_y_min) or not np.isfinite(section_y_max): raise ValueError( f"section_resultants: no element crosses x = {x_section} in the current mesh" ) h = section_y_max - section_y_min y_bar = 0.5 * (section_y_min + section_y_max) # Section width: assume the real-constant thickness applies (PLANE # under-the-hood). Fallback to unit thickness if no real constant # is set. All elements crossing the section share the same real id # in well-formed beam-like meshes; mixed-real sections raise. real_ids: set[int] = set() for e_idx in range(grid.n_cells): cell_ids = conn[offsets[e_idx] : offsets[e_idx + 1]] elem_pts = pts[cell_ids] if elem_pts[:, 0].min() <= x_section <= elem_pts[:, 0].max(): real_ids.add(int(grid.cell_data["ansys_real_constant"][e_idx])) if len(real_ids) > 1: raise NotImplementedError( f"section_resultants: section straddles elements with mixed " f"real-constant ids {sorted(real_ids)}; merging cross-section " "thickness across real ids is roadmap" ) if real_ids: rid = next(iter(real_ids)) rcs = model._real_constants.get(rid, np.array([1.0])) b = float(rcs[0]) if rcs.size > 0 else 1.0 else: b = 1.0 area = b * h i_section = b * h**3 / 12.0 # Sum applied F-BCs on x < x_section. Map dof index to (fx, fy): # dof 0 = UX → applies in +x as FX # dof 1 = UY → applies in +y as FY n_axial = 0.0 m_z = 0.0 for (node, dof_idx), force in model._force_bc.items(): x_node, y_node, _ = pts[int(np.where(nums == int(node))[0][0])] if x_node >= x_section - 1e-9: continue if dof_idx == 0: fx, fy = float(force), 0.0 elif dof_idx == 1: fx, fy = 0.0, float(force) else: # ROTX / ROTY / ROTZ etc. carry no in-plane force contribution. continue n_axial += fx m_z += (x_node - x_section) * fy - (y_node - y_bar) * fx return SectionResultants( n_axial=float(n_axial), m_bending=float(m_z), area=float(area), centroid_y=float(y_bar), i_section=float(i_section), section_height=float(h), )