From geometry to crack propagation: a square-plate SENT simulation

This tutorial performs a fresh phase-field fracture calculation with PhAST in the conventional finite-element order:

  1. install and verify the software;

  2. load the geometry;

  3. generate and inspect the mesh and named boundaries;

  4. define the material and phase-field model;

  5. apply loads and boundary conditions;

  6. select a supported analysis and solver route;

  7. run the coupled problem with visible progress; and

  8. inspect displacement, strain, stress, and damage and animate crack growth.

The model is the 40 mm by 40 mm B3 single-edge-notched tension (SENT) plate. A V-notch extends from the left boundary to (x=20) mm. This is a compact teaching calculation derived from the public B3 input, not a new benchmark validation or a mesh/time-step convergence study.

The calculation uses a CPU; no GPU is required. Installation, meshing, solving, and rendering have separate costs, and no portable runtime is claimed.

The B3 input retains its schema-v1 compatibility format to preserve the matched public example. For the single saved schema-v2 configuration workflow, use the standard problem-setup walkthrough. This notebook does not extend the schema-v2 solver capability boundary.

0. Install PhAST by the portable source route

Use Python >=3.10 with compatible dependency wheels. For a new source checkout:

git clone https://github.com/CEMS-Lab/PhAST.git
cd PhAST
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e . jupyterlab
python tools/run_sanitizer.py
python -m phast doctor
jupyter lab docs/tutorial/notebook_square_plate_fracture.ipynb

On Windows PowerShell, activate with .venv\Scripts\Activate.ps1. Conda and Docker routes are in the installation guide. The base CPU installation is sufficient; optional HPC backends are not required.

For an existing checkout or a documentation build, use that checkout and its prepared environment. Do not clone or install an older release over it. The clone command above selects public main, which is mutable; local changes are not assumed to be published there.

from __future__ import annotations
import copy
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys

from IPython.display import HTML, Image, Markdown, display
import matplotlib.pyplot as plt
import matplotlib.tri as mtri
from matplotlib.animation import FuncAnimation, PillowWriter
import numpy as np
import yaml


def find_repository_root(start: Path) -> Path:
    # Locate a source checkout from the notebook working directory.
    start = start.resolve()
    for candidate in (start, *start.parents):
        if (candidate / "pyproject.toml").is_file() and (candidate / "examples").is_dir():
            return candidate
    raise RuntimeError(
        "Run this notebook from a cloned PhAST repository. "
        "See the installation commands above."
    )

ROOT = find_repository_root(Path.cwd())
os.chdir(ROOT)
sys.path.insert(0, str(ROOT / "src"))
os.environ["PYTHONPATH"] = str(ROOT / "src") + os.pathsep + os.environ.get("PYTHONPATH", "")

import phast
from phast.core.mesh import FEMMesh
from phast.physics.material import Material

plt.rcParams.update({"font.family": "STIXGeneral", "mathtext.fontset": "stix"})

def require_finite(values, name):
    """Reject empty or non-finite data rather than disguising a failed solve."""
    array = np.asarray(values)
    if array.size == 0 or not np.isfinite(array).all():
        raise ValueError(
            f"Empty or non-finite values in {name}; inspect the solver log "
            "before plotting or interpreting this run."
        )
    return array

output_root = Path(os.environ.get("PHAST_NOTEBOOK_OUTPUT_ROOT", ROOT / "runs"))
base = output_root.expanduser().resolve() / "tutorial_square_plate"
RUN_DIR = base
suffix = 2
while RUN_DIR.exists():
    RUN_DIR = Path(f"{base}_{suffix}")
    suffix += 1
RUN_DIR.mkdir(parents=True)

GEO_SOURCE = Path("examples/dynamic/B3_dynamic_sent/mesh.geo")
BASE_CONFIG = Path("examples/dynamic/B3_dynamic_sent/config.yaml")
GEO_PATH = RUN_DIR / "mesh.geo"
MESH_PATH = RUN_DIR / "mesh.msh"
CONFIG_PATH = RUN_DIR / "config.yaml"

print(f"Repository: {ROOT.name}")
print(f"Tutorial output: {RUN_DIR}")
print(f"PhAST import: {Path(phast.__file__).resolve().relative_to(ROOT)}")
Repository: PhAST
Tutorial output: /home/runner/work/PhAST/PhAST/runs/tutorial_square_plate
PhAST import: src/phast/__init__.py

1. Load the geometry and identify its boundaries

The checked-in Gmsh file defines the square, the left-edge notch, a refinement line along the expected crack corridor, and named physical curves. The exterior sides are top, bottom, left, and right; the notch faces are notch_upper and notch_lower. These names connect geometry to boundary conditions without coordinate-based node selection.

shutil.copy2(GEO_SOURCE, GEO_PATH)
geo_text = GEO_PATH.read_text(encoding="utf-8")
print(f"Loaded geometry: {GEO_PATH}")
print("\nGeometry entities and physical groups:\n")
for line in geo_text.splitlines():
    stripped = line.strip()
    if stripped.startswith(("Point(", "Line(", "Physical Curve", "Physical Surface")):
        print(stripped)
