Step-by-step PhAST problem setup

Open in Colab

This notebook builds a small single-edge-notched tension problem from first principles. It creates and inspects the geometry and mesh, defines the material and boundary-value problem with the fluent Python API, writes a runnable schema-v2 YAML configuration, performs a bounded two-step CPU solve, and inspects the resulting artifacts.

Learning objectives:

  1. Create a Gmsh .geo file and convert it to a .msh mesh.

  2. Verify physical groups before using them as PhAST regions.

  3. Apply material parameters, initial-condition choices, supports, and prescribed loading.

  4. Use one saved configuration for setup validation and solver execution.

  5. Run a short quasi-static phase-field workflow check and inspect the generated output directory.

  6. Locate final field images, histories, trajectory stores, and optional animations.

Guided activity pattern:

  • Predict: identify the constrained and loaded boundaries and anticipate the displacement direction before running a cell.

  • Run and inspect: compare the generated mesh, physical groups, solver route, and result manifest with that prediction.

  • Change one quantity: modify h_bulk, l0, or u_top individually; do not change all three at once.

  • Explain: state which discretization, constitutive, or loading choice changed and which output should respond.

  • Fallback: if installation or execution is unavailable, continue with the retained-results notebook. Cleared output cells are not computed evidence.

The default run is intentionally short and is not crack-growth validation. For a research calculation, start from the closest checked-in example, refine the mesh, select a documented loading schedule, and retain the full result and provenance bundle.

Problem statement before software

The specimen is a two-dimensional notched plate subjected to a prescribed vertical displacement. The unknown fields are the displacement u and the phase-field damage variable d, where d = 0 denotes intact material and d = 1 denotes fully developed damage under the adopted convention.

For a quasi-static staggered increment, the computational sequence is:

  1. Hold the current damage field fixed and solve mechanical equilibrium for displacement.

  2. Update the tensile history quantity used to enforce irreversible crack driving.

  3. Hold that driving state fixed and solve the regularized damage subproblem.

  4. Repeat the displacement and damage updates until the selected staggered stopping criterion is met.

  5. Write the accepted state and provenance artifacts.

In variational form, the displacement update seeks stationarity of the mechanical contribution with the damage field fixed, while the damage update seeks stationarity of the fracture contribution and degraded tensile energy with the mechanical driving state fixed. Finite-element test functions convert these statements into discrete residual equations. See the phase-field primer for the governing energies and the solver overview for the implemented pathways.

This notebook uses only two load steps so that a new user can inspect the complete software pathway. It establishes configuration and execution behaviour; it does not establish crack initiation, propagation, mesh convergence, or agreement with an experiment.

Predict before continuing: Which named boundary should remove rigid vertical motion? Which boundary should carry the prescribed displacement? Where should the initial notch appear in the setup plot?

1. Install dependencies

Use Python >=3.10 with compatible dependency wheels. From the current repository checkout, prepare a local environment and install JupyterLab:

python -m pip install -e . jupyterlab
jupyter lab docs/tutorial/notebook_setup.ipynb

The cell below installs packages only in Colab. Local notebook execution and documentation builds use the current checkout without changing the environment or replacing its source with an older release.

Colab uses the public main branch, which is mutable, and prints the resolved commit. A local branch may contain changes not yet published on main; use the notebook bundled with your selected checkout. The subsequent configuration check and solve must succeed before interpreting any result. This notebook does not establish that unpublished changes are available remotely.

Gmsh creates the mesh, MeshIO inspects physical groups, and Matplotlib and Pillow provide visualisation. HDF5 stores the requested trajectory. See the installation guide for platform-specific Gmsh dependencies.

import os
import sys
import subprocess
from pathlib import Path

IN_COLAB = "google.colab" in sys.modules
PHAST_REPOSITORY = "https://github.com/CEMS-Lab/PhAST.git"
PHAST_REVISION = "main"  # Mutable public branch, not an immutable release.

if IN_COLAB:
    subprocess.run(["apt-get", "update", "-qq"], check=True)
    subprocess.run(["apt-get", "install", "-y", "-qq", "gmsh"], check=True)
    checkout = Path("/content/PhAST-main")
    if checkout.exists():
        if not (checkout / ".git").is_dir():
            raise RuntimeError(
                f"{checkout} is not a Git checkout. Restart the Colab runtime."
            )
        origin = subprocess.check_output(
            ["git", "-C", str(checkout), "config", "--get", "remote.origin.url"],
            text=True,
        ).strip()
        if origin != PHAST_REPOSITORY:
            raise RuntimeError(f"Unexpected cached checkout origin: {origin!r}")
    else:
        subprocess.run(
            ["git", "clone", "--branch", PHAST_REVISION, "--depth", "1",
             PHAST_REPOSITORY, str(checkout)],
            check=True,
        )
    subprocess.run(
        ["git", "-C", str(checkout), "fetch", "--depth", "1",
         "origin", "refs/heads/main"],
        check=True,
    )
    expected_commit = subprocess.check_output(
        ["git", "-C", str(checkout), "rev-parse", "FETCH_HEAD"], text=True
    ).strip()
    resolved_commit = subprocess.check_output(
        ["git", "-C", str(checkout), "rev-parse", "HEAD"], text=True
    ).strip()
    tracked_changes = subprocess.check_output(
        ["git", "-C", str(checkout), "status", "--porcelain", "--untracked-files=no"],
        text=True,
    ).strip()
    if resolved_commit != expected_commit or tracked_changes:
        raise RuntimeError(
            "The cached checkout differs from public main. Restart the Colab "
            "runtime rather than overwriting local changes."
        )
    print(f"Public source: {PHAST_REVISION} (mutable)")
    print(f"Resolved commit: {resolved_commit}")
    subprocess.run(
        [sys.executable, "-m", "pip", "install", "-q", "-e", str(checkout), "jupyterlab"],
        check=True,
    )
    os.chdir(checkout)
