Version:

Ultrasonic Weld Inspection Tutorial

This tutorial demonstrates how to simulate ultrasonic weld inspections using a wedge probe in 2D. A V-type weld joining two steel plates is modeled, with an internal crack introduced near the weld root to simulate a typical defect. A wedge probe is placed on the plate surface to interrogate the weld region.
The physical domain consists of three material regions:
  • Plate: two halves of a steel plate that are joined by a weld.
  • Weld: a V-type weld bead with face and root reinforcement, modeled with slightly different material properties than the surrounding plate material.
  • Wedge probe: a plastic wedge carrying a single transducer array that is coupled to the top surface of the plate.
An internal crack is introduced near the weld root as the target defect. The interactions between the ultrasonic wavefield and the crack are demonstrated.

Importing libraries and setting constants

As usual, a series of standardized libraries will be 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_weld_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
PLATE_MATERIAL = wscan.library.materials.materials["steel"].material

# The properties of the weld are manually derived from the `PLATE_MATERIAL`
WELD_MATERIAL = sn.material.from_params(
    VP=PLATE_MATERIAL.VP.p * 1.05,
    VS=PLATE_MATERIAL.VS.p * 1.05,
    RHO=PLATE_MATERIAL.RHO.p * 1.05,
)
Finally, the geometric properties of the domain can be defined. This includes both the properties of the plate, the weld, and the wedge probe. Note that all quantities are in base SI units.
# Plate dimensions
PLATE_WIDTH = 0.15
PLATE_THICKNESS = 0.025

# V-type weld geometry
ROOT_GAP = 0.01
GROOVE_ANGLE_DEGREES = 45.0
FACE_REINFORCEMENT = 0.001
ROOT_REINFORCEMENT = 0.0005

# Probe geometry; PROBE_POSITION places the wedge front face to the left of the
# weld centerline, and TRANSDUCER_POSITION is the center of the transducer
# array on the wedge's inclined face (in probe-local coordinates)
WEDGE_ANGLE_DEGREES = 36.10
PROBE_POSITION = np.array([-1.25 * PLATE_THICKNESS, 0.0])
TRANSDUCER_POSITION = np.array([0.00387835, 0.00982814])

# A very dense collection of transducer elements is used in order to excite a
# smooth plane wave
N_TRANSDUCER_ELEMENTS = 256
TRANSDUCER_ELEMENT_WIDTH = 37.5e-6
The domain geometry is built programmatically using build123d. Each component (plate, weld, crack, and probe) is defined separately before being assembled into a single compound.

Plate

The plate is a rectangle of width PLATE_WIDTH and thickness PLATE_THICKNESS. The Align.MAX setting places the top surface at y = 0, so the plate extends downward to y = −PLATE_THICKNESS.
plate = build123d.Rectangle(
    width=PLATE_WIDTH,
    height=PLATE_THICKNESS,
    align=(build123d.Align.CENTER, build123d.Align.MAX),
).face()
The plate is labeled using a string attribute, which is later used to assign material properties during the meshing process.
plate.label = "plate"
wscan.geometry.dim2.utils.plot_build123d_entity(plate)
<Axes: xlabel='x [m]', ylabel='y [m]'>
While one could certainly construct the weld using a combination of geometric primitives in build123d, W-Scan includes a utility class wscan.geometry.dim2.welds.VTypeStandard, which parametrizes a standard single V-shaped weld. The groove angle, root gap, and reinforcement heights are controlled by the constants defined previously.
weld = wscan.geometry.dim2.welds.VTypeStandard(
    plate_thickness_in_meters=PLATE_THICKNESS,
    root_gap_in_meters=ROOT_GAP,
    groove_angle_degrees=GROOVE_ANGLE_DEGREES,
    face_reinforcement_in_meters=FACE_REINFORCEMENT,
    root_reinforcement_in_meters=ROOT_REINFORCEMENT,
).geometry
wscan.geometry.dim2.utils.plot_build123d_entity(weld)
<Axes: xlabel='x [m]', ylabel='y [m]'>
Note that the weld has not yet been applied to the plate yet; this will be incorporated when assembling all geometric entities into a single compound.
A randomly shaped internal crack is placed near the weld root. The crack is modeled as a thin defect through the use of the InternalCrack class. A small dislocation_distance is used to separate the two crack faces. The origin and direction orient the crack near the weld root, and lateral_variability controls the roughness of the crack path.
The fixed random_seed ensures the result is reproducible. However, one could alternatively vary this within a parameteric study in order to simulate a collection of different crack geometries.
crack = wscan.geometry.dim2.defects.InternalCrack(
    extent=PLATE_THICKNESS / 4,
    origin=np.array([-ROOT_GAP / 4, -PLATE_THICKNESS / 2]),
    direction=np.array([-0.1, -1.0]),
    dislocation_distance=PLATE_THICKNESS / 200,
    lateral_variability=PLATE_THICKNESS,
    n_samples=9,
    random_seed=42,
).geometry
wscan.geometry.dim2.utils.plot_build123d_entity(crack)
<Axes: xlabel='x [m]', ylabel='y [m]'>
The probe is a WedgeProbe2D object that encapsulates both the CAD geometry of the plastic wedge and the transducer element layout:
  • front_face_distance and back_face_distance the lateral extents of the wedge relative to the origin point.
  • height is the maximum height of the wedge.
  • damping_depth controls the thickness of the absorbing damping layer attached to the back face of the wedge, which helps to suppress spurious reflections from the probe body.
