Version:

Notched Specimens

Machined notches are a common substitute for natural defects in ultrasonic NDT qualification work: their geometry is well controlled and they produce strong, repeatable scattered signals. This tutorial compares the ultrasonic wavefields observed between three different surface-breaking notch geometries that are available in W-Scan, namely:
  • Rectangular: a flat-bottomed slot of fixed width and depth, used as the canonical reference reflector in many NDT reference blocks.
  • Stepped: three nested rectangles of decreasing width and increasing depth, which approximates a stepped V-shaped profile.
  • Crack: a thin, jagged surface-breaking crack constructed from a randomly perturbed centreline, used to mimic a real fatigue crack.
For each geometry, a wedge probe is placed on the surface of a steel specimen and a tone-burst excitation is injected into the medium. The volumetric wavefield between each geometric entity is compared side-by-side in order to better understand how these different geometries impact the resulting wave propagation.

Importing libraries and setting constants

As usual, a series of standardized libraries are imported.
Copy
import os

import build123d
import matplotlib.pyplot as plt
import numpy as np
import salvus.namespace as sn
import wscan
For convenience, a number of constants are defined below. Constant parameters are denoted with variable names in all capital letters.
First, the simulation site and the location to which the project should be saved are defined.
# Salvus site on which simulations are to be run
SALVUS_FLOW_SITE_NAME = os.environ.get("SITE_NAME", "local")

# Path where the project should be saved
PATH_TO_PROJECT = "project_notches_tutorial"
Next, parameters that control the mesh resolution and the source time function are set.
# Maximum frequency of the source in Hz; also controls how finely the mesh is
# to be discretized
MAX_FREQUENCY = 2.5e6

# Due to the number of wavelengths that will be propagated, the
# spectral-element order is chosen as 7
SPECTRAL_ELEMENT_ORDER = 7

# Number of elements per wavelength; one of the key parameters that controls
# how fine the mesh is.  Refer to
# https://docs.mondaic.com/knowledge_base/spectral_element_modelling/hp_refinement
# for more details.
ELEMENTS_PER_WAVELENGTH = 1.0

# Simulation end time in seconds
SIMULATION_END_TIME = 25.0e-6
The material properties of the various constituents can be defined using sn.material.Material objects. These can either be defined manually or with W-Scan's material library.
# The material properties of the wedge and the plate are taken from the
# material library
WEDGE_MATERIAL = wscan.library.materials.materials["epoxy resin"].material
SPECIMEN_MATERIAL = wscan.library.materials.materials["steel"].material
Finally, the geometric properties of the domain can be defined. This includes the overall extents of the steel plate and the nominal dimensions that will be reused across all three notch geometries so that they can be fairly compared. By convention, the top of the specimen sits at y = 0 and the specimen extends downwards into the negative y half-plane; the notch is then introduced from the bottom surface and extends upwards (the Specimen class internally rotates the supplied notch by 180 degrees).
# Horizontal extents of the plate in meters. The wedge probe sits near the left
# edge and the notch is positioned near the right edge.
X_MIN = -30.0e-3
X_MAX = 10.0e-3

# Plate thickness in meters
THICKNESS = 10.0e-3

# Common notch dimensions in meters.
NOTCH_WIDTH = 0.5e-3
NOTCH_DEPTH = 5.0e-3
Each notch is constructed as a wscan.geometry.dim2.notches.Specimen, which combines a rectangular plate with an embedded Notch. Three different notch types are instantiated below, each sharing the same specimen extents but differing in the morphology of the reflector itself.

Rectangular notch