else:
    print("Local/docs execution: using the current checkout; no packages installed.")
Local/docs execution: using the current checkout; no packages installed.

2. Imports and working directory

Generated geometry, mesh, configuration, and results share a new runs/notebook_sent/ directory. Existing runs are preserved by adding a numeric suffix. Set PHAST_NOTEBOOK_OUTPUT_ROOT to place generated runs elsewhere, for example during a documentation build.

import json
import shutil

from PIL import Image as PILImage
import matplotlib.pyplot as plt
import meshio
import numpy as np
import yaml

here = Path.cwd().resolve()
repo_root = next(
    (candidate for candidate in (here, *here.parents)
     if (candidate / "pyproject.toml").is_file()
     and (candidate / "src" / "phast").is_dir()),
    None,
)
if repo_root is None:
    raise RuntimeError("Start this notebook from the current PhAST source checkout.")
source_src = repo_root / "src"
sys.path.insert(0, str(source_src))
os.environ["PYTHONPATH"] = str(source_src) + os.pathsep + os.environ.get("PYTHONPATH", "")

import phast

if not Path(phast.__file__).resolve().is_relative_to(source_src):
    raise RuntimeError("Restart the kernel: PhAST was imported from a different checkout.")

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", repo_root / "runs"))
base = output_root.expanduser().resolve() / "notebook_sent"
run_dir = base
suffix = 2
while run_dir.exists():
    run_dir = base.with_name(f"{base.name}_{suffix}")
    suffix += 1
run_dir.mkdir(parents=True)

geo_path = run_dir / "mesh.geo"
mesh_path = run_dir / "mesh.msh"
config_path = run_dir / "config.yaml"
output_dir = run_dir / "results"

print("PhAST:", getattr(phast, "__version__", "source checkout"))
print("Repository root:", repo_root)
print("Working directory:", run_dir)
PhAST: source checkout
Repository root: /home/runner/work/PhAST/PhAST
Working directory: /home/runner/work/PhAST/PhAST/runs/notebook_sent

3. Define a small geometry

The geometry is a rectangular plate with a thin single-edge notch cut from the left boundary to mid-plate. The notch boundary is a named physical curve that supplies nodes for the initial damage field. This makes the geometry robust in Gmsh while still representing a pre-existing crack seed for the phase-field solve.

The physical names are the contract between the mesh and PhAST:

Name

Meaning

Used for

body

2D plate surface

material assignment

bottom

bottom edge

fixed support

top

top edge

prescribed vertical displacement

notch

faces and tip of the geometric notch

optional initial damage seed

left, right

side edges

optional inspection/output regions

L = 1.0          # plate width [mm]
H = 1.0          # plate height [mm]
a0 = 0.50        # initial notch/crack length [mm]
y_crack = 0.50   # crack vertical location [mm]
notch_gap = 0.012 # small geometric opening used for robust meshing [mm]
h_bulk = 0.08    # coarse element size [mm]
h_crack = 0.02   # local element size near the crack [mm]

geo_text = f"""
SetFactory("OpenCASCADE");

L = {L};
H = {H};
a0 = {a0};
yc = {y_crack};
g = {notch_gap};
h_bulk = {h_bulk};
h_crack = {h_crack};

Point(1) = {{0, 0, 0, h_bulk}};
Point(2) = {{L, 0, 0, h_bulk}};
Point(3) = {{L, H, 0, h_bulk}};
Point(4) = {{0, H, 0, h_bulk}};
Point(5) = {{0, yc + 0.5*g, 0, h_crack}};
Point(6) = {{a0, yc + 0.5*g, 0, h_crack}};
Point(7) = {{a0, yc - 0.5*g, 0, h_crack}};
Point(8) = {{0, yc - 0.5*g, 0, h_crack}};

Line(1) = {{1, 2}};
Line(2) = {{2, 3}};
Line(3) = {{3, 4}};
Line(4) = {{4, 5}};
Line(5) = {{5, 6}};
Line(6) = {{6, 7}};
Line(7) = {{7, 8}};
Line(8) = {{8, 1}};

Curve Loop(10) = {{1, 2, 3, 4, 5, 6, 7, 8}};
Plane Surface(20) = {{10}};

Field[1] = Distance;
Field[1].CurvesList = {{5, 6, 7}};
Field[2] = Threshold;
Field[2].InField = 1;
Field[2].SizeMin = h_crack;
Field[2].SizeMax = h_bulk;
Field[2].DistMin = 0.02;
Field[2].DistMax = 0.18;
Background Field = 2;

Physical Surface("body") = {{20}};
Physical Curve("bottom") = {{1}};
Physical Curve("right") = {{2}};
Physical Curve("top") = {{3}};
Physical Curve("left") = {{4, 8}};
Physical Curve("notch") = {{5, 6, 7}};
""".strip()

geo_path.write_text(geo_text + "\n", encoding="utf-8")
print(geo_path)
print(geo_text[:600] + "\n...")
/home/runner/work/PhAST/PhAST/runs/notebook_sent/mesh.geo
SetFactory("OpenCASCADE");

L = 1.0;
H = 1.0;
a0 = 0.5;
yc = 0.5;
g = 0.012;
h_bulk = 0.08;
h_crack = 0.02;

Point(1) = {0, 0, 0, h_bulk};
Point(2) = {L, 0, 0, h_bulk};
Point(3) = {L, H, 0, h_bulk};
Point(4) = {0, H, 0, h_bulk};
Point(5) = {0, yc + 0.5*g, 0, h_crack};
Point(6) = {a0, yc + 0.5*g, 0, h_crack};
Point(7) = {a0, yc - 0.5*g, 0, h_crack};
Point(8) = {0, yc - 0.5*g, 0, h_crack};