Loaded geometry: /home/runner/work/PhAST/PhAST/runs/tutorial_square_plate/mesh.geo

Geometry entities and physical groups:

Point(1) = {0, 0, 0, h_coarse};               // bottom-left
Point(2) = {L, 0, 0, h_coarse};               // bottom-right
Point(3) = {L, L, 0, h_coarse};               // top-right
Point(4) = {0, L, 0, h_coarse};               // top-left
Point(5) = {0, L/2 + 0.001, 0, h_crack};   // notch mouth upper
Point(6) = {0, L/2 - 0.001, 0, h_crack};   // notch mouth lower
Point(7) = {a, L/2, 0, h_crack};              // notch tip
Point(8) = {L, L/2, 0, h_crack};              // crack path end
Line(1) = {1, 2};   // bottom
Line(2) = {2, 8};   // right-lower
Line(3) = {8, 3};   // right-upper
Line(4) = {3, 4};   // top
Line(5) = {4, 5};   // left-upper (above notch)
Line(6) = {5, 7};   // notch upper lip
Line(7) = {7, 6};   // notch lower lip
Line(8) = {6, 1};   // left-lower (below notch)
Line(9) = {7, 8};   // crack path (expected propagation)
Physical Curve("bottom") = {1};
Physical Curve("right") = {2, 3};
Physical Curve("top") = {4};
Physical Curve("left") = {5, 8};
Physical Curve("notch_upper") = {6};
Physical Curve("notch_lower") = {7};
Physical Surface("plate") = {1, 2};

2. Generate and inspect the finite-element mesh

Gmsh creates first-order triangles with refinement around the notch and expected horizontal crack path. PhAST reads the mesh through meshio and converts the physical curves into named node sets. Fracture resolution is assessed relative to (\ell_0), not only by visual appearance.

gmsh_executable = shutil.which("gmsh")
if gmsh_executable is None:
    raise RuntimeError(
        "Gmsh was not found. Reinstall the PhAST environment or follow "
        "the Gmsh recovery advice in docs/install.md."
    )

completed = subprocess.run(
    [gmsh_executable, str(GEO_PATH), "-2", "-format", "msh2", "-o", str(MESH_PATH)],
    cwd=ROOT,
    text=True,
    capture_output=True,
    timeout=60,
)
if completed.returncode != 0:
    raise RuntimeError(completed.stderr or completed.stdout)

mesh = FEMMesh(str(MESH_PATH), device="cpu")
nodes = require_finite(mesh.nodes.detach().cpu().numpy(), "mesh coordinates")
elements = mesh.elements.detach().cpu().numpy()
triangulation = mtri.Triangulation(nodes[:, 0], nodes[:, 1], elements)

boundary_colours = {
    "top": "#2f6f9f", "bottom": "#2f6f9f",
    "left": "#4f4f4f", "right": "#4f4f4f",
    "notch_upper": "#b24a3b", "notch_lower": "#b24a3b",
}
fig, ax = plt.subplots(figsize=(8.2, 7.0), constrained_layout=True)
ax.triplot(triangulation, color="#c8c8c8", linewidth=0.35)
for name, colour in boundary_colours.items():
    indices = mesh.node_sets[name].detach().cpu().numpy()
    points = nodes[indices]
    ax.scatter(points[:, 0], points[:, 1], s=13, color=colour, label=name, zorder=3)
ax.set(aspect="equal", xlabel="x [mm]", ylabel="y [mm]",
       title="Generated mesh and named boundary node sets")
ax.legend(ncol=3, loc="upper center", bbox_to_anchor=(0.5, -0.09), frameon=False)
plt.show()
print(mesh.summary())
[FEMMesh] Loading mesh: /home/runner/work/PhAST/PhAST/runs/tutorial_square_plate/mesh.msh
[FEMMesh] Mesh file read OK
[FEMMesh] 1091 nodes loaded
[FEMMesh] 1940 T3 elements loaded
[FEMMesh] Node sets: ['bottom', 'right', 'top', 'left', 'notch_upper', 'notch_lower']
[FEMMesh] Precomputing areas, grad_phi, incircle, lumped mass...
[FEMMesh] Precompute done: h_min=7.787059e-02, total_area=1599.980000
FEMMesh: /home/runner/work/PhAST/PhAST/runs/tutorial_square_plate/mesh.msh
  1091 nodes, 1940 elements
  h_min=7.787059e-02, total_area=1599.980000
  Node set 'bottom': 21 nodes
  Node set 'right': 25 nodes
  Node set 'top': 21 nodes
  Node set 'left': 26 nodes
  Node set 'notch_upper': 77 nodes
  Node set 'notch_lower': 77 nodes

3. Define the material and phase-field model

Quantity

Symbol

Value

Young’s modulus

(E)

32,000 MPa

Poisson ratio

(\nu)

0.20

Density

(\rho)

(2.45\times10^{-9}) tonne/mm(^3)

Critical fracture energy