PROBE_POSITION shifts the entire probe assembly so that the wedge front face sits slightly to the left of the weld face reinforcement.
probe = wscan.instruments.probe.WedgeProbe2D(
    material=WEDGE_MATERIAL,
    origin=PROBE_POSITION,
    transducer_position=TRANSDUCER_POSITION,
    front_face_distance=16.9e-3,
    back_face_distance=2.7e-3,
    height=14.2e-3,
    wedge_angle_in_degrees=WEDGE_ANGLE_DEGREES,
    element_width=TRANSDUCER_ELEMENT_WIDTH,
    number_of_elements=N_TRANSDUCER_ELEMENTS,
    maximum_frequency=MAX_FREQUENCY,
    wedge_face_side_set_name="wedge",
    damping_depth=2.0e-3,
    sampling_factor=10.0,
)
probe.plot()
<Axes: xlabel='x [m]', ylabel='y [m]'>
Currently all of the geometric entities are disjoint from one another. In order to mesh the domain, all components need to be combined into a single build123d.Compound object. Boolean operations are used to carve the weld region out of the plate and to introduce the crack void into the weld:
  • *(plate - weld): subtracts the weld footprint from the plate so that the plate and weld bodies do not overlap. Since this boolean subtraction separates the plate into two halves, the * operator is used to unpack the resulting compound into its two face components.
  • weld - crack: subtracts the crack from the weld body, creating the defect void.
  • *probe.geometry.children: unpacks the probe's wedge and damping sub-bodies.
welded_plate = build123d.Compound(
    children=(*probe.geometry.children, *(plate - weld), weld - crack),
    label="welded_plate",
)

ax = wscan.geometry.dim2.utils.plot_build123d_entity(welded_plate, legend=True)
plt.show()
The assembly is meshed using the Coreform Cubit's pave algorithm via wscan.mesh.mesh_from_geometry. Material properties are assigned to each labeled face using the material dictionaries defined above. The mesh resolution is controlled by reference_frequency and elements_per_wavelength, which together determine the element size needed to accurately resolve the wavefield at MAX_FREQUENCY.
As noted previously, The resulting compound has four labeled faces: wedge, damping, plate, and weld. These labels can be inspected using build123d's show_topology method, which lists the faces and their attributes.
print(welded_plate.show_topology())
welded_plate Compound at 0x7426b211c710, Location(p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00))
├── wedge    Face     at 0x7426a9f3da10, Location(p=(-0.03, 0.00, 0.00), o=(-0.00, 0.00, -0.00))
├── damping  Face     at 0x7426b20e8e10, Location(p=(-0.03, 0.00, 0.00), o=(-0.00, 0.00, -0.00))
├── plate    Face     at 0x7426a9f36850, Location(p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00))
├── plate    Face     at 0x7426a9f37450, Location(p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00))
└── weld     Face     at 0x7426a9f375d0, Location(p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00))