Line(1) = {1, 2};
Line(2) = {2, 3};
Line(3) = {3, 4};
Line(4) = {4, 5};
Line(5) = {5, 6};
Line(6) = {6, 7};
Line(7) = {7, 8};
Line(8) = {8, 1};

Curve Loop(10) = {1, 2, 3, 4, 5, 6, 7, 8};
Plane Surface(20) = 
...

4. Visualize the geometry before meshing

This schematic is not the finite-element mesh. It is a cheap sanity check: dimensions, crack location, and loaded/support boundaries should be correct before generating elements.

fig, ax = plt.subplots(figsize=(6, 5))
ax.plot([0, L, L, 0, 0], [0, 0, H, H, 0], color="0.15", lw=2)
ax.fill([0, a0, a0, 0], [y_crack - notch_gap/2, y_crack - notch_gap/2, y_crack + notch_gap/2, y_crack + notch_gap/2], color="crimson", alpha=0.22)
ax.plot([0, a0], [y_crack, y_crack], color="crimson", lw=3, label="initial notch / damage seed")
ax.plot([0, L], [0, 0], color="royalblue", lw=4, label="fixed bottom")
ax.plot([0, L], [H, H], color="darkorange", lw=4, label="prescribed top displacement")
ax.set_aspect("equal")
ax.set_xlabel("x [mm]")
ax.set_ylabel("y [mm]")
ax.set_title("Geometry and named boundaries")
ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.12), ncol=1)
fig.tight_layout()
fig.savefig(run_dir / "geometry_preview.png", dpi=180)
plt.show()

5. Generate the mesh with Gmsh

The command below converts mesh.geo into mesh.msh. If this fails in a local environment, install Gmsh and make sure the gmsh executable is on PATH.

gmsh = shutil.which("gmsh")
if gmsh is None:
    raise RuntimeError("gmsh executable was not found on PATH")

cmd = [gmsh, "-2", str(geo_path), "-format", "msh2", "-o", str(mesh_path)]
print(" ".join(cmd))
subprocess.run(cmd, check=True, timeout=60)
print(mesh_path, mesh_path.stat().st_size, "bytes")
/opt/hostedtoolcache/Python/3.11.18/x64/bin/gmsh -2 /home/runner/work/PhAST/PhAST/runs/notebook_sent/mesh.geo -format msh2 -o /home/runner/work/PhAST/PhAST/runs/notebook_sent/mesh.msh
Info    : Running '/opt/hostedtoolcache/Python/3.11.18/x64/bin/gmsh -2 /home/runner/work/PhAST/PhAST/runs/notebook_sent/mesh.geo -format msh2 -o /home/runner/work/PhAST/PhAST/runs/notebook_sent/mesh.msh' [Gmsh 4.15.2, 1 node, max. 1 thread]
Info    : Started on Mon Nov  9 20:11:55 2026
Info    : Reading '/home/runner/work/PhAST/PhAST/runs/notebook_sent/mesh.geo'...
Info    : Done reading '/home/runner/work/PhAST/PhAST/runs/notebook_sent/mesh.geo'
Info    : Meshing 1D...
Info    : [  0%] Meshing curve 1 (Line)
Info    : [ 20%] Meshing curve 2 (Line)
Info    : [ 30%] Meshing curve 3 (Line)
Info    : [ 40%] Meshing curve 4 (Line)
Info    : [ 60%] Meshing curve 5 (Line)
Info    : [ 70%] Meshing curve 6 (Line)
Info    : [ 80%] Meshing curve 7 (Line)
Info    : [ 90%] Meshing curve 8 (Line)
Info    : Done meshing 1D (Wall 0.0025106s, CPU 0.002283s)
Info    : Meshing 2D...
Info    : Meshing surface 20 (Plane, Frontal-Delaunay)
Info    : Done meshing 2D (Wall 0.0158437s, CPU 0.014883s)
Info    : 688 nodes 1382 elements
Info    : Writing '/home/runner/work/PhAST/PhAST/runs/notebook_sent/mesh.msh'...
Info    : Done writing '/home/runner/work/PhAST/PhAST/runs/notebook_sent/mesh.msh'
Info    : Stopped on Mon Nov  9 20:11:55 2026 (From start: Wall 0.0221068s, CPU 0.288415s)
/home/runner/work/PhAST/PhAST/runs/notebook_sent/mesh.msh 61254 bytes

6. Inspect named regions from the mesh

This is the most important mesh validation step. The solver can only apply materials, initial conditions, supports, loads, and histories to regions that are present in the mesh. Missing or misspelled physical names should be fixed in the .geo file before running.

mesh_summary = phast.inspect_mesh(mesh_path)
print("points:", mesh_summary["n_points"])
print("cell blocks:", mesh_summary["cells"])
print(json.dumps(mesh_summary["named_groups"], indent=2))

required_groups = {"body", "bottom", "top", "notch"}
missing = required_groups - set(mesh_summary["named_groups"])
if missing:
    raise RuntimeError(f"Missing required physical groups: {sorted(missing)}")
points: 688
cell blocks: [{'type': 'line', 'count': 114, 'nodes_per_cell': 2}, {'type': 'triangle', 'count': 1260, 'nodes_per_cell': 3}]
{
  "body": {
    "id": 1,
    "dimension": 2,
    "cell_counts": {
      "triangle": 1260
    }
  },
  "bottom": {
    "id": 2,
    "dimension": 1,
    "cell_counts": {
      "line": 13
    }
  },
  "left": {
    "id": 5,
    "dimension": 1,
    "cell_counts": {
      "line": 24
    }
  },
  "notch": {
    "id": 6,
    "dimension": 1,
    "cell_counts": {
      "line": 51
    }
  },
  "right": {
    "id": 3,
    "dimension": 1,
    "cell_counts": {
      "line": 13
    }
  },
  "top": {
    "id": 4,
    "dimension": 1,
    "cell_counts": {
      "line": 13
    }
  }
}