(G_c)

0.003 N/mm

Phase-field length

(\ell_0)

0.5 mm

Residual stiffness

(\eta)

(10^{-7})

Model and energy split

AT2, spectral

Kinematics

plane strain

Here (d=0) denotes intact material and (d=1) fully developed damage. These parameters are retained for the teaching example; they are not calibration recommendations for another material.

source_config = yaml.safe_load(BASE_CONFIG.read_text(encoding="utf-8"))
m = source_config["material"]
material = Material(
    E=m["E"], nu=m["nu"], Gc=m["Gc"], l0=m["l0"], rho=m["rho"],
    eta_residual=m["eta_residual"], energy_split=m["energy_split"],
    pf_model=m["pf_model"], plane_stress=m.get("plane_stress", False),
)
h_min = float(mesh.h_min)
print(material)
print(f"h_min / l0 = {h_min / float(material.l0):.3f}")
Material (AT2, spectral split, plane strain):
  E=32000.0 MPa, nu=0.2, rho=2.45e-09
  Gc=0.003 N/mm, l0=0.5 mm
  Gc/l0=0.0, Gc*l0=0.001500
  kappa=17777.8, mu=13333.3, lam=8888.9
  c_p=3809523.81, c_s=2332847.37, c_R=2119003.03
h_min / l0 = 0.156

4. Apply loads and boundary conditions

Region

Condition

left

(u_x=0)

right

(u_x=0)

top

prescribed (u_y=+0.002) mm

bottom

prescribed (u_y=-0.002) mm

notch faces

traction-free geometric boundaries

The vertical displacement follows a smooth ramp over the first 20 microseconds and is then held. The signs pull the upper and lower boundaries apart.

display(Markdown(
    "~~~yaml\n"
    + yaml.safe_dump(
        {"boundary_conditions": source_config["boundary_conditions"],
         "loading": source_config["loading"]},
        sort_keys=False,
    )
    + "~~~"
))
boundary_conditions:
- nodes: left
  type: fix
  component: 0
- nodes: right
  type: fix
  component: 0
- nodes: top
  type: prescribe
  component: 1
  value: 0.002
- nodes: bottom
  type: prescribe
  component: 1
  value: -0.002
loading:
  protocol: simple
  t_total: 0.0001
  ramp_type: smooth_step
  t_ramp: 2.0e-05

5. Select the analysis and solver route

Dynamic versus quasi-static describes treatment of inertia. Explicit versus implicit describes how a subproblem is advanced or solved. They are not four interchangeable choices.

Route

Current public status

Use here

Dynamic mechanics, explicit central difference

Supported

Selected

Quasi-static mechanics and damage solves

Supported in separate examples

Not selected

Implicit dynamic generalized-(\alpha) mechanics

Not a production route

Not selectable

Fully explicit damage evolution

Not this classical route

Not selectable

Mechanics is advanced explicitly with a lumped mass matrix. At every time increment, PhAST updates the tensile history and solves the current AT2 damage equation with matrix-free, Jacobi-preconditioned conjugate gradients. This is an implicit damage update, not implicit dynamics.

For fixed history (H), the AT2 damage equation on this route is linear in (d). The overall evolution remains coupled because mechanics changes (H) and damage changes the degraded stiffness used next:

[ (\mathbf{u}n,\mathbf{v}n,\mathbf{a}n,d_n) \rightarrow \mathbf{u}{n+1} \rightarrow H{n+1} \rightarrow d{n+1} \rightarrow (\mathbf{v}{n+1},\mathbf{a}{n+1}). ]

This partitioned sequence differs from repeated alternate-minimization iterations within a quasi-static load increment. The CFL condition controls explicit time-step stability; the damage tolerance controls residual convergence of the linear damage solve. One cannot replace the other.

6. Choose a CFL-safe step count and write the configuration

Changing only the explicit step count while retaining an unrelated time increment is unsafe. This notebook computes

[ \Delta t = 0.8,h_{\min}/c_p, ]

then selects 6,101 increments and sets the end time consistently. Final index 6,100 is divisible by the snapshot interval of 50, so the last stored field is the actual final solver step. The runner writes the result file result.vtkhdf by default, and an explicit run stores its fields at the same interval (output.h5_every); the trajectory store training_data.h5 is requested separately by this configuration. The portable Jacobi damage route requires no optional sparse or HPC backend.

NUM_STEPS = 6101
SNAPSHOT_EVERY = 50
PRINT_EVERY = 100
DT_SAFETY = 0.8

dt = DT_SAFETY * h_min / float(material.c_p)
t_total = NUM_STEPS * dt
assert (NUM_STEPS - 1) % SNAPSHOT_EVERY == 0

