← all demos

stress_concentration

A plate with a hole, from outline to the stress concentration at its rim, against Kirsch and Howland.

uv run python examples/cli.py run stress_concentration

The whole pipeline in one row. Left: the mesh after adaptive refinement, grown from 247 triangles to 3384 where the recovery estimator found the most error, with the input outline in blue and the conditions drawn on it; the rim and long edges carry none and are traction-free. Middle: the stress sigma_xx on curved quadratic elements, crowding into the material either side of the hole and relaxing to the applied value within about a diameter. Right: that stress along a strip through the hole centre, peaking at 3.02x the applied value at the rim. The classic Kirsch factor of 3 is for a hole in an infinite plate; Howland's value for a hole 0.10 of this plate's width is 3.02.
The whole pipeline in one row. Left: the mesh after adaptive refinement, grown from 247 triangles to 3384 where the recovery estimator found the most error, with the input outline in blue and the conditions drawn on it; the rim and long edges carry none and are traction-free. Middle: the stress sigma_xx on curved quadratic elements, crowding into the material either side of the hole and relaxing to the applied value within about a diameter. Right: that stress along a strip through the hole centre, peaking at 3.02x the applied value at the rim. The classic Kirsch factor of 3 is for a hole in an infinite plate; Howland's value for a hole 0.10 of this plate's width is 3.02.
outline points           20  (rectangle + polygonalised rim)
initial elements         247
adaptively refined to    3384
worst angle, initial     25.2 deg   (asked for 25)
worst angle, refined     11.5 deg   (red-green carries no angle guarantee)
boundary edges           172   (114 of them the hole rim, up from 16 before refinement)
applied traction         1
hole diameter / height   0.10
peak sigma_xx / applied  3.02   (Howland, finite plate: 3.02; Kirsch, infinite plate: 3)

Source

The functions that pose and solve the problem. The figures are below the fold.

"""A plate with a hole, from outline to the stress concentration at its rim, against
Kirsch and Howland.

The one demo that runs the whole pipeline, so it builds its own mesh: the outline, what
Ruppert's was asked for, and where the conditions went are part of what it shows.
`mesh_plate`, `plate_bc`, and `refine_to_the_rim` each do one step; `run` calls them
and returns a `PlateStudy` of plain results. Nothing here draws: `figures.py` does
that from the `PlateStudy`, and this file is what the gallery shows.
"""
from dataclasses import dataclass

import numpy as np

from fem.analysis.adaptivity import AdaptiveRefinement
from fem.analysis.estimators import RecoveryEstimator
from fem.boundary import Dirichlet, Neumann
from fem.conditions import Conditions
from fem.elements import IsoparametricTriangleElement
from fem.mesh.curves import Circle
from fem.mesh.mesh import Mesh
from fem.mesh.outline import Outline
from fem.mesh.pslg import PSLG
from fem.physics.equations import LinearElastic
from fem.post.solution import ElasticSolution
from fem.regions import intersect, on_plane


def plate_with_hole_outline(length: float = 6.0, height: float = 3.0,
                            radius: float = 0.3) -> Outline:
    """A `length` x `height` plate with a circular hole at its centre.

    Two loops: the outline and the hole, which under the even-odd rule is a hole rather
    than a second region. The hole is a `Circle`, so refinement rounds it and an
    isoparametric solve places its boundary nodes on the true rim.
    """
    plate = np.array([[0.0, 0.0], [length, 0.0], [length, height], [0.0, height]])
    return Outline([Outline.from_polygons([plate]).loops[0],
                    Circle([length / 2, height / 2], radius)])

equation = LinearElastic(E=200, nu=0.3)


def finite_plate_kt(hole_over_width: float) -> float:
    """Howland's stress concentration factor for a circular hole in a finite-width plate
    under tension, relative to the applied (gross) stress. Peterson's polynomial fit
    gives the factor on the net section; dividing by the net fraction of width puts it
    on the applied stress. Reads Kirsch's 3 for a vanishing hole."""
    r = hole_over_width
    net = 3.000 - 3.140 * r + 3.667 * r**2 - 1.527 * r**3
    return net / (1.0 - r)