7. Visualize the mesh and physical groups

A visual check catches common mistakes: swapped top/bottom groups, an unembedded crack line, very coarse crack-tip resolution, or an accidental empty surface group.

mesh = meshio.read(mesh_path)
points = require_finite(mesh.points[:, :2], "mesh coordinates")

triangles = []
lines = []
line_tags = []
for block_index, block in enumerate(mesh.cells):
    if block.type == "triangle":
        triangles.append(block.data)
    if block.type == "line":
        lines.append(block.data)
        tags = mesh.cell_data.get("gmsh:physical", [])[block_index]
        line_tags.append(tags)

triangles = np.vstack(triangles) if triangles else np.empty((0, 3), dtype=int)
lines = np.vstack(lines) if lines else np.empty((0, 2), dtype=int)
line_tags = np.concatenate(line_tags) if line_tags else np.empty((0,), dtype=int)

id_to_name = {int(data[0]): name for name, data in mesh.field_data.items()}
colors = {"bottom": "royalblue", "top": "darkorange", "left": "0.35", "right": "0.35", "notch": "crimson"}

fig, ax = plt.subplots(figsize=(7, 6))
if len(triangles):
    ax.triplot(points[:, 0], points[:, 1], triangles, color="0.82", linewidth=0.5)
for edge, tag in zip(lines, line_tags):
    name = id_to_name.get(int(tag), str(tag))
    xy = points[edge]
    ax.plot(xy[:, 0], xy[:, 1], color=colors.get(name, "0.4"), lw=2.0 if name == "notch" else 1.4)
ax.set_aspect("equal")
ax.set_xlabel("x [mm]")
ax.set_ylabel("y [mm]")
ax.set_title("Mesh with named physical curves")
fig.tight_layout()
fig.savefig(run_dir / "mesh_preview.png", dpi=180)
plt.show()

8. Build the problem manually with phast.Problem

The fluent API is best while designing a model because each line corresponds to a physical decision. The region names on the left are the names used by this Python model; the from_mesh values are the physical names stored in mesh.msh.

The short run below uses AT2 damage, the spectral split, a quasi-static staggered solve, and CPU execution. The geometric notch is present in the mesh. Full damage pre-seeding is shown as an opt-in switch because it is useful for full fracture studies but can be too severe for a two-step Colab smoke test. Change only one choice at a time when exploring a new model.

num_steps = 2          # quick tutorial run; increase for smoother damage evolution
u_top = 0.0002         # prescribed final displacement [mm]
l0 = 0.04             # phase-field length scale [mm]
enable_damage_preseed = False

problem = (
    phast.Problem("Notebook SENT tutorial")
    .mesh(str(mesh_path))
    .region("body", kind="domain", from_mesh="body")
    .region("bottom", from_mesh="bottom")
    .region("top", from_mesh="top")
    .region("notch", from_mesh="notch")
    .material(
        "tutorial_material",
        region="body",
        E=210000.0,
        nu=0.3,
        Gc=2.7,
        l0=l0,
        rho=7.8e-09,
        eta_residual=1.0e-07,
        energy_split="spectral",
        pf_model="AT2",
        plane_stress=False,
    )
    .boundary_condition("fix", region="bottom", dof="x", name="fix_bottom_x")
    .boundary_condition("fix", region="bottom", dof="y", name="fix_bottom_y")
    .boundary_condition("displacement", region="top", dof="y", value=1.0, name="pull_top")
    .analysis_step(
        "load",
        kind="quasi_static",
        controls={"protocol": "simple", "num_steps": num_steps, "dt": u_top / num_steps},
        active_boundary_conditions=["fix_bottom_x", "fix_bottom_y", "pull_top"],
    )
    .solver(
        "quasi_static",
        stagger_tol=1.0e-6,
        max_stagger=50,
        preconditioner="jacobi",
        damage_tol=1.0e-5,
        static_tol=1.0e-7,
        damage_max_iter=500,
        static_max_iter=500,
        fail_on_mechanics_nonconvergence=True,
        fail_on_stagger_nonconvergence=True,
        backend="auto",
        device="cpu",
    )
    .outputs(
        fields=[{"name": "trajectory", "every": 1, "format": "h5"}],
        histories=[{"name": "reaction_force", "region": "bottom", "dof": "y"}],
        output_dir=str(output_dir),
        plots=False,  # Plot only after checking the stored numerical fields.
        profile=True,
        gif=False,
        print_every=1,
    )
)

if enable_damage_preseed:
    problem.initial_condition("damage", region="notch", value=1.0)

problem.validate_setup()

{'mesh': '/home/runner/work/PhAST/PhAST/runs/notebook_sent/mesh.msh',
 'regions': {'body': {'source': 'named_group',
   'external_name': 'body',
   'dimension': 2,
   'cell_counts': {'triangle': 1260}},
  'bottom': {'source': 'named_group',
   'external_name': 'bottom',
   'dimension': 1,
   'cell_counts': {'line': 13}},
  'top': {'source': 'named_group',
   'external_name': 'top',
   'dimension': 1,
   'cell_counts': {'line': 13}},
  'notch': {'source': 'named_group',
   'external_name': 'notch',
   'dimension': 1,
   'cell_counts': {'line': 51}}}}

9. Visualise supports, loading, and the optional damage seed

problem.plot_setup(...) writes a static setup preview. The notch exists in the mesh even when damage pre-seeding is disabled. An initial value d=1 is applied only when enable_damage_preseed is explicitly enabled.

setup_preview = run_dir / "initial_conditions.png"
problem.plot_setup(output=setup_preview)