config = copy.deepcopy(source_config)
config["problem"]["name"] = "Square-plate SENT: executable teaching calculation"
config["geometry"] = {"mesh_path": "mesh.msh"}
config["loading"].update({"num_steps": NUM_STEPS, "dt": dt, "t_total": t_total})
config["solver"].update({
    "solver_type": "explicit",
    "time_integrator": "central_difference",
    "dt_safety": DT_SAFETY,
    "damage_every": 1,
    "use_multigrid": False,
    "preconditioner": "jacobi",
    "damage_max_iter": 1000,
})
config["output"].update({
    "trajectory": True,
    "h5": True,
    "trajectory_format": "h5",
    "h5_every": SNAPSHOT_EVERY,
    "print_every": PRINT_EVERY,
    "plots": False,
    "gif": False,
    "profile": True,
})
config["device"] = {"device": "cpu", "compile": False}
CONFIG_PATH.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8")

print(f"Configuration: {CONFIG_PATH}")
print("Selected route: dynamic / explicit mechanics / implicit AT2 damage")
print(f"Number of steps: {NUM_STEPS}")
print(f"Stable dt: {dt:.6e} s")
print(f"End time: {t_total * 1e6:.3f} microseconds")
print(f"Stored snapshots: {(NUM_STEPS - 1) // SNAPSHOT_EVERY + 1}")
Configuration: /home/runner/work/PhAST/PhAST/runs/tutorial_square_plate/config.yaml
Selected route: dynamic / explicit mechanics / implicit AT2 damage
Number of steps: 6101
Stable dt: 1.635282e-08 s
End time: 99.769 microseconds
Stored snapshots: 123

7. Validate, then run with actual progress

–validate-only checks the YAML schema; it does not solve the PDE or establish scientific validity. The progress bar below is driven by actual step lines emitted by PhAST, not an elapsed-time estimate. The log prints the resolved device, integrator, phase-field model, history update, damage route, bounds treatment, and preconditioner.

preflight = subprocess.run(
    [sys.executable, "-m", "phast", "run", str(CONFIG_PATH), "--validate-only"],
    cwd=ROOT, text=True, capture_output=True, timeout=60,
)
print(preflight.stdout, end="")
print(preflight.stderr, end="")
if preflight.returncode != 0:
    raise RuntimeError("Configuration preflight failed.")
OK: /home/runner/work/PhAST/PhAST/runs/tutorial_square_plate/config.yaml passes schema validation.
if any((RUN_DIR / name).exists()
       for name in ("result.vtkhdf", "training_data.h5", "training_data.zarr")):
    raise RuntimeError(
        f"{RUN_DIR} already contains a result. Restart the kernel to create "
        "a new numbered directory rather than overwriting evidence."
    )

run_command = [
    sys.executable, "-m", "phast", "run", str(CONFIG_PATH),
    "--device", "cpu", "--output_dir", str(RUN_DIR),
]
progress = display(
    HTML(f"<progress value='0' max='{NUM_STEPS}' style='width:70%'></progress> "
         f"0 / {NUM_STEPS} steps"),
    display_id=True,
)
process = subprocess.Popen(
    run_command, cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
    text=True, bufsize=1,
)
assert process.stdout is not None
for line in process.stdout:
    print(line, end="")
    match = re.search(r"Step\s+(\d+)", line)
    if match:
        completed_step = int(match.group(1)) + 1
        progress.update(
            HTML(f"<progress value='{completed_step}' max='{NUM_STEPS}' "
                 f"style='width:70%'></progress> "
                 f"{completed_step:,} / {NUM_STEPS:,} steps")
        )
return_code = process.wait()
if return_code != 0:
    raise RuntimeError(f"PhAST exited with status {return_code}.")
progress.update(
    HTML(f"<progress value='{NUM_STEPS}' max='{NUM_STEPS}' "
         f"style='width:70%'></progress> "
         f"{NUM_STEPS:,} / {NUM_STEPS:,} steps complete")
)
6,101 / 6,101 steps complete
============================================================
  Square-plate SENT: executable teaching calculation
  Borden et al. (2012), CMAME
  Config: /home/runner/work/PhAST/PhAST/runs/tutorial_square_plate/config.yaml
  Output: /home/runner/work/PhAST/PhAST/runs/tutorial_square_plate
============================================================
[device] Context: device=cpu, dtype=torch.float64, compile=False, profile=True
[FEMMesh] Loading mesh: /home/runner/work/PhAST/PhAST/runs/tutorial_square_plate/mesh.msh

[FEMMesh] Mesh file read OK
[FEMMesh] 1091 nodes loaded
[FEMMesh] 1940 T3 elements loaded
[FEMMesh] Node sets: ['bottom', 'right', 'top', 'left', 'notch_upper', 'notch_lower']
[FEMMesh] Precomputing areas, grad_phi, incircle, lumped mass...
[FEMMesh] Precompute done: h_min=7.787059e-02, total_area=1599.980000
Saved: run_lockfile.json
Dynamic integrator: central_difference
Material (AT2, spectral split, plane strain):
  E=32000.0 MPa, nu=0.2, rho=2.45e-09
  Gc=0.003 N/mm, l0=0.5 mm
  Gc/l0=0.0, Gc*l0=0.001500
  kappa=17777.8, mu=13333.3, lam=8888.9
  c_p=3809523.81, c_s=2332847.37, c_R=2119003.03