The mesh_from_geometry function must contain one material entry for each of these distinct region names. Due to the geometric intricacies of this example, the wscan.mesh.algorithms.cubit.pave algorithm is used. However, since this additionally requires a Coreform Cubit installation, the mesh for this example has been provided in the accompanying data directory.
For users with an installed version of Coreform Cubit, the mesher can be directly executed using the following command:
m = wscan.mesh.mesh_from_geometry(
    geometry=welded_plate,
    model={
        "wedge": WEDGE_MATERIAL,
        "damping": WEDGE_MATERIAL,
        "plate": PLATE_MATERIAL,
        "weld": WELD_MATERIAL,
    },
    mesh_resolution=sn.MeshResolution(
        reference_frequency=MAX_FREQUENCY,
        elements_per_wavelength=ELEMENTS_PER_WAVELENGTH,
    ),
    algorithm=wscan.mesh.algorithms.cubit.pave.Mesher(
        options=wscan.mesh.algorithms.cubit.pave.Options(
            site=SALVUS_FLOW_SITE_NAME,
        )
    ),
)
In order for this tutorial to be self-contained for users that do not currently have a Coreform Cubit installation, the mesh can alternatively be loaded in directly using the UnstructuredMesh.from_h5 constructor.
m = sn.UnstructuredMesh.from_h5("data/weld_mesh.h5")
The mesh can then be visually inspected by plotting it.
<unstructured_mesh.unstructured_mesh.UnstructuredMesh object at 0x7426a9f317d0>
There are two side sets that need to be manually set on the mesh in order to apply the following:
  • Absorbing boundary conditions on the left and right plate edges, in addition to the probe damping.
  • Place the source transducers on the wedge face.
In order to apply these side sets to the mesh, the mesh is decomposed block-wise in order to isolate the relevant surface regions. Once the side sets have been applied to each sub-mesh, the sub-meshes are recombined into a single mesh that carries all the required side set information.
The block indices correspond to the order in which the faces were listed when constructing the build123d.Compound:
Block indexRegion
1Wedge
2Damping
3Plate
4Weld
Each distinct region of the mesh is extracted using the apply_element_mask method.
m_weld = m.apply_element_mask(m.elemental_fields["element_block_index"] == 4)
m_plate = m.apply_element_mask(m.elemental_fields["element_block_index"] == 3)
m_probe = m.apply_element_mask(
    (m.elemental_fields["element_block_index"] == 1)
    | (m.elemental_fields["element_block_index"] == 2)
)
For the plate sub-mesh, find_side_sets automatically detects the exterior boundary and labels it with cardinal names (x0, x1, y0, y1). The side sets x0 and x1 (left and right plate edges) will later be assigned absorbing boundary conditions to prevent edge reflections. While y0 and y1 are also present, they are not used subsequently and can be ignored.
m_plate.find_side_sets()
m_plate
<unstructured_mesh.unstructured_mesh.UnstructuredMesh object at 0x7426a84a9250>
The probe requires two named side sets:
  • damping: the outer surface of the damping layer, used to apply an absorbing boundary condition that mimics the physical backing material of the probe.
  • wedge: the inclined face of the wedge where the transducer elements are located; this is the interface through which sources and receivers are defined.
# Get the exterior surrounding the entire probe
m_probe.find_surface()

# Select only the damping region and rename the side set to "damping"
m_damping = m_probe.apply_element_mask(
    m_probe.elemental_fields["element_block_index"] == 2
)
m_damping.side_sets["damping"] = m_damping.side_sets["surface"]
m_damping.side_sets.pop("surface")

# Select only the wedge region and rename the side set to the wedge face
m_wedge = m_probe.apply_element_mask(
    m_probe.elemental_fields["element_block_index"] == 1
)
m_wedge.side_sets[probe.wedge_face_side_set_name] = m_wedge.side_sets[
    "surface"
]
m_wedge.side_sets.pop("surface")