img = np.asarray(PILImage.open(setup_preview).convert("RGB"))
fig, ax = plt.subplots(figsize=(8, 5))
ax.imshow(img)
ax.axis("off")
ax.set_title("PhAST setup preview")
plt.show()
print(setup_preview)
/home/runner/work/PhAST/PhAST/runs/notebook_sent/initial_conditions.png

10. Solver settings: what the common choices mean

Phase-field fracture replaces a sharp crack with a continuous damage field d. Values near 0 represent intact material and values near 1 represent fully damaged material. The length scale l0 controls the width of the diffuse crack band, so the mesh should resolve l0 near the expected crack path.

Choice

Typical values

Meaning

pf_model

AT1, AT2

Crack-density model. AT2 is smooth at damage onset; AT1 has a finite damage threshold and is common in dynamic benchmark decks.

energy_split

spectral, amor, isotropic, star_convex

How tensile and compressive elastic energy contributions are separated before driving damage. Use the split required by the benchmark or paper claim.

solver_type

quasi_static, explicit

Quasi-static solves coupled displacement/damage equilibrium at each load increment. Explicit dynamics advances the transient wave/crack problem with a stable time step.

static vs dynamic

quasi_static vs explicit

Static/quasi-static cases ignore inertia; dynamic cases require density and time-step controls.

implicit vs explicit

quasi_static uses iterative equilibrium solves; explicit uses time stepping

Current public examples promote quasi-static fracture and explicit dynamic fracture. Experimental or beta paths should stay clearly labelled.

trajectory format

zarr, h5, both

HDF5 is the default single-file format; Zarr or both formats require explicit selection.

For a paper result, keep these choices in YAML and report them in the run manifest. Do not rely on notebook state as the only record of a simulation.

11. Save one runnable schema-v2 configuration

Problem.save(config_path) retains the geometry, named regions, material, boundary conditions, analysis step, solver settings, and output requests in one YAML file. Validate and execute that same file, rather than maintaining a second hand-written configuration.

This example is restricted to the existing single-material, external-mesh, quasi-static fracture adapter. It does not establish support for arbitrary schema-v2 combinations. Schema validation does not solve the equations or demonstrate convergence. Mechanics and staggered nonconvergence remain fatal; do not disable these checks to obtain a plot.

problem.save(config_path)
saved_config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
if saved_config.get("schema_version") != 2:
    raise RuntimeError("This workflow requires the saved schema-v2 configuration.")
print("Saved configuration used for validation and execution:", config_path)
print(yaml.safe_dump(saved_config, sort_keys=False))

validate_cmd = [sys.executable, "-m", "phast", "run", str(config_path), "--validate-only"]
print(" ".join(validate_cmd))
subprocess.run(validate_cmd, check=True, cwd=repo_root, timeout=60)
Saved configuration used for validation and execution: /home/runner/work/PhAST/PhAST/runs/notebook_sent/config.yaml
schema_version: 2
name: Notebook SENT tutorial
geometry:
  mesh_path: /home/runner/work/PhAST/PhAST/runs/notebook_sent/mesh.msh
  mesh_type: file
regions:
- name: body
  kind: domain
  selector:
    from_mesh: body
- name: bottom
  kind: region
  selector:
    from_mesh: bottom
- name: top
  kind: region
  selector:
    from_mesh: top
- name: notch
  kind: region
  selector:
    from_mesh: notch
materials:
  tutorial_material:
    model: phase_field
    parameters:
      E: 210000.0
      nu: 0.3
      Gc: 2.7
      l0: 0.04
      rho: 7.8e-09
      eta_residual: 1.0e-07
      energy_split: spectral
      pf_model: AT2
      plane_stress: false
assignments:
- material: tutorial_material
  region: body
boundary_conditions:
- name: fix_bottom_x
  type: fix
  region: bottom
  component: 0
  ramp_type: constant
  t_ramp: 0.0
  rotation_free: true
- name: fix_bottom_y
  type: fix
  region: bottom
  component: 1
  ramp_type: constant
  t_ramp: 0.0
  rotation_free: true
- name: pull_top
  type: prescribe
  region: top
  component: 1
  value: 1.0
  ramp_type: constant
  t_ramp: 0.0
  rotation_free: true
analysis_steps:
- name: load
  type: quasi_static
  controls:
    protocol: simple
    num_steps: 2
    dt: 0.0001
  active_boundary_conditions:
  - fix_bottom_x
  - fix_bottom_y
  - pull_top
solver:
  type: quasi_static
  max_stagger: 50
  preconditioner: jacobi
  static_tol: 1.0e-07
  damage_max_iter: 500
  static_max_iter: 500
  device: cpu
outputs:
  directory: /home/runner/work/PhAST/PhAST/runs/notebook_sent/results
  fields:
  - name: trajectory
    every: 1
    format: h5
  history:
  - name: reaction_force
    every: 1
    region: bottom
    component: 1
  profile: true
  print_every: 1

/opt/hostedtoolcache/Python/3.11.18/x64/bin/python -m phast run /home/runner/work/PhAST/PhAST/runs/notebook_sent/config.yaml --validate-only
OK: /home/runner/work/PhAST/PhAST/runs/notebook_sent/config.yaml passes schema-v2 workflow contract validation.
CompletedProcess(args=['/opt/hostedtoolcache/Python/3.11.18/x64/bin/python', '-m', 'phast', 'run', '/home/runner/work/PhAST/PhAST/runs/notebook_sent/config.yaml', '--validate-only'], returncode=0)

Predict before running the solver

Write down three expectations:

  1. The solver family and device that should be reported.

  2. The output directory that should be created.

  3. The fields or histories that a two-step run can reasonably contain.

After the run, compare these expectations with the printed route, manifest, and available artifacts. A completed short run is an execution check, not a fracture-validation result.

12. Run the bounded solver workflow