FEMMesh: /home/runner/work/PhAST/PhAST/runs/tutorial_square_plate/mesh.msh
  1091 nodes, 1940 elements
  h_min=7.787059e-02, total_area=1599.980000
  Node set 'bottom': 21 nodes
  Node set 'right': 25 nodes
  Node set 'top': 21 nodes
  Node set 'left': 26 nodes
  Node set 'notch_upper': 77 nodes
  Node set 'notch_lower': 77 nodes

------------------------------------------------------------------
  Pre-simulation diagnostic (set SKIP_PRECHECK=1 to hide)
------------------------------------------------------------------
  Wave speeds      : c_p=3810  c_s=2333  c_R=2119  m/s
  CFL time step    : dt_CFL=20.441 ns  (×0.8 safety = 16.353 ns)
  Step count       : n_steps=6,101  (source: YAML num_steps)
  Resolution       : h_min=0.0779 mm, h/l0=0.16 (GOOD), elems/(2l0)=12
  Subcycling bound : N_max = floor(c_p/(0.6*c_R)) = 2  (current damage_every=1)
------------------------------------------------------------------
[StaggeredSolver] Assembling solver (explicit)...
[FEMOperators] Initializing (spectral split, AT2)...
[FEMOperators] dt_CFL = 2.044103e-08 (c_p=3809523.81)
[PhaseFieldDamageSolver] Initializing CG solver (model=AT2, tol=1.0e-05, max_iter=1000, bounds=post_clamp)...
[PhaseFieldDamageSolver] CG device: cpu, dtype: float64
[PhaseFieldDamageSolver] Ready (preconditioner=jacobi).
[StaggeredSolver] Dynamic(central_difference): dt=1.635282e-08
[StaggeredSolver] State allocated: 1091 nodes, 1940 elements, device=cpu, dtype=torch.float64
[PhAST route]
  device=cpu; dtype=torch.float64; elements=T3
  mechanics=ExplicitDynamics; solver_type=explicit; time_integrator=central_difference; backend_requested=auto; backend_resolved=at first solve
  fracture=AT2; energy_split=spectral; history_update=hard_max; bounds=post_clamp; preconditioner=jacobi
findfont: Failed to find font weight semibold for DejaVu Sans, now using 700.
findfont: Failed to find font weight semibold for DejaVu Sans, now using 700.
findfont: Failed to find font weight semibold for DejaVu Sans, now using 700.
findfont: Failed to find font weight medium for DejaVu Sans Mono, now using 400.
HDF5 snapshots: /home/runner/work/PhAST/PhAST/runs/tutorial_square_plate/training_data.h5

Running 6101 explicit steps, dt=1.6353e-08

