← all demos

pressurized_cylinder

A thick-walled cylinder pressurized past first yield: the plastic front marches outward through the wall, tracked against Hill's classical solution.

uv run python examples/cli.py run pressurized_cylinder

One quarter of a thick-walled cylinder (wall ratio 2) under rising internal pressure, colored by von Mises stress in units of the yield stress, so 1.0 on the scale is the flow stress. Elastic at 46% of the limit pressure, the classic Lame decay from the bore. Past first yield the bore cannot be stressed harder: raising the pressure instead recruits more wall, a flat band pinned at 1.0 spreading outward to the dashed front. The right chart tracks that front against Hill's classical solution (measured within 2% of the wall thickness; the small lag is the slight hardening Hill's perfectly plastic wall lacks), and it steepens toward the limit: near collapse, a little more pressure sweeps the front through a lot of wall.
One quarter of a thick-walled cylinder (wall ratio 2) under rising internal pressure, colored by von Mises stress in units of the yield stress, so 1.0 on the scale is the flow stress. Elastic at 46% of the limit pressure, the classic Lame decay from the bore. Past first yield the bore cannot be stressed harder: raising the pressure instead recruits more wall, a flat band pinned at 1.0 spreading outward to the dashed front. The right chart tracks that front against Hill's classical solution (measured within 2% of the wall thickness; the small lag is the slight hardening Hill's perfectly plastic wall lacks), and it steepens toward the limit: near collapse, a little more pressure sweeps the front through a lot of wall.

Why the limit pressure is failure: the pressure is held by the integral of the stress difference sigma_theta - sigma_r across the wall, and a yielded ring contributes at most its capped flow value. When the front reaches the outer surface every ring is at its cap, the integral has hit its maximum 2k ln(b/a), and no stress state balances a higher pressure: the wall flows without bound. Failure here is equilibrium running out, not material breaking.

First yield is therefore not failure: this vessel carries 85% more pressure after the bore yields, with essentially no material hardening. The reserve is pure geometry, 2 ln(b/a) / (1 - a^2/b^2): a thick wall holds under-stressed material for the redistribution to recruit, while for a thin wall the ratio tends to 1 and first yield and collapse coincide. Sizing a thick vessel by first yield alone understates its static capacity by that factor; the same contained-yielding argument gives a beam its plastic hinge reserve.

The material is Ramberg-Osgood deformation plasticity with a sharp hardening exponent, near elastic-perfectly-plastic, valid here because the pressurization is monotonic. What it cannot do is unload: the compressive residual stress left by overpressurizing and releasing (autofrettage, the trick behind gun barrels and high-pressure vessels), and the cyclic checks real vessel codes add on top of static capacity, live in the loading history, which takes flow-theory plasticity.

wall ratio b/a            2.00
elements                  1625 (curved quadratic)
first yield pressure      0.4330   (Hill: k (1 - a^2/b^2), k = sigma_y/sqrt(3))
limit pressure            0.8004   (Hill: 2k ln(b/a))
reserve past first yield  1.85x   (geometry: 2 ln(b/a) / (1 - a^2/b^2); -> 1 for a thin wall)
pressures swept           0.368 .. 0.776 (8 solves, each seeded with the last)
front vs Hill             within 2.0% of the wall (hardening exponent 100)

Source

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