First, the simplest reflector is assembled using a flat-bottomed slot with a constant width.
rectangular_notch = wscan.geometry.dim2.notches.SimpleSpecimen(
    notch=wscan.geometry.dim2.notches.Rectangular(NOTCH_WIDTH, NOTCH_DEPTH),
    x_left_in_meters=X_MIN,
    x_right_in_meters=X_MAX,
    thickness_in_meters=THICKNESS,
)
rectangular_notch.plot()
Next, a stair stepped V-shaped notch is assembled using the union of three nested rectangles whose widths decrease and whose depths increase from the surface inward. The widths and depths are chosen such as to produce a profile that narrows monotonically with depth while keeping all internal edges axis-aligned.
stepped_notch = wscan.geometry.dim2.notches.SimpleSpecimen(
    notch=wscan.geometry.dim2.notches.Stepped(
        NOTCH_WIDTH * 1.5,
        NOTCH_DEPTH * 1 / 3,
        NOTCH_WIDTH,
        NOTCH_DEPTH * 2 / 3,
        NOTCH_WIDTH * 0.5,
        NOTCH_DEPTH,
    ),
    x_left_in_meters=X_MIN,
    x_right_in_meters=X_MAX,
    thickness_in_meters=THICKNESS,
)
stepped_notch.plot()
Finally, the third notch consists of a thin, jagged surface-breaking crack. The Crack is fully parameterized, meaning that one could quickly prototype a variety of crack geometries. The random_seed is fixed for this example so that the geometry is reproducible. In a parametric study, one would instead loop over many seeds to sample a population of plausible crack realisations.
crack_notch = wscan.geometry.dim2.notches.SimpleSpecimen(
    notch=wscan.geometry.dim2.notches.Crack(
        direction=np.array([0.0, -1.0]),
        depth_in_meters=NOTCH_DEPTH,
        width_in_meters=NOTCH_WIDTH * 0.1,
        lateral_variability=0.05,
        random_seed=42,
    ),
    x_left_in_meters=X_MIN,
    x_right_in_meters=X_MAX,
    thickness_in_meters=THICKNESS,
)
crack_notch.plot()
A WedgeProbe2D is placed on the top surface of the specimen so that it fires obliquely toward the notch. The wedge geometry, transducer layout, and damping layer follow the same pattern as the other NDT tutorials:
  • origin places the probe near the left edge of the specimen, away from the notch region.
  • wedge_angle_in_degrees=45 produces an obliquely incident wavefront in the steel.
  • A large number_of_elements is used to synthesise a smooth plane wave.
  • damping_depth introduces an absorbing layer on the back face of the wedge that suppresses spurious reverberations from the probe body.
probe = wscan.instruments.probe.WedgeProbe2D(
    material=wscan.library.materials.materials["Epoxy resin"].get_material(),
    origin=np.array([-0.02, 0.0]),
    transducer_position=np.array([2.4e-3, 9.4e-3]),
    front_face_distance=16.9e-3,
    back_face_distance=2.7e-3,
    height=14.2e-3,
    wedge_angle_in_degrees=45.0,
    number_of_elements=256,
    element_width=3.75e-05,
    inter_element_spacing=0.0,
    damping_depth=1.0e-3,
    receiver_fields=["displacement"],
    maximum_frequency=MAX_FREQUENCY,
)
probe.plot()
<Axes: xlabel='x [m]', ylabel='y [m]'>
Each notch specimen is combined with the probe into a single build123d.Compound. The probe's wedge and damping sub-bodies are unpacked with *probe.geometry.children and joined with the notched specimen, producing one assembled geometry per notch type. All three assemblies share the same probe but differ in the notch morphology.
geometries = {}

for name, notch in zip(
    ["rectangular", "stepped", "crack"],
    [rectangular_notch, stepped_notch, crack_notch],
):
    # Assemble the geometry into a `build123d` compound
    geometries[name] = build123d.Compound(
        children=(*probe.geometry.children, notch.geometry),
        label="assembly",
    )

    wscan.geometry.dim2.utils.plot_build123d_entity(
        geometries[name], legend=True
    )
Each assembly is meshed via Coreform Cubit using the notched_specimen algorithm, a meshing strategy tailored to this class of geometries. In particular, the assignment of suitable element sizes, side sets, and materials is handled automatically by this meshing strategy.
As with the other tutorials, the resolution is governed by reference_frequency and elements_per_wavelength, which together determine the element size required to resolve waves at MAX_FREQUENCY. The labelled regions (wedge, damping, specimen) are assigned to the corresponding material properties through the model.
In order for this tutorial to be run by users that do not have Coreform Cubit installed, examples of each of the meshes are provided in the supplementary data directory. However, if one were to construct these meshes themselves, one could do so using the following command:
meshes = {}

for name, geometry in geometries.items():
    meshes[name] = wscan.mesh.mesh_from_geometry(
        geometry=geometry,
        model={
            "wedge": WEDGE_MATERIAL,
            "damping": WEDGE_MATERIAL,
            "specimen": SPECIMEN_MATERIAL,
        },
        mesh_resolution=sn.MeshResolution(
            reference_frequency=MAX_FREQUENCY,
            elements_per_wavelength=ELEMENTS_PER_WAVELENGTH
        ),
        algorithm=wscan.mesh.algorithms.cubit.notched_specimen.Mesher(
            wscan.mesh.algorithms.cubit.notched_specimen.Options(
                site=SALVUS_FLOW_SITE_NAME
            )
        )
    )
However, for this tutorial, the meshes are instead loaded from disk.
meshes = {}