The command executes the saved schema-v2 configuration and writes to the selected run’s results/ directory. It is a two-step CPU workflow check, not evidence of crack initiation, propagation, mesh convergence, or benchmark agreement. A nonzero exit status stops the notebook.

run_cmd = [
    sys.executable, "-m", "phast", "run", str(config_path),
    "--output_dir", str(output_dir),
]
print(" ".join(run_cmd))
subprocess.run(run_cmd, check=True, cwd=repo_root, timeout=120)
/opt/hostedtoolcache/Python/3.11.18/x64/bin/python -m phast run /home/runner/work/PhAST/PhAST/runs/notebook_sent/config.yaml --output_dir /home/runner/work/PhAST/PhAST/runs/notebook_sent/results
============================================================
  Notebook SENT tutorial
  Config: /tmp/phast-schema-v2-fracture-ws15toyu/config.yaml
  Output: /home/runner/work/PhAST/PhAST/runs/notebook_sent/results
============================================================
[device] Context: device=cpu, dtype=torch.float64, compile=False, profile=True
[FEMMesh] Loading mesh: /home/runner/work/PhAST/PhAST/runs/notebook_sent/mesh.msh

[FEMMesh] Mesh file read OK
[FEMMesh] 688 nodes loaded
[FEMMesh] 1260 T3 elements loaded
[FEMMesh] Node sets: ['bottom', 'right', 'top', 'left', 'notch']
[FEMMesh] Precomputing areas, grad_phi, incircle, lumped mass...
[FEMMesh] Precompute done: h_min=6.057750e-03, total_area=0.994000
Saved: mesh.msh (from /home/runner/work/PhAST/PhAST/runs/notebook_sent/mesh.msh)
Saved: mesh.geo (from /home/runner/work/PhAST/PhAST/runs/notebook_sent/mesh.geo)
Saved: run_lockfile.json
Material (AT2, spectral split, plane strain):
  E=210000.0 MPa, nu=0.3, rho=7.8e-09
  Gc=2.7 N/mm, l0=0.04 mm
  Gc/l0=67.5, Gc*l0=0.108000
  kappa=175000.0, mu=80769.2, lam=121153.8
  c_p=6020183.02, c_s=3217923.18, c_R=2980291.93
FEMMesh: /home/runner/work/PhAST/PhAST/runs/notebook_sent/mesh.msh
  688 nodes, 1260 elements
  h_min=6.057750e-03, total_area=0.994000
  Node set 'bottom': 14 nodes
  Node set 'right': 14 nodes
  Node set 'top': 14 nodes
  Node set 'left': 26 nodes
  Node set 'notch': 52 nodes

------------------------------------------------------------------
  Pre-simulation diagnostic (set SKIP_PRECHECK=1 to hide)
------------------------------------------------------------------
  Wave speeds      : c_p=6020  c_s=3218  c_R=2980  m/s
  CFL time step    : dt_CFL=1.006 ns  (×0.8 safety = 0.805 ns)
  Step count       : n_steps=2  (source: YAML num_steps)
  Resolution       : h_min=0.0061 mm, h/l0=0.15 (GOOD), elems/(2l0)=13
  Subcycling bound : N_max = floor(c_p/(0.6*c_R)) = 3  (current damage_every=3)
------------------------------------------------------------------
[StaggeredSolver] Assembling solver (quasi_static)...
[FEMOperators] Initializing (spectral split, AT2)...
[FEMOperators] dt_CFL = 1.006240e-09 (c_p=6020183.02)
[PhaseFieldDamageSolver] Initializing CG solver (model=AT2, tol=1.0e-05, max_iter=500, bounds=post_clamp)...
[PhaseFieldDamageSolver] CG device: cpu, dtype: float64
[PhaseFieldDamageSolver] Ready (preconditioner=jacobi).
[StaggeredSolver] QuasiStaticSolver ready
[StaggeredSolver] State allocated: 688 nodes, 1260 elements, device=cpu, dtype=torch.float64
[PhAST route]
  device=cpu; dtype=torch.float64; elements=T3
  mechanics=QuasiStaticSolver; solver_type=quasi_static; 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/notebook_sent/results/training_data.h5

Running 2 quasi-static steps, du=0.0001

[result] result.vtkhdf: fields every 1 increment(s), history every increment
[QuasiStaticSolver] mechanics backend: scipy (SciPy SuperLU sparse-direct LU); tangent=frozen-state secant sparse tangent; free_dofs=1334
  Step   0 | u=0.000100 | max(d)=0.00010 | 224ms
  Step   1 | u=0.000200 | max(d)=0.00042 | 50ms

Total: 0.3s (2 steps, 149.3 ms/step)
Saved: result.vtkhdf (status complete, 2 stored steps, 2 history rows)
Saved: timing_per_step.csv (2 rows)
Saved: energy.csv (2 rows)
Saved: solver_telemetry.csv (2 rows)
Saved: results.csv (2 rows; node_set='bottom', component=1)
H5 snapshots saved.
Saved: damage_final.png
Saved: run_metadata.json
[PhAST route]
  device=cpu; dtype=torch.float64; elements=T3
  mechanics=QuasiStaticSolver; solver_type=quasi_static; time_integrator=central_difference; backend_requested=auto; backend_resolved=scipy
  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/notebook_sent/results
All outputs in: /home/runner/work/PhAST/PhAST/runs/notebook_sent/results/
schema-v2 fracture execution adapter: quasi_static
CompletedProcess(args=['/opt/hostedtoolcache/Python/3.11.18/x64/bin/python', '-m', 'phast', 'run', '/home/runner/work/PhAST/PhAST/runs/notebook_sent/config.yaml', '--output_dir', '/home/runner/work/PhAST/PhAST/runs/notebook_sent/results'], returncode=0)
# Inspect the output contract without assuming every optional writer was enabled.
artifact_groups = {
    "provenance": ["run_manifest.json", "run_metadata.json", "run_lockfile.json"],
    "history": ["history.csv", "results.csv", "reaction_force.csv"],
    "field_or_trajectory": [
        "damage_final.png",
        "displacement_final.png",
        "result.vtkhdf",
        "training_data.h5",
        "training_data.zarr",
    ],
}

