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
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)
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 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)