for name in geometries.keys():
    # Load the mesh from disk
    meshes[name] = sn.UnstructuredMesh.from_h5(f"data/{name}.h5")
Each generated mesh can be visually inspected to confirm that the notch tip is properly resolved and that the wedge–specimen interface looks reasonable.
meshes["rectangular"]
<unstructured_mesh.unstructured_mesh.UnstructuredMesh object at 0x7ed42378cdd0>
meshes["stepped"]
<unstructured_mesh.unstructured_mesh.UnstructuredMesh object at 0x7ed423710d10>
meshes["crack"]
<unstructured_mesh.unstructured_mesh.UnstructuredMesh object at 0x7ed437abfc50>
A new Salvus project is initialized from the rectangular-notch mesh; the remaining two meshes are subsequently added as additional simulation configurations under the same project.
p = sn.Project.from_mesh(
    path=PATH_TO_PROJECT, mesh=meshes["rectangular"], load_if_exists=True
)
The probe's get_event method instantiates the sources and receivers corresponding to each transducer element. The excitation_mode="longitudinal" specifies that each element radiates longitudinal (compressional) waves into the wedge.
Since all three meshes share the same probe, the same event can be reused for every simulation configuration.
p.add_to_project(
    probe.get_event(
        excitation_mode="longitudinal",
        source_type=sn.simple_config.source.cartesian.SideSetVectorPoint2D,
        receiver_type=sn.simple_config.receiver.cartesian.SideSetPoint2D,
    )
)
A ToneBurst source time function is used to produce a narrowband excitation centred at approximately three-quarters of the maximum frequency.
stf = sn.simple_config.stf.ToneBurst(
    center_frequency=3 / 4 * MAX_FREQUENCY,
    num_cycles=4,
)
stf.plot()
In order to compare snapshots between the three simulations at exactly the same physical times, all three runs are forced to use a common time step. The time step is computed mesh-by-mesh from the Courant–Friedrichs–Lewy (CFL) condition, and the smallest of the three is adopted for every simulation. This ensures both stability for the finest mesh while also ensuring that the wavefield snapshots are time-aligned across all three simulations.
# Placeholder for the time step
time_step = np.inf

for mesh in meshes.values():
    # Get the time step for the current mesh
    time_step_iter = mesh.compute_time_step(
        max_velocity=np.max(mesh.elemental_fields["VP"], axis=1),
        simulation_order=SPECTRAL_ELEMENT_ORDER,
    )[0]

    # If the time step computed above is smaller than the one we currently have
    # in memory, assign the newly computed time step as the global time step
    time_step = min(time_step_iter, time_step)

print(f"Global time step: {time_step} s")
Global time step: 5.893590639289037e-10 s
A separate UnstructuredMeshSimulationConfiguration is added for each notch geometry, all sharing the same source time function, spectral element order, end time, and absorbing boundary conditions. Absorbing boundary conditions are applied on the left (x0) and right (x1) plate edges, in addition to the probe damping layer (damping) in order to suppress spurious reflections from those boundaries. For the sake of simplicity, sponge layers are disabled by setting width_in_meters=0.0; only first-order absorbing conditions are applied directly on the boundary faces. Any exterior boundaries that are not explicitly assigned a boundary condition are implicitly treated as free surfaces.
for name, mesh in meshes.items():
    p.add_to_project(
        sn.UnstructuredMeshSimulationConfiguration(
            name=name,
            unstructured_mesh=mesh,
            event_configuration=sn.EventConfiguration(
                wavelet=stf,
                waveform_simulation_configuration=sn.WaveformSimulationConfiguration(
                    end_time_in_seconds=SIMULATION_END_TIME,
                    spectral_element_order=SPECTRAL_ELEMENT_ORDER,
                    time_step_in_seconds=time_step,
                    boundary_conditions=sn.simple_config.boundary.Absorbing(
                        taper_amplitude=MAX_FREQUENCY,
                        side_sets=["x0", "x1", "damping"],
                        width_in_meters=0.0,
                    ),
                ),
            ),
        )
    )
As a quick sanity check, the geometry is plotted together with the transducer source positions to confirm that the elements lie on the wedge's inclined face.
ax = wscan.geometry.dim2.utils.plot_build123d_entity(
    geometries["crack"], legend=True, figsize=(12, 12)
)

for source in p.events.get(p.events.list()[0]).sources:
    plt.plot(source.point[0], source.point[1], "k.")

