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.
import os
import build123d
import matplotlib.pyplot as plt
import numpy as np
import salvus.namespace as sn
import wscanFor 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-6The 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"].materialFinally, 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-3Notch geometry examples
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()Stepped (V-shaped) notch
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()Crack-like notch
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()Wedge probe
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:
originplaces the probe near the left edge of the specimen, away from the notch region.wedge_angle_in_degrees=45produces an obliquely incident wavefront in the steel.- A large
number_of_elementsis used to synthesise a smooth plane wave. damping_depthintroduces 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]'>
Assembly
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
)Meshing
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"]<salvus.mesh.data_structures.unstructured_mesh.unstructured_mesh.UnstructuredMesh object at 0x7d4d742d7cd0>
meshes["stepped"]<salvus.mesh.data_structures.unstructured_mesh.unstructured_mesh.UnstructuredMesh object at 0x7d4d742e7a10>
meshes["crack"]<salvus.mesh.data_structures.unstructured_mesh.unstructured_mesh.UnstructuredMesh object at 0x7d4d74554b50>
Simulation setup
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_TO_PROJECT, meshes["rectangular"])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()Running the simulation
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-10-12 21:28:47,517] INFO: Submitting job ... Uploading 2 files... 🚀 Submitted job_2610122128693724_d85ae613ae@local
[2026-10-12 21:29:15,455] INFO: Submitting job ... Uploading 2 files... 🚀 Submitted job_2610122129545163_c7ab4a007d@local
[2026-10-12 21:29:48,870] INFO: Submitting job ... Uploading 2 files... 🚀 Submitted job_2610122129998554_376f7fd2e7@local
Plotting the data
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-10-12 21:30:20,234] 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-10-12 21:31:03,599] 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-10-12 21:31:49,065] 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/fc/f1/864483ef3d6f rectangular: project_notches_tutorial/EVENTS/wedge_probe_390192a6_0.0_degrees_longitudinal/WAVEFORM_DATA/INTERNAL/2c/81/66a65e2940d9 stepped: project_notches_tutorial/EVENTS/wedge_probe_390192a6_0.0_degrees_longitudinal/WAVEFORM_DATA/INTERNAL/6b/87/3d3d2458fed8
For more details on visualizing wavefields with ParaView, see here.
Extensions to more complex specimens
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 = 8Each 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.