Crack notch as the defect. A wedge probe is
placed on the top surface of the plate, a tone-burst excitation is injected
into the medium, and the resulting volumetric wavefield is post-processed
into a maximum amplitude distribution.import os
import build123d
import matplotlib.pyplot as plt
import numpy as np
from salvus.mesh.unstructured_mesh_utils import extract_model_to_regular_grid
import salvus.namespace as sn
import wscan
import xarray as xr# 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_maximum_amplitude_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["Acrylic glass"].materialy = 0 and the specimen extends downwards
into the negative y half-plane; the wedge probe sits near the left edge and
the crack notch breaks the bottom surface near the centre of the plate and
extends upwards (the SimpleSpecimen class internally rotates the supplied
notch by 180 degrees), where the obliquely incident wavefield reaches it.# Horizontal extents of the plate in meters. The wedge probe sits near the left
# edge and the crack notch is positioned near the centre.
X_MIN = -30.0e-3
X_MAX = 10.0e-3
# Plate thickness in meters
THICKNESS = 10.0e-3
# Crack notch dimensions in meters.
NOTCH_WIDTH = 0.5e-3
NOTCH_DEPTH = 5.0e-3build123d. The
notched specimen and the probe are defined separately before being combined
into a single compound.wscan.geometry.dim2.notches.SimpleSpecimen helper, which combines a
rectangular plate (spanning X_MIN to X_MAX horizontally and extending
from the surface at y = 0 down to y = -THICKNESS) with an embedded notch.
The resulting face is labeled specimen, which is later used to assign
material properties during the meshing process.Crack, constructed exactly as
in the
notched specimen tutorial.
The Crack is fully parameterized: direction orients the crack as it
propagates from the surface towards its tip, depth_in_meters and
width_in_meters set its vertical extent and mouth opening, and
lateral_variability controls the roughness of the crack walls.random_seed ensures the result is reproducible. However, one
could alternatively vary this within a parametric study in order to simulate
a collection of different crack geometries.specimen = wscan.geometry.dim2.notches.SimpleSpecimen(
notch=wscan.geometry.dim2.notches.Crack(
direction=np.array([-0.25, -1.0]),
depth_in_meters=NOTCH_DEPTH,
width_in_meters=NOTCH_WIDTH * 0.1,
lateral_variability=0.05,
random_seed=9001,
),
x_left_in_meters=X_MIN,
x_right_in_meters=X_MAX,
thickness_in_meters=THICKNESS,
)
specimen.plot()WedgeProbe2D is placed on the top surface of the specimen so that it
fires obliquely toward the crack. The wedge geometry, transducer layout, and
damping layer follow the same dimensions as the other NDT tutorials:origin places the probe near the left edge of the specimen, away from the
crack region.wedge_angle_in_degrees=45 produces an obliquely incident wavefront in the
specimen.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 object:specimen.geometry: the notched specimen face, with the crack void already
subtracted from the plate and the specimen label preserved.*probe.geometry.children: unpacks the probe's wedge and damping
sub-bodies.geometry = build123d.Compound(
children=(*probe.geometry.children, specimen.geometry),
label="assembly",
)
ax = wscan.geometry.dim2.utils.plot_build123d_entity(geometry, legend=True)
plt.show()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. The compound
contains exactly three labeled faces — wedge, damping, and specimen —
each of which is assigned to the corresponding material properties through
the model.reference_frequency and elements_per_wavelength, which together determine
the element size required to resolve waves at MAX_FREQUENCY.data directory. However, if one were to construct these meshes themselves,
one could do so using the following command:mesh = 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, model_order=SPECTRAL_ELEMENT_ORDER, ), algorithm=wscan.mesh.algorithms.cubit.notched_specimen.Mesher( wscan.mesh.algorithms.cubit.notched_specimen.Options(site=SALVUS_FLOW_SITE_NAME) ), )
mesh = sn.UnstructuredMesh.from_h5("./data/crack.h5")mesh<unstructured_mesh.unstructured_mesh.UnstructuredMesh object at 0x7d47814e9350>
p = sn.Project.from_mesh(PATH_TO_PROJECT, mesh)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()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.p.add_to_project(
sn.UnstructuredMeshSimulationConfiguration(
name="forward_simulation",
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,
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(
geometry, 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()frequency_domain key in the
extra_output_configuration. Recall that the source time function is
a tone burst with the following temporal characteristics:stf.plot()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={
"frequency_domain": {
"frequencies": np.linspace(
MAX_FREQUENCY / 2, MAX_FREQUENCY, 25
).tolist(),
"fields": ["displacement"],
"start_time_in_seconds": 0.0,
"end_time_in_seconds": SIMULATION_END_TIME,
},
},
delete_conflicting_previous_results=True,
)
p.simulations.query(block=True, ping_interval_in_seconds=1.0)[2026-06-16 03:58:20,213] INFO: Submitting job ... Uploading 2 files... 🚀 Submitted job_2606160358404832_24731e4a04@local
True
UnstructuredMesh. It carries one complex-valued FFT-* elemental field per
requested frequency and displacement component, alongside an auxiliary
fluid field that is not needed here and is, therefore, discarded.fft_wavefield = sn.UnstructuredMesh.from_h5(
p.simulations.get_simulation_output_directory(
simulation_configuration="forward_simulation", event=p.events.list()[0]
)
/ "frequency_domain_output.h5"
)
del fft_wavefield.elemental_fields["fluid"]FFT-Re_#_X: Real part of the x-component of the displacement wavefieldFFT-Im_#_X: Imaginary part of the x-component of the displacement wavefieldFFT-Re_#_Y: Real part of the y-component of the displacement wavefieldFFT-Im_#_Y: Imaginary part of the y-component of the displacement wavefield# Attach the field that will store the maximum amplitudes
fft_wavefield.attach_field(
"maximum_amplitude",
np.zeros_like(fft_wavefield.connectivity, dtype=float),
)
# Get the names of the elemental fields that contain the FFT wavefield for each
# discrete frequency
fft_fields = [
key
for key in fft_wavefield.elemental_fields.keys()
if key.startswith("FFT-")
]
for field in fft_fields:
# Add the square of the FFT field to the maximum field
fft_wavefield.elemental_fields["maximum_amplitude"] += (
fft_wavefield.elemental_fields[field] ** 2
)
# Remove the field from the mesh; this is only done to make it easier to
# find the `maximum_amplitude` field in the mesh visualization widget
fft_wavefield.elemental_fields.pop(field)
# Apply the normalization factor of 1 / N and take the square root
fft_wavefield.elemental_fields["maximum_amplitude"] = np.sqrt(
fft_wavefield.elemental_fields["maximum_amplitude"] / (len(fft_fields) / 4)
)
# Normalize for ease of plotting
fft_wavefield.elemental_fields[
"maximum_amplitude"
] /= fft_wavefield.elemental_fields["maximum_amplitude"].max()maximum_amplitude field
using Salvus' mesh visualization widgetmatplotlibfft_wavefield<unstructured_mesh.unstructured_mesh.UnstructuredMesh object at 0x7d47697aab10>
# Define the grid onto which to interpolate the mesh
grid = xr.Dataset(
coords={
"x": np.linspace(
fft_wavefield.points[:, 0].min(),
fft_wavefield.points[:, 0].max(),
601,
),
"y": np.linspace(
fft_wavefield.points[:, 1].min(),
fft_wavefield.points[:, 1].max(),
601,
),
}
)
# Perform the interpolation
envelope = extract_model_to_regular_grid(
fft_wavefield, grid, ["maximum_amplitude"]
)
# Plot the data
plt.figure(figsize=(8, 8))
envelope.maximum_amplitude.T.plot(
cmap="plasma",
vmin=0.0,
vmax=0.6,
add_colorbar=False,
)
wscan.geometry.dim2.utils.plot_build123d_entity(
geometry,
face_alpha=0.0,
ax=plt.gca(),
)
plt.show()