"""A thick-walled cylinder pressurized past first yield, against Hill's solution.

Below first yield the wall is Lame's elastic annulus, most stressed at the bore. Push
the pressure past that and the bore cannot carry more: it yields, and a plastic front
marches outward through the wall as the pressure rises, until the whole section flows
at the limit pressure. Hill's classical elastic-plastic solution gives the pressure
that holds the front at radius c,

    p(c) = k * (2 ln(c/a) + 1 - c^2/b^2),      k = sigma_y / sqrt(3),

so first yield is p(a) = k (1 - a^2/b^2) and the limit pressure p(b) = 2k ln(b/a):
the curve the measured front is judged against, the way the plate demo is judged
against Kirsch and Howland.

The material is Ramberg-Osgood deformation plasticity with a sharp hardening exponent
(near elastic-perfectly-plastic), which is valid here because the pressurization is
monotonic; each pressure is an independent Newton solve, seeded with the previous one.
Hill assumes a perfectly plastic, plastically incompressible wall, so the measured
front tracks his curve to within a few percent, not exactly. What this model cannot do
is unload: the residual stress an overpressurized vessel keeps (autofrettage, the
point of doing this to a real vessel) lives in the loading history, which is
flow-theory plasticity's territory.

One quarter of the annulus is modelled, rollers on the two cut edges standing in for
the symmetry, with curved quadratic elements on both arcs. `quarter_annulus` builds
the outline, `pressurize` solves the sweep, and `run` returns a `CylinderStudy` of
plain results; `figures.py` draws it.
"""
from dataclasses import dataclass

import numpy as np

from fem.algebra.solve import BacktrackingLineSearch, NewtonSolve
from fem.boundary import Dirichlet, Neumann
from fem.conditions import Conditions, Initial
from fem.elements import IsoparametricTriangleElement
from fem.mesh.curves import Arc, Line
from fem.mesh.mesh import Mesh
from fem.mesh.outline import Outline
from fem.physics.equations import DeformationPlasticity
from fem.post.solution import ElasticSolution
from fem.regions import on_plane

E, NU = 1000.0, 0.3


def hill_pressure(c, inner, outer, k):
    """Hill's pressure holding the plastic front at radius `c` (see module docstring).

    At `c = inner` this is the first-yield pressure and at `c = outer` the limit
    pressure; elementwise in `c`.
    """
    c = np.asarray(c, dtype=float)
    return k * (2.0 * np.log(c / inner) + 1.0 - (c / outer) ** 2)


def quarter_annulus(inner: float, outer: float) -> Outline:
    """One quarter of the annulus, the two arcs joined by radial cuts on the axes.

    Both arcs are `Arc` pieces, so Ruppert's split points and the isoparametric edge
    nodes land on the true circles: the bore the pressure loads is round, not a
    chord polygon.
    """
    quarter = np.pi / 2.0
    return Outline([[
        Line([inner, 0.0], [outer, 0.0]),
        Arc([0.0, 0.0], outer, 0.0, quarter),
        Line([0.0, outer], [0.0, inner]),
        Arc([0.0, 0.0], inner, 0.0, quarter).reversed(),
    ]])


def cylinder_bc(inner: float, pressure: float) -> Conditions:
    """Rollers on the two cut edges (the symmetry planes), pressure on the bore.

    The pressure is the traction `p * r_hat` on the bore surface: it pushes the wall
    radially outward, written as a callable of position so it follows the arc. The
    outer surface carries nothing and is traction-free. On the axes the traction's
    tangential component is exactly zero, so it never loads the component the roller
    there pins.
    """
    def outward(points):
        radial = points / np.linalg.norm(points, axis=1, keepdims=True)
        # The arc endpoint on the y axis sits at x = cos(pi/2) ~ 6e-17, and a traction
        # that is nonzero there in x would (rightly) be refused as driving the rolled
        # component; roundoff of the true zero is snapped back to it.
        radial[np.abs(radial) < 1e-12] = 0.0
        return pressure * radial

    def bore(points):
        return np.hypot(points[:, 0], points[:, 1]) <= inner * (1.0 + 1e-6)

    return Conditions(
        Dirichlet(on_plane(0, 0.0), [0, None]),
        Dirichlet(on_plane(1, 0.0), [None, 0]),
        Neumann(bore, outward),
    )


def plastic_front(solution: ElasticSolution, yield_stress: float, inner: float,
                  threshold: float = 0.98) -> float:
    """The radius the yielding has reached: the outermost element at flow stress.

    With near-perfectly-plastic hardening the whole plastic zone sits at von Mises
    stress ~ yield, approached from *below* as the exponent sharpens (in the perfectly
    plastic limit nothing exceeds it at all), so the front is read at a hair under
    yield: `threshold` times it. Read per element (the centroid radius of every
    element past the threshold), so its resolution is the local element size. `inner`
    when nothing has yielded.
    """
    radii = np.hypot(*solution.mesh.centroids.T)
    yielded = solution.von_mises > threshold * yield_stress
    return float(radii[yielded].max()) if bool(yielded.any()) else inner