print("Artifact contract check")
for category, candidates in artifact_groups.items():
    present = [name for name in candidates if (output_dir / name).exists()]
    print(f"{category:>19}: {present if present else 'not found under standard names'}")

print()
print(
    "Interpretation: availability depends on the selected workflow and output "
    "settings. Inspect the run manifest before treating an absent optional artifact "
    "as a solver failure."
)
Artifact contract check
         provenance: ['run_metadata.json', 'run_lockfile.json']
            history: ['results.csv']
field_or_trajectory: ['damage_final.png', 'result.vtkhdf', 'training_data.h5']

Interpretation: availability depends on the selected workflow and output settings. Inspect the run manifest before treating an absent optional artifact as a solver failure.

13. Inspect the result directory

A completed public run should be inspectable without reading solver internals. The exact files depend on requested outputs, but the important classes are metadata/lockfiles, CSV histories, final PNGs, the result file result.vtkhdf, trajectory stores, and optional animations.

for path in sorted(output_dir.iterdir()):
    print(path.name + ("/" if path.is_dir() else ""))

result = phast.load_result(output_dir)
print("\nmetadata keys:", sorted(result.metadata().keys()))
print("histories:", result.history_names())
print("fields:", result.field_names())
print("visuals:", result.visuals())

stored_steps = []
result_file = output_dir / "result.vtkhdf"
if result_file.is_file():
    # The result file numbers its stored steps 0, 1, 2, ...; history/step_index
    # maps each solver step to its stored step (-1 when no fields were stored).
    stored_steps = [int(step) for step in result.steps()]
    history = result.history_columns()
    solver_steps = np.asarray(history["increment"])[np.asarray(history["step_index"]) >= 0]
    if not stored_steps or len(solver_steps) != len(stored_steps) or int(solver_steps[-1]) != num_steps - 1:
        raise RuntimeError("The requested final step is missing from the result file.")
    print("Stored steps:", stored_steps, "| solver steps:", solver_steps.tolist())
else:
    print("No result file is available; field plots and animation will be omitted.")
config.yaml
damage_final.png
energy.csv
initial_conditions.png
mesh.geo
mesh.msh
result.vtkhdf
result.vtkhdf.sha256
results.csv
run_lockfile.json
run_metadata.json
solver_telemetry.csv
timing_per_step.csv
training_data.h5

metadata keys: ['config_file', 'crack_step', 'device', 'git_hash', 'material', 'memory', 'mesh', 'platform', 'preseed_notch_nodesets', 'problem', 'solver', 'timestamp', 'total_time_s']
histories: ['energy', 'history', 'load_displacement', 'max_damage', 'reaction', 'results', 'solver_telemetry', 'timing_per_step']
fields: ['damage', 'displacement', 'history_field', 'strain', 'stress', 'stress_zz']
visuals: [{'file': 'damage_final.png', 'artifact_type': 'image', 'size_bytes': 30366}, {'file': 'initial_conditions.png', 'artifact_type': 'image', 'size_bytes': 236744}]
Stored steps: [0, 1] | solver steps: [0, 1]

14. Plot available histories and final fields

The result API exposes stored field names and CSV-backed history rows. Displacement magnitude is derived from displacement; vertical strain and stress use their stored in-plane components when available. This adapter may omit optional strain or stress snapshots: missing output is reported, not replaced with zeros or reconstructed without its constitutive inputs.

Every plotted numerical array must be finite. Empty fields, NaNs, infinities, or incompatible field shapes stop post-processing. Damage uses the fixed Reds scale from 0 to 1.

if "load_displacement" in result.history_names():
    rows = result.history("load_displacement")
    if rows:
        columns = set(rows[0])
        if {"applied_disp", "reaction_force"} <= columns:
            x_key, y_key, force_unit = "applied_disp", "reaction_force", "N"
        elif {"displacement", "reaction_kN"} <= columns:
            x_key, y_key, force_unit = "displacement", "reaction_kN", "kN"
        else:
            raise ValueError(f"Unrecognised load-displacement columns: {sorted(columns)}")
        applied = require_finite(
            np.asarray([row[x_key] for row in rows], dtype=float), x_key
        )
        reaction = require_finite(
            np.asarray([row[y_key] for row in rows], dtype=float), y_key
        )
        fig, ax = plt.subplots(figsize=(6, 4))
        ax.plot(applied, reaction, "o-")
        ax.set(xlabel="Applied displacement [mm]", ylabel=f"Reaction [{force_unit}]",
               title="Two-step response, not a converged fracture curve")
        fig.tight_layout()
        plt.show()
    else:
        print("The load-displacement history is empty; no curve is drawn.")
else:
    print("No load-displacement history was stored; no curve is fabricated.")


def write_scalar_field_png(values, title, path, cmap="viridis", limits=None):
    arr = require_finite(values, title)
    if arr.ndim != 1 or arr.size not in (points.shape[0], triangles.shape[0]):
        raise ValueError(f"{title}: field shape {arr.shape} does not match this mesh")
    colour_limits = {} if limits is None else dict(vmin=limits[0], vmax=limits[1])
    fig, ax = plt.subplots(figsize=(6, 5))
    if arr.size == points.shape[0]:
        im = ax.tripcolor(
            points[:, 0], points[:, 1], triangles, arr,
            shading="gouraud", cmap=cmap, **colour_limits,
        )
    else:
        im = ax.tripcolor(
            points[:, 0], points[:, 1], triangles, facecolors=arr,
            shading="flat", cmap=cmap, **colour_limits,
        )
    ax.set(aspect="equal", title=title, xlabel="x [mm]", ylabel="y [mm]")
    fig.colorbar(im, ax=ax, shrink=0.82)
    fig.tight_layout()
    fig.savefig(path, dpi=180)
    plt.close(fig)
    return path