[result] result.vtkhdf: fields every 50 increment(s), history every increment
  Step     0 | t=  0.00us | max(d)=0.00000 | 6.2ms
  Step     1 | t=  0.02us | max(d)=0.00000 | 2.2ms
  Step     2 | t=  0.03us | max(d)=0.00000 | 2.0ms
  Step     3 | t=  0.05us | max(d)=0.00000 | 2.0ms
  Step     4 | t=  0.07us | max(d)=0.00000 | 2.0ms
  Step   100 | t=  1.64us | max(d)=0.00069 | 2.2ms
  Step   200 | t=  3.27us | max(d)=0.00255 | 1.9ms
  Step   300 | t=  4.91us | max(d)=0.00483 | 2.6ms
  Step   400 | t=  6.54us | max(d)=0.00699 | 3.3ms
  Step   500 | t=  8.18us | max(d)=0.03041 | 3.6ms
  Step   600 | t=  9.81us | max(d)=0.09903 | 3.5ms
  Step   700 | t= 11.45us | max(d)=0.22433 | 3.2ms
  Step   800 | t= 13.08us | max(d)=0.44004 | 3.2ms
  Step   900 | t= 14.72us | max(d)=0.86801 | 3.4ms
  ** Crack at step 935 **
  Step  1000 | t= 16.35us | max(d)=1.00000 | 2.8ms
  Step  1100 | t= 17.99us | max(d)=1.00000 | 3.1ms
  Step  1200 | t= 19.62us | max(d)=1.00000 | 2.8ms
  Step  1300 | t= 21.26us | max(d)=1.00000 | 2.8ms
  Step  1400 | t= 22.89us | max(d)=1.00000 | 2.5ms
  Step  1500 | t= 24.53us | max(d)=1.00000 | 2.5ms
  Step  1600 | t= 26.16us | max(d)=1.00000 | 2.6ms
  Step  1700 | t= 27.80us | max(d)=1.00000 | 2.6ms
  Step  1800 | t= 29.44us | max(d)=1.00000 | 2.6ms
  Step  1900 | t= 31.07us | max(d)=1.00000 | 2.2ms
  Step  2000 | t= 32.71us | max(d)=1.00000 | 2.1ms
  Step  2100 | t= 34.34us | max(d)=1.00000 | 2.1ms
  Step  2200 | t= 35.98us | max(d)=1.00000 | 2.0ms
  Step  2300 | t= 37.61us | max(d)=1.00000 | 2.1ms
  Step  2400 | t= 39.25us | max(d)=1.00000 | 2.0ms
  Step  2500 | t= 40.88us | max(d)=1.00000 | 2.0ms
  Step  2600 | t= 42.52us | max(d)=1.00000 | 2.0ms
  Step  2700 | t= 44.15us | max(d)=1.00000 | 1.9ms
  Step  2800 | t= 45.79us | max(d)=1.00000 | 1.9ms
  Step  2900 | t= 47.42us | max(d)=1.00000 | 2.0ms
  Step  3000 | t= 49.06us | max(d)=1.00000 | 2.0ms
  Step  3100 | t= 50.69us | max(d)=1.00000 | 1.9ms
  Step  3200 | t= 52.33us | max(d)=1.00000 | 2.0ms
  Step  3300 | t= 53.96us | max(d)=1.00000 | 1.9ms
  Step  3400 | t= 55.60us | max(d)=1.00000 | 1.9ms
  Step  3500 | t= 57.23us | max(d)=1.00000 | 1.9ms
  Step  3600 | t= 58.87us | max(d)=1.00000 | 1.9ms
  Step  3700 | t= 60.51us | max(d)=1.00000 | 2.0ms
  Step  3800 | t= 62.14us | max(d)=1.00000 | 1.9ms
  Step  3900 | t= 63.78us | max(d)=1.00000 | 1.9ms
  Step  4000 | t= 65.41us | max(d)=1.00000 | 2.0ms
  Step  4100 | t= 67.05us | max(d)=1.00000 | 1.9ms
  Step  4200 | t= 68.68us | max(d)=1.00000 | 2.0ms
  Step  4300 | t= 70.32us | max(d)=1.00000 | 1.9ms
  Step  4400 | t= 71.95us | max(d)=1.00000 | 2.0ms
  Step  4500 | t= 73.59us | max(d)=1.00000 | 2.0ms
  Step  4600 | t= 75.22us | max(d)=1.00000 | 2.0ms
  Step  4700 | t= 76.86us | max(d)=1.00000 | 2.0ms
  Step  4800 | t= 78.49us | max(d)=1.00000 | 2.0ms
  Step  4900 | t= 80.13us | max(d)=1.00000 | 2.0ms
  Step  5000 | t= 81.76us | max(d)=1.00000 | 1.9ms
  Step  5100 | t= 83.40us | max(d)=1.00000 | 1.9ms
  Step  5200 | t= 85.03us | max(d)=1.00000 | 1.9ms
  Step  5300 | t= 86.67us | max(d)=1.00000 | 2.0ms
  Step  5400 | t= 88.31us | max(d)=1.00000 | 1.9ms
  Step  5500 | t= 89.94us | max(d)=1.00000 | 2.0ms
  Step  5600 | t= 91.58us | max(d)=1.00000 | 2.0ms
  Step  5700 | t= 93.21us | max(d)=1.00000 | 1.9ms
  Step  5800 | t= 94.85us | max(d)=1.00000 | 2.0ms
  Step  5900 | t= 96.48us | max(d)=1.00000 | 2.0ms
  Step  6000 | t= 98.12us | max(d)=1.00000 | 2.2ms
  Step  6100 | t= 99.75us | max(d)=1.00000 | 2.0ms
Total: 32.0s (6101 steps, 5.2 ms/step)
Saved: result.vtkhdf (status complete, 123 stored steps, 6101 history rows)
Saved: timing_per_step.csv (6101 rows)
Saved: energy.csv (6101 rows)
Saved: solver_telemetry.csv (6101 rows)
H5 snapshots saved.
Saved: damage_final.png
Saved: run_metadata.json
[PhAST route]
  device=cpu; dtype=torch.float64; elements=T3
  mechanics=ExplicitDynamics; solver_type=explicit; time_integrator=central_difference; backend_requested=auto; backend_resolved=at first solve
  fracture=AT2; energy_split=spectral; history_update=hard_max; bounds=post_clamp; preconditioner=jacobi
Run: python -m phast postprocess /home/runner/work/PhAST/PhAST/runs/tutorial_square_plate
All outputs in: /home/runner/work/PhAST/PhAST/runs/tutorial_square_plate/

8. Load and check the stored fields

Displacement and damage are nodal fields. Strain and stress, when stored, are element fields. phast.load_result reads the result file result.vtkhdf of the run directory, where a field is addressed by the index of a stored step (0, 1, 2, …; (-1) is the last), not by the solver step. The table history/step_index maps each solver step to its stored step, which gives the solver step of every stored field. The checks require that the last stored field is solver step 6,100 and reject empty or non-finite arrays before plotting. Missing optional strain or stress output is reported, not replaced by a fabricated field.

result = phast.load_result(RUN_DIR)
print("Stored fields:", result.field_names())