plt.show()
The three simulations are launched sequentially. In order to keep the volumetric wavefield file sizes manageable, the volumetric output is downsampled in time via sampling_interval_in_time_steps, which is chosen here so that roughly 300 snapshots are produced across the full simulation window. The query(block=True) blocks the cell until each simulation has finished before the next one is launched.
for name in p.simulations.list():
    p.simulations.launch(
        simulation_configuration=name,
        events=p.events.list(),
        site_name=SALVUS_FLOW_SITE_NAME,
        ranks_per_job=sn.api.get_site(SALVUS_FLOW_SITE_NAME).config[
            "default_ranks"
        ],
        extra_output_configuration={
            "volume_data": {
                "sampling_interval_in_time_steps": int(
                    SIMULATION_END_TIME / time_step / 300
                ),
                "fields": ["displacement"],
            },
        },
    )
    p.simulations.query(block=True)
[2026-06-02 18:21:45,906] INFO: Submitting job ...
Uploading 2 files...

🚀  Submitted job_2606021821204224_04d8718f4d@local
[2026-06-02 18:22:16,555] INFO: Submitting job ...
Uploading 2 files...

🚀  Submitted job_2606021822710231_3313065380@local
[2026-06-02 18:22:45,916] INFO: Submitting job ...
Uploading 2 files...

🚀  Submitted job_2606021822074598_65fd2d95f0@local
Once all three simulations have finished, the volumetric wavefield from each one is interpolated onto a common Cartesian grid covering the region around the notch. The interpolation is carried out in the spectral-element basis and, therefore, inherits the full accuracy of the spectral-element solution.
Note: Points in the grid that fall inside the void of each notch, which contains no finite elements, will remain unclaimed during the interpolation. The resulting warning from Salvus can safely be ignored in this case.
wavefields = {}

for name in p.simulations.list():
    # Get the event data from which the wavefield can be extracted
    event_data = p.waveforms.get(name, p.events.list()[0])[0]

    # Interpolate the wavefield to a regular grid
    wavefields[name] = sn.wavefield_output_to_xarray(
        wo=event_data.get_wavefield_output(
            output_type="volume",
            field="displacement",
        ),
        points=[
            np.linspace(-0.0024, 0.0023, 201),
            np.linspace(-0.0037, -0.01, 201),
        ],
    )
[2026-06-02 18:23:22,453] WARNING - salvus.mesh.algorithms.unstructured_mesh.utils: 309 points were not claimed by enclosing elements. Depending on your use case, this may not be an issue.
[2026-06-02 18:24:07,085] WARNING - salvus.mesh.algorithms.unstructured_mesh.utils: 3339 points were not claimed by enclosing elements. Depending on your use case, this may not be an issue.
[2026-06-02 18:24:53,799] WARNING - salvus.mesh.algorithms.unstructured_mesh.utils: 3388 points were not claimed by enclosing elements. Depending on your use case, this may not be an issue.
Three snapshots are plotted as the incident wavefield arrives at the notch. A common color range (vrange) is used so that the relative amplitudes are directly comparable between the three columns.
# Approximate times at which the waves arrive at the different notches
times_to_plot = np.array([8.7e-6, 9.5e-6, 10.2e-6])

# Fixed color range
vrange = 2.0e-10

for t_plot in times_to_plot:
    _, axs = plt.subplots(
        nrows=1, ncols=3, figsize=[10, 4], sharex=True, sharey=True
    )

    for ax, name in zip(axs, meshes.keys()):
        # Plot the outline of the geometry
        ax = wscan.geometry.dim2.utils.plot_build123d_entity(
            geometries[name],
            face_alpha=0.0,
            ax=ax,
        )

        # Get the time index that is closest to `t_plot`
        t_ind = np.argmin(np.abs(wavefields[name].t.to_numpy() - t_plot))

        # Plot the wavefield; `c=1` is the y-component of displacement,
        # `c=0` is the x-component
        wavefields[name].sel(c=1, t=wavefields[name].t[t_ind]).T.plot(
            shading="gouraud",
            infer_intervals=False,
            ax=ax,
            cmap="coolwarm",
            zorder=-1,
            add_colorbar=False,
            vmin=-vrange,
            vmax=vrange,
        )

        # Formatting
        ax.set_aspect("equal")
        ax.set_xlabel("x [m]")
        ax.set_ylabel("y [m]" if name == "rectangular" else "")

    plt.tight_layout()
    plt.show()
In order to visualize the entire wavefield, one can alternatively view the raw simulation output in a third-party visualization tool, such as ParaView. The paths to the simulation output directories for each notch geometry are listed below:
for name in p.simulations.list():
    print(
        f"{name}: "
        + str(
            p.simulations.get_simulation_output_directory(
                simulation_configuration=name, event=p.events.list()[0]
            )
        )
    )