def rim_facets(mesh: Mesh) -> int:
    """How many boundary facets lie on the hole: `plate_with_hole_outline` draws the hole
    as loop 1, and Ruppert's tags every facet with the loop it came from."""
    assert mesh.boundary_tags is not None
    return int(np.sum(mesh.boundary_tags == 1))


def mesh_plate(length, height, radius, rim_chords, min_angle,
               max_area_fraction) -> tuple[PSLG, Mesh]:
    """The sampled outline and its coarse Ruppert's triangulation.

    The hole is sampled as a coarse `rim_chords`-gon, which is enough: the hole is a
    `Circle`, so Ruppert's split points, red-green refinement, and the isoparametric
    element's edge nodes all land on the true rim. The mesh's `boundary_tags` name the
    rim (loop 1) on every mesh refinement builds from it.
    """
    outline = plate_with_hole_outline(length, height, radius)
    graph = outline.sample(resolution=2 * np.pi * radius / rim_chords / outline.extent)
    # Coarse: resolving the rim is adaptive refinement's job. The rim still grades
    # finer than the interior, since Ruppert's honours its short segments.
    return graph, graph.mesh(min_angle=min_angle, max_area_fraction=max_area_fraction)


def plate_bc(length, traction) -> Conditions:
    """A roller on the left, tension on the right, and nothing on the rim.

    The rim takes no condition: a free surface is the natural boundary condition of
    the weak form, so "traction-free" is what an edge means when nothing is said.

    The left edge is a roller, not a clamp: pinned normal to itself (x = 0), free
    tangentially (y) so the plate can narrow as it stretches. A clamp would resist
    that Poisson contraction and add its own stress concentration, which competes with
    the hole for the estimator's attention. Pinning y along the edge would do the same,
    so a second condition pins y at one corner only, removing the last rigid-body mode.
    The conditions are written against coordinates, so they resolve against whatever
    triangulation arrives, including the ones adaptive refinement rebuilds.
    """
    return Conditions(
        Dirichlet(on_plane(0, 0.0), [0, None]),
        Dirichlet(intersect(on_plane(0, 0.0), on_plane(1, 0.0)), [None, 0]),
        Neumann(on_plane(0, length), [traction, 0]),
    )


def refine_to_the_rim(mesh: Mesh, bc: Conditions, refinement_iters,
                      refinement_budget) -> tuple[Mesh, ElasticSolution]:
    """Solve on the curved quadratic element, adaptively refined by the recovery
    estimator, which reads the curved rim's stress correctly.

    The rim splits project onto the true circle, so more refinement keeps rounding the
    hole. Everything measured afterwards is read off the refined mesh.
    """
    refinement = AdaptiveRefinement(
        mesh, lambda m: equation.problem(m, bc, element_type=IsoparametricTriangleElement),
        RecoveryEstimator(),
        max_triangles=len(mesh.elements) + refinement_budget, max_iters=refinement_iters,
    )
    solution = refinement.run()
    return refinement.mesh, solution


@dataclass
class PlateStudy:
    """Everything `run` computed, for the figures and the summary to read."""
    length: float
    height: float
    radius: float
    traction: float
    min_angle: float
    pslg: PSLG
    bc: Conditions
    n_initial: int                  # triangles before adaptive refinement
    initial_worst_angle: float
    initial_rim_facets: int
    mesh: Mesh                      # after refinement
    solution: ElasticSolution
    sigma_xx: np.ndarray            # nodal stress on the refined mesh
    y_strip: np.ndarray             # the strip through the hole centre, sorted by y
    ratio_strip: np.ndarray         # sigma_xx / traction along it
    peak: float                     # rim sigma_xx / traction

    @property
    def hole_over_width(self) -> float:
        return 2*self.radius / self.height

    @property
    def finite_kt(self) -> float:
        """Howland's finite-plate factor at this hole/width ratio."""
        return finite_plate_kt(self.hole_over_width)

    @property
    def worst_angle(self) -> float:
        """Ruppert's angle guarantee does not survive red-green refinement, which bisects
        existing triangles rather than re-triangulating for shape; reported rather than
        hidden."""
        return self.mesh.min_angle

    @property
    def rim_facets(self) -> int:
        """The rim facets of the refined mesh: the tag survives every split."""
        return rim_facets(self.mesh)