final_step = NUM_STEPS - 1
final_fields = {}
for name in ("displacement", "strain", "stress", "damage"):
    if not result.has_field(name):
        if name in {"displacement", "damage"}:
            raise RuntimeError(f"Required {name} trajectory is missing.")
        print(f"Optional {name} output was not stored; its plot will be omitted.")
        continue
    field = require_finite(result.field(name, step=-1), name)  # last stored step
    final_fields[name] = field
    print(f"{name:12s} shape={field.shape}, min={field.min():.6g}, max={field.max():.6g}")

displacement = final_fields["displacement"]
damage = final_fields["damage"]
strain = final_fields.get("strain")
stress = final_fields.get("stress")

# Stored steps are numbered 0, 1, 2, ...; the history maps solver steps to them.
stored_steps = np.asarray(result.steps())
stored_times = require_finite(result.times(), "stored times")
history = result.history_columns()
solver_steps = np.asarray(history["increment"])[np.asarray(history["step_index"]) >= 0]
if len(solver_steps) != len(stored_steps) or int(solver_steps[-1]) != final_step:
    raise RuntimeError("The final solver step is not the last stored step.")
print(f"{len(stored_steps)} stored steps; solver steps "
      f"{solver_steps[0]}, {solver_steps[1]}, ..., {solver_steps[-1]}")

mesh_arrays = result.mesh_arrays()
coordinates = require_finite(mesh_arrays["points"], "stored mesh coordinates")
connectivity = np.asarray(mesh_arrays["cells"]["T3"])
if displacement.shape != (len(coordinates), 2) or damage.shape != (len(coordinates),):
    raise ValueError("Stored nodal fields do not match the mesh.")
for name, field in (("strain", strain), ("stress", stress)):
    if field is not None and field.shape != (len(connectivity), 3):
        raise ValueError(f"Unexpected in-plane {name} shape: {field.shape}")

plot_mesh = mtri.Triangulation(coordinates[:, 0], coordinates[:, 1], connectivity)
Stored fields: ['acceleration', 'damage', 'displacement', 'history_field', 'strain', 'stress', 'stress_zz', 'velocity']
displacement shape=(1091, 2), min=-0.00261373, max=0.00270658
strain       shape=(1940, 3), min=-0.00375597, max=0.0301892
stress       shape=(1940, 3), min=-18.9529, max=6.22014
damage       shape=(1091,), min=0.0063707, max=1
123 stored steps; solver steps 0, 50, ..., 6100

9. Visualise the available final fields

The views show displacement magnitude, vertical strain, an in-plane equivalent stress, and phase-field damage. The stress scalar uses only the stored ((\sigma_{xx},\sigma_{yy},\sigma_{xy})) components:

[ \sigma_{\mathrm{eq,in\mbox{-}plane}} = \sqrt{\sigma_{xx}^{2}-\sigma_{xx}\sigma_{yy} +\sigma_{yy}^{2}+3\sigma_{xy}^{2}}. ]

This is not the full three-dimensional von Mises stress for plane strain. The out-of-plane stress (\sigma_{zz}) is not part of this scalar and is not reconstructed here, particularly with the selected spectral tension/compression split and damage degradation. This scalar is a labelled visualisation, not a yield or failure criterion.

Element fields are rendered elementwise. Both the static damage plot and the animation use Reds with the fixed scale (0\leq d\leq1).

u_magnitude = require_finite(np.linalg.norm(displacement, axis=1), "displacement magnitude")
epsilon_yy = None if strain is None else strain[:, 1]
in_plane_equivalent_stress = None
if stress is not None:
    sigma_xx, sigma_yy, sigma_xy = stress.T
    in_plane_equivalent_stress = require_finite(
        np.sqrt(np.maximum(
            sigma_xx**2 - sigma_xx * sigma_yy + sigma_yy**2 + 3.0 * sigma_xy**2,
            0.0,
        )),
        "in-plane equivalent stress",
    )

fig, axes = plt.subplots(2, 2, figsize=(12, 9), constrained_layout=True)
specs = [
    ("Displacement magnitude, |u| [mm]", u_magnitude, "cividis", "nodal"),
    ("Vertical strain, epsilon_yy [-]", epsilon_yy, "RdBu_r", "element"),
    ("In-plane equivalent stress [MPa]", in_plane_equivalent_stress, "viridis", "element"),
    ("Phase-field damage, d [-]", damage, "Reds", "nodal"),
]
for ax, (title, field, cmap, location) in zip(axes.flat, specs):
    if field is None:
        ax.text(0.5, 0.5, f"{title}\nOptional field not stored",
                ha="center", va="center", transform=ax.transAxes)
        ax.axis("off")
        continue
    field = require_finite(field, title)
    limits = dict(vmin=0.0, vmax=1.0) if cmap == "Reds" else {}
    artist = (
        ax.tripcolor(plot_mesh, field, shading="gouraud", cmap=cmap, **limits)
        if location == "nodal"
        else ax.tripcolor(plot_mesh, facecolors=field, shading="flat", cmap=cmap, **limits)
    )
    ax.set(aspect="equal", xlabel="x [mm]", ylabel="y [mm]", title=title)
    fig.colorbar(artist, ax=ax, shrink=0.82)
