PeriDEM 0.3.0
PeriDEM -- Peridynamics-based high-fidelity model for granular media
Loading...
Searching...
No Matches
problem.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -------------------------------------------
3# Copyright (c) 2021 - 2026 Prashant K. Jha
4# -------------------------------------------
5# PeriDEM https://github.com/prashjha/PeriDEM
6#
7# Distributed under the Boost Software License, Version 1.0. (See accompanying
8# file LICENSE)
9
10"""Silling 2003 Kalthoff-Winkler notched-plate impact, 2D and 3D, from Python.
11
12A 200 x 100 mm maraging-steel plate with two open 1.5 mm notches is struck
13edge-on by a rigid 1.57 kg cylinder at 32 m/s. Silling reports cracks running
14from the notch tips at roughly 900 m/s and about 70 degrees to the notch.
15
16This example uses the parts of the interface a pure JSON deck cannot reach:
17
18* ``sim.setup()`` builds the model, then ``sim.break_bonds_in_slots(...)``
19 seeds the pre-notch by cutting the peridynamic bonds that span each slot --
20 the same operation the C++ driver does between ``init()`` and the time loop;
21* ``sim.integrate()`` then runs the loop *without* re-initialising, or
22 :func:`run_with_arrival_times` drives the loop step by step from Python to
23 record when each node first becomes damaged, which gives the crack speed.
24
25``Test_PeriDEM_notched_impact_inbuilt`` builds the same deck in C++.
26``python/tests/test_example_parity.py`` compares the two decks key by key.
27"""
28
29from __future__ import annotations
30
31import os
32import sys
33from pathlib import Path
34
35HERE = Path(__file__).resolve().parent
36for _p in HERE.parents:
37 if (_p / "examples" / "peridem_env.py").is_file():
38 sys.path.insert(0, str(_p / "examples"))
39 break
40import peridem_env # noqa: F401,E402
41
42import numpy as np # noqa: E402
43import peridem # noqa: E402
44from peridem import Deck, Geometry # noqa: E402
45from peridem.deck import MeshSpec, contact_stiffness # noqa: E402
46
47# Silling Fig. 2 geometry.
48W, H = 0.200, 0.100
49NOTCH_DEPTH = 0.050
50NOTCH_HALF = 0.025 # notch centrelines at x = +/- 25 mm
51NOTCH_W = 0.0015 # 1.5 mm open gap
52THICKNESS = 0.009 # 3D only
53
54# Impactor: 1.57 kg cylinder, as wide as the 50 mm ligament.
55IW, IH = 0.050, 0.100
56IMPACT_V = 32.0
57IMPACTOR_MASS = 1.57
58
59MESH_SIZE = 0.001 # Silling's 200 x 100 x 9 grid
60RC_FACTOR = 0.95
61
62# Table 2 M1 = maraging steel; Gc from KIc ~ 90 MPa sqrt(m).
63RHO = 8000.0
64E = 191.0e9
65K_BULK = 159.2e9
66GC = 42408.0
67
68DT = 2.5e-9
69FINAL_TIME = 1.7e-4 # 170 us
70
71TAGS = ["Displacement", "Velocity", "Force", "Damage", "Damage_Bond",
72 "Damage_Z", "Particle_ID"]
73
74# The --quick settings: the same problem on a smaller, softer plate.
75QUICK = dict(W=0.040, H=0.020, notch_half=0.005, mesh_size=0.020 / 16.0,
76 Iw=0.008, Ih=0.004, rho=1200.0, E=1.23e9, K_bulk=2.0e9, Gc=424.0,
77 dt=1.0e-8, final_time=1.0e-4)
78
79
80def _nu(E_: float, K_: float) -> float:
81 return 0.5 * (1.0 - E_ / (3.0 * K_))
82
83
84def notch_void_boxes(h: float, notch_half: float, notch_w: float,
85 notch_depth: float, z_lo: float,
86 z_hi: float) -> list[list[float]]:
87 """The two notch slots as boxes, open at the top edge."""
88 hw = 0.5 * notch_w
89 y_tip = 0.5 * h - notch_depth
90 y_hi = 0.5 * h + 1.0e-9
91 return [[-notch_half - hw, y_tip, z_lo, -notch_half + hw, y_hi, z_hi],
92 [notch_half - hw, y_tip, z_lo, notch_half + hw, y_hi, z_hi]]
93
94
95def build_deck(output_path: str | os.PathLike[str] = "runs/out/", *,
96 dim3: bool = False, quick: bool = False,
97 final_time: float | None = None, dt: float | None = None,
98 num_steps: int | None = None,
99 mesh_dir: str | os.PathLike[str] | None = None,
100 v_impact: float = IMPACT_V) -> Deck:
101 cfg = dict(W=W, H=H, notch_half=NOTCH_HALF, mesh_size=MESH_SIZE, Iw=IW,
102 Ih=IH, rho=RHO, E=E, K_bulk=K_BULK, Gc=GC, dt=DT,
103 final_time=FINAL_TIME)
104 if quick:
105 cfg.update(QUICK)
106 cfg["notch_depth"] = 0.5 * cfg["H"]
107 cfg["notch_w"] = max(cfg["mesh_size"], 0.1 * cfg["notch_half"])
108 else:
109 cfg["notch_depth"] = NOTCH_DEPTH
110 cfg["notch_w"] = NOTCH_W
111
112 if final_time is not None:
113 cfg["final_time"] = final_time
114 if dt is not None:
115 cfg["dt"] = dt
116 n_steps = (int(round(cfg["final_time"] / cfg["dt"])) if num_steps is None
117 else num_steps)
118
119 w, h = cfg["W"], cfg["H"]
120 mesh_size = cfg["mesh_size"]
121 horizon = 3.0 * mesh_size
122 notch_half, notch_w, notch_depth = (cfg["notch_half"], cfg["notch_w"],
123 cfg["notch_depth"])
124 iw, ih = cfg["Iw"], cfg["Ih"]
125 gap = 1.5 * RC_FACTOR * mesh_size
126 rho, e_mod, k_bulk, gc = cfg["rho"], cfg["E"], cfg["K_bulk"], cfg["Gc"]
127 nu = _nu(e_mod, k_bulk)
128 g_mod = e_mod / (2.0 * (1.0 + nu))
129
130 d = Deck(dim=3 if dim3 else 2, t_final=cfg["final_time"], n_steps=n_steps,
131 # Element-node connectivity is only used for strain output and
132 # does not handle the hexahedra the 3D structured grid produces.
133 populate_element_node_connectivity=not dim3,
134 self_contact="none", bond_break="tension", wall_contact="meshed")
135
136 d.set_output(output_path, tags=TAGS, interval=max(1, n_steps // 10),
137 debug=1, perform_fe_out=False, dt_test_out=n_steps,
138 pvd_collection=False)
139 d.set_neighbor(update_criteria="simple_all", s_factor=5.0, update_interval=1,
140 near_bd_nodes_tol=0.5)
141
142 # --- bodies
143 if dim3:
144 plate = Geometry("cuboid", [-0.5 * w, -0.5 * h, -0.5 * THICKNESS,
145 0.5 * w, 0.5 * h, 0.5 * THICKNESS])
146 # Cylinder axis along the impact direction, flat face striking the edge.
147 impactor = Geometry("cylinder", [0.5 * iw, 0.0, -0.5 * ih, 0.0,
148 0.0, ih, 0.0])
149 z_lo, z_hi = -0.5 * THICKNESS - 1.0e-9, 0.5 * THICKNESS + 1.0e-9
150 else:
151 plate = Geometry("rectangle",
152 [-0.5 * w, -0.5 * h, 0.0, 0.5 * w, 0.5 * h, 0.0])
153 impactor = Geometry("rectangle",
154 [-0.5 * iw, -0.5 * ih, 0.0, 0.5 * iw, 0.5 * ih, 0.0])
155 z_lo, z_hi = -1.0e-9, 1.0e-9
156
157 voids = notch_void_boxes(h, notch_half, notch_w, notch_depth, z_lo, z_hi)
158 base = Path(mesh_dir) if mesh_dir is not None else HERE / "runs/inp"
159 # The plate sits on Silling's equally spaced structured grid with the notch
160 # slots carved out, so they are real gaps rather than cut material.
161 g_plate = d.add_particle_type(
162 plate, MeshSpec(file=base / "mesh_plate.msh", size=mesh_size,
163 info="uniform", voids=voids))
164 # In 2D the impactor is a rectangle, so it goes on the same grid; in 3D it
165 # is a cylinder, which a uniform grid cannot represent.
166 g_imp = d.add_particle_type(
167 impactor,
168 MeshSpec(file=base / "mesh_impactor.msh", size=mesh_size,
169 info="gmsh_builtin_mesh" if dim3 else "uniform"))
170
171 # The impactor has the plate material with Gc = 0. Its nodal forces are
172 # replaced by the rigid-body acceleration, so its bonds carry no load.
173 m_plate = d.add_material(material_type="PMBBond", horizon=horizon,
174 density=rho, K=k_bulk, G=g_mod, Gc=gc, E=e_mod,
175 influence_fn_type=0, influence_fn_params=[1.0])
176 m_imp = d.add_material(material_type="PDElasticBond", horizon=horizon,
177 density=rho, K=k_bulk, G=g_mod, Gc=0.0, E=e_mod,
178 influence_fn_type=0, influence_fn_params=[1.0])
179
180 # Contact force density is Kn * V_j * overlap, so Kn carries one power of
181 # the horizon per spatial dimension of the nodal weight: 5 in 3D, 4 in 2D.
182 kn = contact_stiffness(k_bulk, k_bulk, horizon,
183 horizon_power=5 if dim3 else 4)
184 for i, j in ((0, 0), (0, 1), (1, 1)):
185 d.add_contact_pair(i, j, contact_radius=RC_FACTOR * mesh_size,
186 Kn=kn, eps=1.0, damping_on=False, friction_on=False,
187 beta_n_factor=0.0, K=k_bulk)
188 d.set_contact_laws(damping_law="off", friction_law="coulomb_simple")
189
190 # The plate carries no load on its boundary. The displacement condition
191 # constrains the impactor to move along y.
192 d.add_displacement_bc(particles=[1], direction=[1],
193 time_fn_type="constant", time_fn_params=[0.0],
194 spatial_fn_type="constant", zero_displacement=True)
195 d.add_initial_velocity([0.0, -v_impact, 0.0], particles=[1])
196
197 # In two dimensions the mass is per unit thickness of the 9 mm plate.
198 d.add_rigid_particle(1, IMPACTOR_MASS if dim3 else IMPACTOR_MASS / THICKNESS)
199
200 d.place(0.0, 0.0, 0.0, geometry=g_plate, material=m_plate, contact=0)
201 d.place(0.0, 0.5 * h + 0.5 * ih + gap, 0.0, geometry=g_imp,
202 material=m_imp, contact=1)
203
204 # Used by seed_prenotch and crack_speed below.
205 d.extra_info = {"H": h, "notch_half": notch_half, "notch_w": notch_w,
206 "notch_depth": notch_depth, "mesh_size": mesh_size,
207 "horizon": horizon, "n_steps": n_steps,
208 "dt": cfg["dt"], "final_time": cfg["final_time"]}
209 return d
210
211
212def seed_prenotch(sim, info: dict[str, float]) -> int:
213 """Cut the peridynamic bonds that span the two notch slots.
214
215 The slots are already absent from the mesh, but the horizon is twice the
216 slot width, so bonds still reach across them. Must run after ``setup()``.
217 """
218 y_top = 0.5 * info["H"]
219 y_tip = y_top - info["notch_depth"]
220 n = sim.break_bonds_in_slots(
221 [-info["notch_half"], info["notch_half"]], info["notch_w"],
222 y_tip, y_top + 0.01 * info["H"], 0)
223 if n < 10:
224 raise RuntimeError(f"expected pre-notch bonds to cut, got {n}")
225 return n
226
227
228def run_with_arrival_times(sim, info: dict[str, float], *,
229 threshold: float = 0.30) -> np.ndarray:
230 """Drive the time loop from Python, recording first-damage time per node.
231
232 The arrival-time field is what gives the crack speed Silling reports.
233 Returns an array of length n_nodes, -1 where the node never damaged.
234 """
235 n_nodes = sim.n_nodes
236 arrival = np.full(n_nodes, -1.0)
237 plate = np.asarray(sim.particle_id) == 0
238 sample_every = max(1, int(info["n_steps"]) // 400)
239
240 def sample() -> None:
241 fresh = (arrival < 0.0) & plate & (sim.damage >= threshold)
242 arrival[fresh] = sim.time
243
244 sim.apply_initial_condition()
245 if sim.perform_output:
246 sim.write_output()
247 sim.set_current_dt(sim.dt)
248 sim.apply_displacement_bc()
249 sim.compute_forces()
250 sim.apply_rigid_body_constraint()
251 while sim.step_index < sim.n_steps:
252 sim.step()
253 if sim.should_output:
254 sim.write_output()
255 if sim.step_index % sample_every == 0:
256 sample()
257 sim.check_stop()
258 if sim.stopped:
259 break
260 sample()
261 return arrival
262
263
264def crack_speed(sim, arrival: np.ndarray,
265 info: dict[str, float]) -> tuple[float, int, float] | None:
266 """Least-squares crack-tip speed from the arrival-time field.
267
268 Fits distance-from-the-right-notch-tip against arrival time over damaged
269 plate nodes below the tip (where the crack runs) and within half the plate
270 height of it. Returns (m/s, points used, correlation).
271
272 The returned correlation states how well the fit holds. If damage spreads
273 through the plate instead of advancing as a front, the fit has no meaning
274 and the correlation is low. The ``--quick`` settings produce that, on a
275 plate of 40 by 20 mm with E = 1.23 GPa.
276 """
277 y_tip = 0.5 * info["H"] - info["notch_depth"]
278 tip = np.array([info["notch_half"], y_tip])
279 x = np.asarray(sim.reference)
280 seen = (arrival >= 0.0) & (np.asarray(sim.particle_id) == 0)
281 dist = np.linalg.norm(x[:, :2] - tip, axis=1)
282 # The crack runs into the plate, below the notch tip.
283 band = seen & (dist < 0.5 * info["H"]) & (x[:, 1] < y_tip)
284 n = int(band.sum())
285 if n < 20:
286 return None
287 t = arrival[band]
288 r_dist = dist[band]
289 if np.ptp(t) <= 0.0:
290 return None
291 slope, _ = np.polyfit(t, r_dist, 1)
292 r = float(np.corrcoef(t, r_dist)[0, 1])
293 return float(slope), n, r
294
295
296def main(argv: list[str] | None = None) -> int:
297 import argparse
298
299 p = argparse.ArgumentParser(description=__doc__)
300 p.add_argument("-o", "--output", default=None)
301 p.add_argument("--dim3", action="store_true", help="Silling's 3D plate")
302 p.add_argument("--quick", action="store_true",
303 help="small, soft, short variant for a smoke run")
304 p.add_argument("--final-time", type=float, default=None)
305 p.add_argument("--num-steps", type=int, default=None)
306 p.add_argument("--arrival-times", action="store_true",
307 help="drive the loop from Python and report crack speed")
308 p.add_argument("--nthreads", type=int,
309 default=int(os.environ.get("NTHREADS", "8")))
310 p.add_argument("--snapshot", metavar="PNG", default=None)
311 p.add_argument("--write-deck", metavar="PATH", default=None)
312 args = p.parse_args(argv)
313
314 tag = "silling3d" if args.dim3 else ("quick" if args.quick else "silling2d")
315 base = Path(args.output or (HERE / "runs_py" / tag)).resolve()
316 out, inp = base / "out", base / "inp"
317
318 d = build_deck(str(out) + "/", dim3=args.dim3, quick=args.quick,
319 final_time=args.final_time, num_steps=args.num_steps,
320 mesh_dir=inp)
321 if args.write_deck:
322 print(d.write(args.write_deck))
323 return 0
324
325 out.mkdir(parents=True, exist_ok=True)
326 inp.mkdir(parents=True, exist_ok=True)
327 info = d.extra_info
328
329 peridem.init(n_threads=args.nthreads)
330 sim = d.simulation(workdir=HERE)
331 sim.setup()
332 n_pre = seed_prenotch(sim, info)
333 print(f"{tag}: nodes={sim.n_nodes} prenotch bonds cut={n_pre} "
334 f"h={info['mesh_size']:.3e} horizon={info['horizon']:.3e} "
335 f"Nt={info['n_steps']}")
336
337 if args.arrival_times:
338 arrival = run_with_arrival_times(sim, info)
339 n_damaged = int((arrival >= 0.0).sum())
340 print(f"damaged nodes: {n_damaged} of {sim.n_nodes}")
341 fit = crack_speed(sim, arrival, info)
342 if fit is None:
343 print("crack speed: too few damaged nodes ahead of the tip to fit")
344 else:
345 v, n_fit, r = fit
346 note = "" if abs(r) > 0.9 else " (poor fit; damage is not " \
347 "running as a front)"
348 print(f"crack speed: {v:.1f} m/s "
349 f"[{n_fit} nodes, r = {r:.2f}]{note}")
350 else:
351 sim.integrate()
352 sim.close()
353
354 print(f"final: step={sim.step_index} t={sim.time:g} "
355 f"max damage={float(sim.damage.max()):.4f}")
356 if args.snapshot:
357 print(peridem.snapshot(peridem.last_vtu(out), args.snapshot,
358 color="Damage_Z", title=f"{tag}: damage"))
359 print(f"VTU under {out}")
360 return 0
361
362
363if __name__ == "__main__":
364 raise SystemExit(main())
tuple[float, int, float]|None crack_speed(sim, np.ndarray arrival, dict[str, float] info)
Definition problem.py:265
np.ndarray run_with_arrival_times(sim, dict[str, float] info, *float threshold=0.30)
Definition problem.py:229
float _nu(float E_, float K_)
Definition problem.py:80
list[list[float]] notch_void_boxes(float h, float notch_half, float notch_w, float notch_depth, float z_lo, float z_hi)
Definition problem.py:86
int seed_prenotch(sim, dict[str, float] info)
Definition problem.py:212
Deck build_deck(str|os.PathLike[str] output_path="runs/out/", *str preset="short", float|None final_time=None, int|None num_steps=None, int|None output_interval=None, float omega=OMEGA, str|os.PathLike[str] mesh_dir=MESH_DIR, str|os.PathLike[str] csv=CSV)
Definition problem.py:132