def run(traction=1.0, length=6.0, height=3.0, radius=0.15, min_angle=25,
        max_area_fraction=0.01, rim_chords=16, refinement_iters=36,
        refinement_budget=40000) -> PlateStudy:
    """Mesh the plate, refine into the rim, and read the concentration off it."""
    pslg, mesh = mesh_plate(length, height, radius, rim_chords, min_angle,
                            max_area_fraction)
    n_initial, initial_worst_angle = len(mesh.elements), mesh.min_angle
    initial_rim_facets = rim_facets(mesh)
    bc = plate_bc(length, traction)
    mesh, solution = refine_to_the_rim(mesh, bc, refinement_iters, refinement_budget)

    # The stress at the nodes: each element evaluated at its own nodes and averaged
    # where they meet, so the rim value is read on the rim itself.
    nodes = solution.space.node_coords
    sigma_xx = solution.nodal_stress()[:, 0, 0]

    # A vertical strip through the hole's centre: the line the concentration decays
    # along. The rim crossings are mesh nodes (the 16-gon has a vertex at the top and
    # bottom of the hole, and refinement keeps it), so the peak is the value there.
    strip = np.abs(nodes[:, 0] - length/2) < 0.25*radius
    order = np.argsort(nodes[strip, 1])
    y_strip, ratio_strip = nodes[strip, 1][order], (sigma_xx[strip] / traction)[order]
    on_rim = (np.isclose(nodes[:, 0], length/2)
              & np.isclose(np.abs(nodes[:, 1] - height/2), radius))
    peak = float(sigma_xx[on_rim].max() / traction)

    # Two reference values. Kirsch's factor of 3 is for a hole in an infinite plate.
    # This plate is finite, and the hole removes some of its section, so the remaining
    # material carries slightly more stress and the exact peak is a little above 3.
    # Howland (1930) worked out that finite-width correction for a strip with a central
    # hole; `finite_plate_kt` gives his value at this hole/width ratio, and that is the
    # line the measured peak is judged against.
    #
    # The peak converges to it from below, since a finite element solution is slightly
    # too stiff and the steepest gradient is the last thing it resolves: 2.97, 3.00,
    # 3.00, 3.03 over 624, 970, 1877 and 3301 elements. Thirty-six rounds is enough to
    # agree to within a hundredth.
    return PlateStudy(length, height, radius, traction, min_angle, pslg, bc,
                      n_initial, initial_worst_angle, initial_rim_facets, mesh, solution,
                      sigma_xx, y_strip, ratio_strip, peak)
The figures, and the demo that assembles them
"""The figure and summary of the plate-with-a-hole demo, drawn from a `PlateStudy`."""
from demo_registry import Demo, DemoResult, Figure
from matplotlib.collections import LineCollection

from demos.stress_concentration import physics
from demos.stress_concentration.physics import PlateStudy, run
from fem.plot.plotter import Plotter