def pressurize(mesh: Mesh, inner: float, metal: DeformationPlasticity,
               pressures: np.ndarray) -> list[ElasticSolution]:
    """Solve the sweep, each pressure seeded with the last solution.

    Deformation theory is history-free, so every pressure is its own equilibrium and
    the seed is only a head start for Newton; the answers do not depend on the order.
    """
    space = metal.space(mesh, element_type=IsoparametricTriangleElement)
    newton = NewtonSolve(line_search=BacktrackingLineSearch())
    solutions: list[ElasticSolution] = []
    previous: ElasticSolution | None = None
    for pressure in pressures:
        problem = metal.problem(space, cylinder_bc(inner, float(pressure)))
        seed = None if previous is None else Initial(previous)
        previous = problem.solve(strategy=newton, initial=seed)
        solutions.append(previous)
    return solutions


@dataclass
class CylinderStudy:
    """Everything `run` computed, for the figures and the summary to read."""
    inner: float
    outer: float
    yield_stress: float
    k: float                     # sigma_y / sqrt(3), Hill's shear-yield scale
    hardening_exponent: float
    mesh: Mesh
    pressures: np.ndarray        # the sweep, ascending
    fronts: np.ndarray           # measured plastic front radius at each pressure
    showcase: list[tuple[float, float, ElasticSolution, np.ndarray]]   # (p, front, solution, nodal vm)

    @property
    def first_yield(self) -> float:
        """The pressure at which the bore first yields: Hill's p(a)."""
        return float(hill_pressure(self.inner, self.inner, self.outer, self.k))

    @property
    def limit_pressure(self) -> float:
        """The pressure at which the whole wall flows: Hill's p(b)."""
        return float(hill_pressure(self.outer, self.inner, self.outer, self.k))

    @property
    def reserve(self) -> float:
        """Limit pressure over first-yield pressure: the post-yield capacity the
        redistribution buys. Pure geometry, `2 ln(b/a) / (1 - a^2/b^2)`: large for a
        thick wall with under-stressed material to recruit, 1 in the thin-wall limit,
        where first yield and collapse coincide."""
        return self.limit_pressure / self.first_yield

    @property
    def hill_fronts(self) -> np.ndarray:
        """Hill's front radius at each swept pressure, `inner` below first yield.

        `p(c)` is monotone in `c`, so the front is read by bisection; vectorized over
        the sweep by looping, which at a handful of pressures costs nothing.
        """
        from scipy.optimize import brentq
        fronts = []
        for p in self.pressures:
            if p <= self.first_yield:
                fronts.append(self.inner)
                continue
            # `p` is bound as a default: brentq calls within the iteration, but the
            # binding keeps the lambda correct if it ever outlives it.
            fronts.append(brentq(
                lambda c, p=p: float(hill_pressure(c, self.inner, self.outer, self.k)) - p,
                self.inner, self.outer))
        return np.asarray(fronts)