# Recombine the probe
m_probe = m_damping + m_wedge
m_probe
<unstructured_mesh.unstructured_mesh.UnstructuredMesh object at 0x7426a8317e10>
The sub-meshes are then recombined into a single mesh that carries all the required side sets.
m = m_probe + m_plate + m_weld
m
<unstructured_mesh.unstructured_mesh.UnstructuredMesh object at 0x7426a8388f10>
Before creating the project, the cell below can be uncommented to remove any existing project directory and start fresh. Note that this only works on Unix-like operating systems.
# !rm -rf $PATH_TO_PROJECT
A new Salvus project is initialized from the mesh.
p = sn.Project.from_mesh(PATH_TO_PROJECT, m)
The probe's get_event method instantiates the required sources for the transducer on the wedge probe. Theexcitation_mode="longitudinal" specifies that the sources emit 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,
    )
)
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()
The simulation configuration ties together the mesh, the event, the source time function, and the solver settings.
As mentioned previously, absorbing boundary conditions are applied to 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, meaning that only first-order absorbing boundary conditions are applied directly on the boundary faces.
Note that any exterior boundaries that are not explicitly assigned a boundary condition are implicitly treated as free surfaces.
p.add_to_project(
    sn.UnstructuredMeshSimulationConfiguration(
        name="forward_simulation",
        unstructured_mesh=m,
        event_configuration=sn.EventConfiguration(
            wavelet=stf,
            waveform_simulation_configuration=sn.WaveformSimulationConfiguration(
                end_time_in_seconds=SIMULATION_END_TIME,
                spectral_element_order=SPECTRAL_ELEMENT_ORDER,
                boundary_conditions=sn.simple_config.boundary.Absorbing(
                    taper_amplitude=MAX_FREQUENCY,
                    side_sets=["x0", "x1", "damping"],
                    width_in_meters=0.0,
                ),
            ),
        ),
    )
)
We can visualize the geometry together with the source positions to verify that the transducer elements are correctly placed on the wedge face.
ax = wscan.geometry.dim2.utils.plot_build123d_entity(
    welded_plate, 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 simulation is launched asynchronously. In order to keep the file sizes of the volumetric wavefield manageable, the wavefield is saved to disk using a sampling interval of sampling_interval_in_time_steps.
p.simulations.launch(
    simulation_configuration="forward_simulation",
    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": 250,
            "fields": ["displacement"],
        },
    },
)
[2026-05-26 12:35:47,325] INFO: Submitting job ...
Uploading 2 files...

🚀  Submitted job_2605261235575168_4993c8d48f@local
1
The query call blocks until all jobs have completed.
p.simulations.query(block=True)
True
Once the simulation has finished, the volumetric wavefield is interpolated onto a regular Cartesian grid for visualization. The interpolation is carried out in the spectral-element basis, so it inherits the full accuracy of the spectral-element solution. The grid is chosen to cover the weld region and the surrounding plate material where the crack is located.
Note: points in the grid that fall inside the crack void (which contains no finite elements) will remain unclaimed during the interpolation. The resulting warning from Salvus can safely be ignored.
# Get the event data from which the wavefield can be extracted
event_data = p.waveforms.get("forward_simulation", p.events.list()[0])[0]

# Interpolate the wavefield to a regular grid
wavefield = sn.wavefield_output_to_xarray(
    wo=event_data.get_wavefield_output(
        output_type="volume",
        field="displacement",
    ),
    points=[
        np.linspace(-0.013, 0.0125, 201),
        np.linspace(-0.026, 0.0025, 201),
    ],
)
[2026-05-26 12:37:14,237] WARNING - salvus.mesh.algorithms.unstructured_mesh.utils: 4285 points were not claimed by enclosing elements. Depending on your use case, this may not be an issue.
A few snapshots are plotted at the approximate times when the incident shear wave arrives at the crack. The x-component of displacement (c=0) is shown, which highlights the shear wave mode and the diffraction tips at the crack ends.
# Approximate times at which the waves arrive at the crack
times_to_plot = np.array([10.0e-6, 12.3e-6, 14.3e-6])

for t_plot in times_to_plot:
    # Plot the outline of the geometry
    ax = wscan.geometry.dim2.utils.plot_build123d_entity(
        welded_plate,
        face_alpha=0.0,
    )

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

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

    ax.axes.set_aspect("equal")
    plt.show()
Alternativelly, one can plot the wavefield animation in a dedicated third-party visualization software, such as ParaView. The path to the relevant simulation files can be retrieved using the following command:
p.simulations.get_simulation_output_directory(
    simulation_configuration="forward_simulation", event=p.events.list()[0]
)
PosixPath('project_weld_tutorial/EVENTS/wedge_probe_ef1b4407_0.0_degrees_longitudinal/WAVEFORM_DATA/INTERNAL/fa/78/a59e989f84a2')
For more details on visualizing wavefields with ParaView, see here.
PAGE CONTENTS