def _pipeline_figure(s: PlateStudy) -> Figure:
    # One figure, three plots: mesh with outline and conditions, the stress, the chart.
    figure = Plotter(1, 3, figsize=(14.0, 3.6),
                     title='From an outline to a stress concentration')
    figure.plot(s.mesh, mode='bc', conditions=s.bc, idx=(0, 0),
                title=f'{len(s.mesh.elements)} triangles (refined from {s.n_initial}), '
                      'with conditions')
    ax0 = figure.get_ax((0, 0))
    # The triangulation under the conditions: thin and grey, below the glyphs.
    ax0.triplot(s.mesh.vertices[:, 0], s.mesh.vertices[:, 1], s.mesh.elements,
                color='0.55', linewidth=0.2, zorder=1.5)
    # The input segments over the triangulation: which of the outline the mesher kept.
    ax0.add_collection(LineCollection(
        list(s.pslg.vertices[s.pslg.segments]), colors='blue', linewidths=1.0, zorder=2.0))
    # Passing the solution draws the P2 field on its own tessellation, rim and all.
    figure.plot(s.solution, s.sigma_xx, mode='colored', idx=(0, 1), label='sigma_xx',
                title='Stress concentration (sigma_xx)')
    ax = figure.chart_ax(idx=(0, 2), xlabel='y', ylabel='sigma_xx / applied')
    # Two runs, below the hole and above it, so nothing is drawn across the gap.
    below = s.y_strip < s.height/2
    ax.plot(s.y_strip[below], s.ratio_strip[below], 'o-', color='tab:blue', markersize=2,
            label='through the hole centre')
    ax.plot(s.y_strip[~below], s.ratio_strip[~below], 'o-', color='tab:blue', markersize=2)
    ax.axhline(s.finite_kt, color='tab:red', linestyle='--',
               label=f'finite plate (Howland): {s.finite_kt:.2f}x')
    ax.axhline(3.0, color='tab:red', linestyle=':', label='infinite plate (Kirsch): 3x')
    ax.axhline(1.0, color='gray', linestyle=':', label='far field')
    ax.set_title(f'Peak {s.peak:.2f}x the applied stress')
    ax.grid(alpha=0.3)
    # Over the flat far field, where the legend hides nothing.
    ax.legend(loc='center left', fontsize='small')
    return Figure(
        figure,
        f'The whole pipeline in one row. Left: the mesh after adaptive refinement, '
        f'grown from {s.n_initial} triangles to {len(s.mesh.elements)} where the '
        f'recovery estimator found the most error, with the input outline in blue '
        f'and the conditions drawn on it; the rim and long edges carry none and are '
        f'traction-free. Middle: the stress sigma_xx on curved quadratic elements, '
        f'crowding into the material either side of the hole and relaxing to the '
        f'applied value within about a diameter. Right: that stress along a strip '
        f'through the hole centre, peaking at {s.peak:.2f}x the applied value at the '
        f'rim. The classic Kirsch factor of 3 is for a hole in an infinite plate; '
        f"Howland's value for a hole {s.hole_over_width:.2f} of this plate's width is "
        f'{s.finite_kt:.2f}.')


def _summary(s: PlateStudy) -> str:
    return (f'outline points           {len(s.pslg.vertices)}  '
            f'(rectangle + polygonalised rim)\n'
            f'initial elements         {s.n_initial}\n'
            f'adaptively refined to    {len(s.mesh.elements)}\n'
            f'worst angle, initial     {s.initial_worst_angle:.1f} deg   '
            f'(asked for {s.min_angle})\n'
            f'worst angle, refined     {s.worst_angle:.1f} deg   '
            f'(red-green carries no angle guarantee)\n'
            f'boundary edges           {len(s.mesh.boundary)}   '
            f'({s.rim_facets} of them the hole rim, up from {s.initial_rim_facets} before '
            f'refinement)\n'
            f'applied traction         {s.traction:.3g}\n'
            f'hole diameter / height   {s.hole_over_width:.2f}\n'
            f'peak sigma_xx / applied  {s.peak:.2f}   '
            f'(Howland, finite plate: {s.finite_kt:.2f}; Kirsch, infinite plate: 3)')


def demo(**kwargs) -> DemoResult:
    """A plate with a hole, from outline to the stress concentration at its rim, against
    Kirsch and Howland."""
    s = run(**kwargs)
    return DemoResult([_pipeline_figure(s)], text=_summary(s))


# The pipeline demo builds its own domain, from an outline through to a stress.
DEMO = Demo('stress_concentration', demo, section='Solids & structures',
            smoke_kwargs={'max_area_fraction': 0.05, 'refinement_iters': 3,
                          'refinement_budget': 200},
            show_source=physics)