def run(inner=1.0, outer=2.0, yield_stress=1.0, hardening_exponent=100.0,
        n_pressures=8, min_angle=28, max_area_fraction=0.001,
        resolution=0.02) -> CylinderStudy:
    """Mesh the quarter annulus, sweep the pressure past first yield toward the limit,
    and measure the plastic front at every level.

    The sweep starts below first yield (the front sits at the bore) and stops just
    short of the limit pressure, where the equilibrium exists only because the
    hardening curve still rises.
    """
    outline = quarter_annulus(inner, outer)
    mesh = outline.sample(resolution).mesh(min_angle=min_angle,
                                           max_area_fraction=max_area_fraction)
    metal = DeformationPlasticity(E, NU, yield_stress, hardening_exponent)
    k = yield_stress / np.sqrt(3.0)
    first_yield = float(hill_pressure(inner, inner, outer, k))
    limit = float(hill_pressure(outer, inner, outer, k))
    pressures = np.linspace(0.85 * first_yield, 0.97 * limit, n_pressures)

    solutions = pressurize(mesh, inner, metal, pressures)
    fronts = np.array([plastic_front(s, yield_stress, inner) for s in solutions])
    showcase = [(float(pressures[i]), float(fronts[i]), solutions[i],
                 solutions[i].nodal_von_mises())
                for i in (0, (len(solutions) - 1) // 2, len(solutions) - 1)]
    return CylinderStudy(inner, outer, yield_stress, k, hardening_exponent,
                         mesh, pressures, fronts, showcase)
The figures, and the demo that assembles them
"""The figure and summary of the pressurized-cylinder demo, drawn from a `CylinderStudy`."""
import numpy as np
from demo_registry import Demo, DemoResult, Figure

from demos.pressurized_cylinder import physics
from demos.pressurized_cylinder.physics import CylinderStudy, hill_pressure, run
from fem.plot.plotter import Plotter


def _yield_figure(s: CylinderStudy) -> Figure:
    # Three snapshots of the wall, colored by von Mises stress in units of the yield
    # stress so 1.0 *is* the flow stress: the plastic zone reads directly as the
    # flat band at 1.0, and the measured front is drawn on it as a dashed arc.
    figure = Plotter(1, 4, figsize=(15.0, 3.6),
                     title='A pressure vessel yielding from the bore outward')
    top = max(float(vm.max()) for _, _, _, vm in s.showcase) / s.yield_stress
    for i, (p, front, solution, vm) in enumerate(s.showcase):
        yielded = front > s.inner
        state = (f'plastic to r = {front / s.inner:.2f}a' if yielded
                 else f'elastic (peak {vm.max() / s.yield_stress:.2f})')
        figure.plot(solution, vm / s.yield_stress, mode='colored', idx=(0, i),
                    label='von Mises / yield', clim=(0.0, top),
                    colorbar=(i == len(s.showcase) - 1),
                    title=f'p = {p / s.limit_pressure:.0%} of limit: {state}')
        if yielded:
            theta = np.linspace(0.0, np.pi / 2.0, 100)
            figure.get_ax((0, i)).plot(front * np.cos(theta), front * np.sin(theta),
                                       'w--', linewidth=1.2)

    ax = figure.chart_ax(idx=(0, 3), xlabel='pressure / limit pressure',
                         ylabel='plastic front radius / bore radius')
    c = np.linspace(s.inner, s.outer, 200)
    ax.plot(hill_pressure(c, s.inner, s.outer, s.k) / s.limit_pressure, c / s.inner,
            color='tab:red', linestyle='--', label='Hill (perfectly plastic)')
    ax.plot(s.pressures / s.limit_pressure, s.fronts / s.inner, 'o',
            color='tab:blue', label='measured front')
    ax.axvline(s.first_yield / s.limit_pressure, color='gray', linestyle=':',
               label='first yield at the bore')
    ax.axvline(1.0, color='black', linestyle=':',
               label='limit: whole wall flows')
    ax.axhline(s.outer / s.inner, color='gray', linestyle='-.',
               label='outer surface')
    # The takeaway number, written where the reserve it names actually is: between
    # first yield and collapse, below the curve where nothing else is drawn.
    middle = 0.5 * (s.first_yield / s.limit_pressure + 1.0)
    ax.annotate(f'+{100 * (s.reserve - 1):.0f}% pressure\nafter first yield',
                xy=(middle, 1.0 + 0.02 * (s.outer / s.inner - 1.0)),
                ha='center', va='bottom', fontsize='small', color='black')
    worst = float(np.abs(s.fronts - s.hill_fronts).max()) / (s.outer - s.inner)
    ax.set_title(f'Front within {100 * worst:.0f}% of the wall of Hill')
    ax.grid(alpha=0.3)
    ax.legend(loc='upper left', fontsize='small')
    return Figure(
        figure,
        f'One quarter of a thick-walled cylinder (wall ratio '
        f'{s.outer / s.inner:.0f}) under rising internal pressure, colored by von '
        f'Mises stress in units of the yield stress, so 1.0 on the scale is the flow '
        f'stress. Elastic at {s.showcase[0][0] / s.limit_pressure:.0%} of the limit '
        f'pressure, the classic Lame decay from the bore. Past first yield the bore '
        f'cannot be stressed harder: raising the pressure instead recruits more '
        f'wall, a flat band pinned at 1.0 spreading outward to the dashed front. '
        f'The right chart tracks that front against Hill\'s classical solution '
        f'(measured within {100 * worst:.0f}% of the wall thickness; the small lag '
        f'is the slight hardening Hill\'s perfectly plastic wall lacks), and it '
        f'steepens toward the limit: near collapse, a little more pressure sweeps '
        f'the front through a lot of wall.',
        body=[
            'Why the limit pressure is failure: the pressure is held by the integral '
            'of the stress difference sigma_theta - sigma_r across the wall, and a '
            'yielded ring contributes at most its capped flow value. When the front '
            'reaches the outer surface every ring is at its cap, the integral has '
            'hit its maximum 2k ln(b/a), and no stress state balances a higher '
            'pressure: the wall flows without bound. Failure here is equilibrium '
            'running out, not material breaking.',

            f'First yield is therefore not failure: this vessel carries '
            f'{100 * (s.reserve - 1):.0f}% more pressure after the bore yields, with '
            f'essentially no material hardening. The reserve is pure geometry, '
            f'2 ln(b/a) / (1 - a^2/b^2): a thick wall holds under-stressed material '
            f'for the redistribution to recruit, while for a thin wall the ratio '
            f'tends to 1 and first yield and collapse coincide. Sizing a thick '
            f'vessel by first yield alone understates its static capacity by that '
            f'factor; the same contained-yielding argument gives a beam its plastic '
            f'hinge reserve.',

            'The material is Ramberg-Osgood deformation plasticity with a sharp '
            'hardening exponent, near elastic-perfectly-plastic, valid here because '
            'the pressurization is monotonic. What it cannot do is unload: the '
            'compressive residual stress left by overpressurizing and releasing '
            '(autofrettage, the trick behind gun barrels and high-pressure vessels), '
            'and the cyclic checks real vessel codes add on top of static capacity, '
            'live in the loading history, which takes flow-theory plasticity.',
        ])


def _summary(s: CylinderStudy) -> str:
    errors = np.abs(s.fronts - s.hill_fronts) / (s.outer - s.inner)
    return (f'wall ratio b/a            {s.outer / s.inner:.2f}\n'
            f'elements                  {len(s.mesh.elements)} (curved quadratic)\n'
            f'first yield pressure      {s.first_yield:.4f}   '
            f'(Hill: k (1 - a^2/b^2), k = sigma_y/sqrt(3))\n'
            f'limit pressure            {s.limit_pressure:.4f}   (Hill: 2k ln(b/a))\n'
            f'reserve past first yield  {s.reserve:.2f}x   '
            f'(geometry: 2 ln(b/a) / (1 - a^2/b^2); -> 1 for a thin wall)\n'
            f'pressures swept           {s.pressures[0]:.3f} .. {s.pressures[-1]:.3f} '
            f'({len(s.pressures)} solves, each seeded with the last)\n'
            f'front vs Hill             within {100 * errors.max():.1f}% of the wall '
            f'(hardening exponent {s.hardening_exponent:.0f})')


def demo(**kwargs) -> DemoResult:
    """A thick-walled cylinder pressurized past first yield: the plastic front marches
    outward through the wall, tracked against Hill's classical solution."""
    s = run(**kwargs)
    return DemoResult([_yield_figure(s)], text=_summary(s))


# Builds its own quarter-annulus domain, so the curved bore carries the pressure.
DEMO = Demo('pressurized_cylinder', demo, section='Solids & structures',
            smoke_kwargs={'n_pressures': 3, 'max_area_fraction': 0.02,
                          'resolution': 0.08},
            show_source=physics)