fig.suptitle(
    f"Square-plate SENT: step {final_step} at {stored_times[-1] * 1e6:.2f} microseconds",
    fontsize=14,
)
field_figure = RUN_DIR / "final_fields.png"
fig.savefig(field_figure, dpi=160)
plt.show()
print(f"Saved: {field_figure}")
Saved: /home/runner/work/PhAST/PhAST/runs/tutorial_square_plate/final_fields.png

10. Animate the stored damage evolution

The geometric notch terminates at (x=20) mm. Inspect whether and where the stored high-damage band advances. Every selected frame is checked for finite values before rendering. An animation is not evidence of mesh convergence, time-step convergence, or experimental agreement.

frame_indices = np.unique(np.linspace(0, len(stored_steps) - 1, 62, dtype=int))
damage_frames = [
    require_finite(result.field("damage", step=int(stored_steps[index])),
                   f"damage at solver step {solver_steps[index]}")
    for index in frame_indices
]
if any(field.shape != (len(coordinates),) for field in damage_frames):
    raise ValueError("A damage frame does not match the mesh nodes.")
fig, ax = plt.subplots(figsize=(7.2, 6.2), constrained_layout=True)
artist = ax.tripcolor(
    plot_mesh, damage_frames[0],
    shading="gouraud", cmap="Reds", vmin=0.0, vmax=1.0,
)
ax.set(aspect="equal", xlabel="x [mm]", ylabel="y [mm]")
fig.colorbar(artist, ax=ax).set_label("damage d")

def update_animation(frame_number: int):
    index = frame_indices[frame_number]
    artist.set_array(damage_frames[frame_number])
    ax.set_title(
        f"Crack evolution: solver step {solver_steps[index]} | "
        f"t = {stored_times[index] * 1e6:.2f} microseconds"
    )
    return (artist,)

animation = FuncAnimation(
    fig, update_animation, frames=len(frame_indices), interval=100, blit=False
)
animation_path = RUN_DIR / "crack_evolution.gif"
animation.save(animation_path, writer=PillowWriter(fps=10), dpi=115)
plt.close(fig)
display(Image(filename=str(animation_path)))
print(f"Saved: {animation_path}")
../_images/561474d04031738096d730209f4169ace9af16de4ab12c29dbaa940543a23337.gif
Saved: /home/runner/work/PhAST/PhAST/runs/tutorial_square_plate/crack_evolution.gif

11. Summarize and retain the result

The (x)-extent of nodes with (d\geq0.95) is descriptive. It does not replace a crack-tip algorithm, energy balance, convergence study, or experiment.

high_damage_nodes = coordinates[damage >= 0.95]
if len(high_damage_nodes):
    print(
        "Nodes with d >= 0.95 span "
        f"x = {high_damage_nodes[:, 0].min():.3f} to "
        f"{high_damage_nodes[:, 0].max():.3f} mm."
    )
print("\nTop-level run artifacts:")
for artifact in sorted(RUN_DIR.iterdir()):
    if artifact.name not in {"result.vtkhdf", "training_data.h5"}:
        print(f"  {artifact.name}")
print("  result.vtkhdf     (result file: fields, history, mesh; opens in ParaView)")
print("  training_data.h5  (single-file trajectory store, requested above)")
Nodes with d >= 0.95 span x = 19.644 to 40.000 mm.

Top-level run artifacts:
  config.yaml
  crack_evolution.gif
  damage_final.png
  energy.csv
  final_fields.png
  initial_conditions.png
  mesh.geo
  mesh.msh
  result.vtkhdf.sha256
  run_lockfile.json
  run_metadata.json
  solver_telemetry.csv
  timing_per_step.csv
  result.vtkhdf     (result file: fields, history, mesh; opens in ParaView)
  training_data.h5  (single-file trajectory store, requested above)

Interpretation and limitations

Successful execution demonstrates this workflow for the selected configuration. The cells report which fields were stored, check their finiteness, and print the actual extent of nodes exceeding the stated damage threshold. Cleared notebook outputs make no claim about a previously computed crack path.

Do not infer more than that:

  • the mesh and time increment have not undergone a convergence study;

  • the result is not newly compared with the Borden et al. benchmark data;

  • the compact mesh is instructional, not a publication-quality prediction;

  • local CPU timing is not a portable performance claim;

  • changing (G_c), (\ell_0), loading, or mesh defines a new study and requires corresponding resolution and validation checks.

For quasi-static fracture, use examples/quasistatic/miehe_tension/config.yaml. Do not change only the solver type here and assume the boundary-value problem remains equivalent.

Suggested student exercise

Predict first, modify one item, and rerun into a new directory:

  • increase SNAPSHOT_EVERY to reduce stored trajectory size without changing the solver update cadence; or

  • refine the mesh and recompute the CFL increment before changing the step count.

If installation, execution, or interpretation remains unclear, open a GitHub issue with the first error, platform, Python version, command, and configuration. Questions, reproducible bug reports, documentation corrections, and student contributions are welcome.