import os
import build123d
import matplotlib.pyplot as plt
import numpy as np
import salvus.namespace as sn
import wscan# 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"# 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-6sn.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"].materialy = 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-3wscan.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 = 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_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 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()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.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]'>
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
)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.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.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 ) ) )
meshes = {}
for name in geometries.keys():
# Load the mesh from disk
meshes[name] = sn.UnstructuredMesh.from_h5(f"data/{name}.h5")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>
p = sn.Project.from_mesh(
path=PATH_TO_PROJECT, mesh=meshes["rectangular"], load_if_exists=True
)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.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,
)
)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()# 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
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,
),
),
),
)
)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()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
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.
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()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
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 = 8WAVY_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),
]
)
)# 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()# 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]'>
# 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()