checked_images = []
for field_name, title, cmap, component, limits in [
    ("damage", "Final phase-field damage, d [-]", "Reds", None, (0.0, 1.0)),
    ("displacement", "Final displacement magnitude [mm]", "cividis", "magnitude", None),
    ("stress", "Final in-plane stress, sigma_yy [MPa]", "RdBu_r", 1, None),
    ("strain", "Final vertical strain, epsilon_yy [-]", "RdBu_r", 1, None),
]:
    if not stored_steps or not result.has_field(field_name):
        print(f"No stored {field_name} field; its plot is omitted.")
        continue
    values = require_finite(result.field(field_name, step=stored_steps[-1]), field_name)
    if component == "magnitude":
        if values.ndim != 2 or values.shape[1] != 2:
            raise ValueError(f"Unexpected displacement shape: {values.shape}")
        values = np.linalg.norm(values, axis=1)
    elif component is not None:
        if values.ndim != 2 or values.shape[1] != 3:
            raise ValueError(f"Unexpected in-plane {field_name} shape: {values.shape}")
        values = values[:, component]
    checked_images.append(write_scalar_field_png(
        values, title, output_dir / f"{field_name}_final.png", cmap=cmap, limits=limits
    ))

if checked_images:
    fig, axes = plt.subplots(
        (len(checked_images) + 1) // 2, 2,
        figsize=(11, 5 * ((len(checked_images) + 1) // 2)),
    )
    for ax in np.asarray(axes).flat:
        ax.axis("off")
    for ax, path in zip(np.asarray(axes).flat, checked_images):
        ax.imshow(np.asarray(PILImage.open(path).convert("RGB")))
        ax.set_title(path.name)
    fig.tight_layout()
    plt.show()
else:
    print("No available finite trajectory fields to plot.")

15. Create an animation from stored damage

Use the stored-step indices of the result file (result.steps()), not the number of CSV history rows. All selected damage snapshots are checked before rendering. Two frames illustrate the output workflow; they do not demonstrate crack propagation. For longer studies, render from the retained trajectory after solving.

from IPython.display import Image, display

gif_path = output_dir / "damage_evolution.gif"
if stored_steps and result.has_field("damage"):
    damage_frames = [
        require_finite(result.field("damage", step=step), f"damage at step {step}")
        for step in stored_steps
    ]
    if any(field.shape != (len(points),) for field in damage_frames):
        raise ValueError("A damage snapshot does not match the mesh nodes.")
    frames = []
    for step_index, damage in zip(stored_steps, damage_frames):
        fig, ax = plt.subplots(figsize=(6, 5))
        im = ax.tripcolor(
            points[:, 0], points[:, 1], triangles, damage,
            shading="gouraud", cmap="Reds", vmin=0.0, vmax=1.0,
        )
        ax.set(aspect="equal", title=f"Damage, stored step {step_index}",
               xlabel="x [mm]", ylabel="y [mm]")
        fig.colorbar(im, ax=ax, shrink=0.82)
        fig.tight_layout()
        fig.canvas.draw()
        frames.append(PILImage.fromarray(
            np.asarray(fig.canvas.buffer_rgba())[:, :, :3].copy()
        ))
        plt.close(fig)
    frames[0].save(
        gif_path, save_all=True, append_images=frames[1:], duration=800, loop=0
    )
    display(Image(filename=str(gif_path)))
else:
    print("No stored damage trajectory; no animation is fabricated.")
../_images/19939099516c952f3abcbf7d4ad204d0e2d724854ef7c872e0969f4392374bed.gif

16. What to change for a real study

  1. Start from a public example closest to your target physics.

  2. Keep the .geo, .msh, config.yaml, run_fluent.py, setup preview, final field images, animations, and CSV histories together.

  3. Use a mesh size that resolves the phase-field length scale; a common starting point is several elements across l0 near the crack path.

  4. Validate the YAML with python -m phast run config.yaml --validate-only before using HPC time.

  5. For dynamic fracture, use a matched public dynamic example, include density, and verify the stable time step. Changing only the solver type does not define an equivalent problem.

  6. For quasi-static fracture, monitor staggered convergence and load-displacement response before trusting crack paths.

  7. Archive the result directory, not only the notebook. The result directory is the reproducible simulation artifact.

17. Controlled exercises and exit questions

Change only one quantity per run and use a new output directory.

Exercise A: mesh size

Before changing h_bulk, predict the direction of change in element count, memory use, and runtime. Regenerate the mesh, inspect the actual counts, and explain whether the observation agrees with the prediction. This is a software and discretization exercise, not a convergence claim.

Exercise B: phase-field length scale

Change l0 while keeping the mesh fixed. Calculate h_bulk/l0 and explain why interpreting a damage profile becomes unsafe when the regularized field is poorly resolved. Do not compare apparent crack paths as scientific evidence from this two-step workflow.

Exercise C: loading

Change u_top while keeping the number of steps fixed. Predict how the increment size changes and identify which history should reveal the mechanical response.

Exit questions

  1. What physical decision is represented by each named mesh region?

  2. What did --validate-only establish, and what did it leave untested?

  3. Which files identify the executed solver route and repository revision?

  4. What additional mesh, convergence, calibration, and comparison evidence would be required before reporting a fracture result?

If execution was unavailable, inspect the retained-results notebook rather than interpreting empty output cells. Continue with From a tutorial to a first research study before adapting the example to a research problem.

If a documented step fails, open a GitHub issue with the configuration, command, operating system, Python version, PhAST version or commit, and complete error output.