crack: project_notches_tutorial/EVENTS/wedge_probe_390192a6_0.0_degrees_longitudinal/WAVEFORM_DATA/INTERNAL/a0/72/a8932481b4d0
rectangular: project_notches_tutorial/EVENTS/wedge_probe_390192a6_0.0_degrees_longitudinal/WAVEFORM_DATA/INTERNAL/fa/22/d978414e252a
stepped: project_notches_tutorial/EVENTS/wedge_probe_390192a6_0.0_degrees_longitudinal/WAVEFORM_DATA/INTERNAL/89/2c/c927ef6c499d
For more details on visualizing wavefields with ParaView, see here.
The notch helpers used above cover the simplest reference geometry (i.e., a rectangular specimen with the SimpleSpecimen class), but any specimen that can be expressed in build123d slots into exactly the same pipeline. As a closing example, a specimen with a flat top surface and a wavy bottom is assembled directly from a series of cubic splines. This example is merely intended to demonstrate the flexibility and customizability of this modeling approach.
# The actual bottom surface oscillates about `-THICKNESS` by +/-
# `WAVY_AMPLITUDE`.
WAVY_AMPLITUDE = 0.5e-3

# Number of half-wavelength humps along the bottom; one `build123d.Spline`
# segment is produced per hump.
WAVY_NUMBER_OF_HUMPS = 8
Each hump is described by a single cubic spline through three control points, namely the two endpoints on the nominal bottom line and a midpoint that is displaced vertically by WAVY_AMPLITUDE. The sign of the midpoint offset alternates between consecutive humps so that the resulting profile resembles a sinusoid while remaining locally smooth.
hump_width = (X_MAX - X_MIN) / WAVY_NUMBER_OF_HUMPS

# The `build123d` splines will be stored here
bottom_splines = []

for i in range(WAVY_NUMBER_OF_HUMPS):
    # Get the start and end points of the spline
    x_start = X_MIN + i * hump_width
    x_end = x_start + hump_width
    sign = 1 if i % 2 == 0 else -1

    # Assemble the spline as a `build123d` entity
    bottom_splines.append(
        build123d.Spline(
            [
                (x_start, -THICKNESS),
                (
                    0.5 * (x_start + x_end),
                    -THICKNESS + sign * WAVY_AMPLITUDE,
                ),
                (x_end, -THICKNESS),
            ]
        )
    )
The remaining three sides of the specimen (the two vertical edges and the flat top) are constructed as simple straight line segments. All edges are then collected, in order, into a single closed wire from which a planar face can be built.
# Create the flat edges of the specimen
left_edge = build123d.Line([(X_MIN, 0.0), (X_MIN, -THICKNESS)])
right_edge = build123d.Line([(X_MAX, -THICKNESS), (X_MAX, 0.0)])
top_edge = build123d.Line([(X_MAX, 0.0), (X_MIN, 0.0)])

# Create a face from the outline
wavy_specimen = build123d.make_face(
    build123d.Wire([left_edge, *bottom_splines, right_edge, top_edge])
).face()
wavy_specimen.label = "specimen"

wscan.geometry.dim2.utils.plot_build123d_entity(wavy_specimen)
plt.show()
A notch can then be added to the specimen; a U-shaped notch is considered for this example. For this type of custom geometry, the notch must be manually placed to the correct position and with the desired rotation.
# Create a U-shaped notch
ushaped_notch = wscan.geometry.dim2.notches.UShaped(
    NOTCH_WIDTH, NOTCH_DEPTH
).geometry

# Place the notch at the crest of one of the wave humps
ushaped_notch = ushaped_notch.rotate(axis=build123d.Axis.Z, angle=180.0)
ushaped_notch.position += (-0.0025, -(THICKNESS + WAVY_AMPLITUDE))

wscan.geometry.dim2.utils.plot_build123d_entity(ushaped_notch)
<Axes: xlabel='x [m]', ylabel='y [m]'>
Finally, the notch can be incorporated in the geometry by performing a boolean subtraction between the specimen and the notch.
# Subtract the notch from the specimen
notched_wavy_specimen = wavy_specimen - ushaped_notch

wscan.geometry.dim2.utils.plot_build123d_entity(notched_wavy_specimen)
plt.show()
The remainder of the modeling pipeline would remain the same as from the first parts of this tutorial. That is, a transducer would be added to the geometry, the domain could be meshed, and then a simulation could be run with this geometry.
PAGE CONTENTS