Note: While SalvusPy supports many material forms, the solver currently only acceptselastic.isotropic.Velocity
,elastic.hexagonal.Velocity
orelastic.triclinic.TensorComponents
materials on the elastic side. You may need to convert your material to one of these forms before simulation.
import os
import numpy as np
import xarray as xr
from salvus import material
from salvus.material._details.material import to_solver_form
import salvus.namespace as sn
from scipy.spatial import Voronoi, cKDTree
# This is a local import for visualization helpers (provided with the notebook)
import wavefield_helpers
SALVUS_FLOW_SITE_NAME = os.environ.get("SITE_NAME", "local")
SALVUS_FLOW_RANKS = int(os.environ.get("NUM_MPI_RANKS", 4))
OUTPUT_FIELD = "displacement" # You can change this to e.g. 'velocity'
MAX_FREQ = 1.0 # Hz
PROJECT_DIR = f"project_{MAX_FREQ}"
DOMAIN_SIZE = 75_000 # meters
approximate_wavelength = 3500.0
RESOLUTION = int(
7.5 * DOMAIN_SIZE / approximate_wavelength
) # For visualization
# Create a cubic domain and initialize the project
box_domain = sn.domain.dim3.BoxDomain.from_bounds(
DOMAIN_SIZE, DOMAIN_SIZE, DOMAIN_SIZE
)
p = sn.Project.from_domain(
path=PROJECT_DIR, domain=box_domain, load_if_exists=True
)
# Define two events: P-wave and S-wave dominated
events = ["P-wave dominated", "S-wave dominated"]
location = {
c: v for c, v in zip(["x", "y", "z"], box_domain.bounding_box.mean(axis=0))
}
# Define an isotropic moment tensor for the P-wave source
moment_P = {
"mxx": 1.0,
"myy": 1.0,
"mzz": 1.0,
"mxy": 0.0,
"mxz": 0.0,
"myz": 0.0,
}
source_P = sn.simple_config.source.cartesian.MomentTensorPoint3D(
**location, **moment_P
)
# Define an antisymmetric gradient source (pure torque) for the S-wave source
source_S = sn.simple_config.source.cartesian.VectorGradientPoint3D(
**location,
gxx=0.0,
gxy=1.0,
gxz=0.0,
gyx=-1.0,
gyy=0.0,
gyz=0.0,
gzx=0.0,
gzy=0.0,
gzz=0.0,
)
sources = {k: v for k, v in zip(events, [source_P, source_S])}
# Add events to the project
for event in events:
p += sn.Event(event_name=event, sources=[sources[event]])
stf = sn.simple_config.stf.Ricker(center_frequency=MAX_FREQ / 2.0)
number_of_snapshots = 1 # Only store the last time step
end_time = (DOMAIN_SIZE / 10_000) * (4.0 / 5.0)
start_time = stf.get_auto_start_time()
visualization_time = end_time
time_step = 2.5e-2 / MAX_FREQ
number_of_time_steps = (end_time - start_time) / time_step
elements_per_wavelength = 1.25
output_configuration = {
"volume_data": {
# Calculate interval as to store 1 snapshot (Salvus always stores first
# and last).
"sampling_interval_in_time_steps": int(
number_of_time_steps / number_of_snapshots
),
"fields": [OUTPUT_FIELD],
},
}
wsc = sn.WaveformSimulationConfiguration(
time_step_in_seconds=time_step,
start_time_in_seconds=start_time,
end_time_in_seconds=end_time,
)
ec = sn.EventConfiguration(
wavelet=stf,
waveform_simulation_configuration=wsc,
)
sc_common_settings = {
"elements_per_wavelength": elements_per_wavelength,
"event_configuration": ec,
"max_frequency_in_hertz": MAX_FREQ,
}
UnstructuredMeshSimulationConfiguration
object to your project. If you simply need a homogeneous simulation, you can
use the simpler way of creating a ModelConfiguration object with a background
model from a material, and passing this to a SimulationConfiguration object.UnstructuredMeshSimulationConfiguration
manually.def create_results(project, name: str, material, mesh=None):
"""
Collection of SalvusProject actions we need to repeat often in this
notebook.
Note that it uses some variables that are not passed explicitly (like
`events`, `sc_common_settings`, `SALVUS_FLOW_SITE_NAME`, etc.). This works
because Python creates a closure: the function “remembers” those
outer-scope variables and can use them without you passing them in every
time. A closure is simply a function that captures values from its defining
scope.
Only works for elastic materials, as it contains a material cast to
triclinic.
"""
if sn.material.is_oriented(material):
# We need to convert the material to a tensor component form
# before passing it to the background model.
material = to_solver_form(material)
if sn.material.is_homogeneous(material):
# Homogeneous material, so SalvusProject will create the mesh for us.
project.add_to_project(
sn.SimulationConfiguration(
name=name,
model_configuration=sn.ModelConfiguration(
background_model=sn.model.background.homogeneous.FromMaterial(
material=sn.material.elastic.triclinic.TensorComponents.from_material(
material
)
)
),
**sc_common_settings,
),
overwrite=True,
)
else:
# Heterogeneous material, so we create a mesh using the layered mesher
# instead, and add it as an UnstructuredMeshSimulationConfiguration.
mesh = sn.layered_meshing.mesh_from_domain(
domain=box_domain,
model=material,
mesh_resolution=sn.MeshResolution(
reference_frequency=MAX_FREQ,
elements_per_wavelength=elements_per_wavelength,
),
)
project.add_to_project(
sn.UnstructuredMeshSimulationConfiguration(
name=name, unstructured_mesh=mesh, event_configuration=ec
),
overwrite=True,
)
p.simulations.launch(
simulation_configuration=name,
events=events,
site_name=SALVUS_FLOW_SITE_NAME,
ranks_per_job=SALVUS_FLOW_RANKS,
extra_output_configuration=output_configuration,
)
p.simulations.query(block=True, ping_interval_in_seconds=2.5)
material_1
: A simple isotropic material with P and S wave velocities.material_2
: A transversely isotropic material with anisotropic P and S
wave velocities.material_3
: A fully anisotropic material with a stiffness tensor defined
by its stiffness coefficients. Those are chosen as to result in P and S
wave velocities that are similar to the ones in the previous two materials
while providing strong anisotropy -- the specific values are not
meaningful.material_1 = material.from_params(vp=5000, vs=3500, rho=2500)
material_2 = material.from_params(
vpv=6000, vph=4000, vsv=2500, vsh=3500, rho=2500, eta=1.0
)
material_3 = material.from_params(
**{
# fmt: off
"rho": 2500,
"c11": 6.25e10,
"c12": 1.25e9,
"c13": 1.93e9,
"c14": 0.7e9,
"c15": 0.8e9,
"c16": 0.9e9,
"c22": 7.5625e10,
"c23": 1.45e9,
"c24": 0.8e9,
"c25": 1.9e9,
"c26": 1.0e9,
"c33": 5.e10,
"c34": 1.2e9,
"c35": 1.9e9,
"c36": 1.4e9,
"c44": 2.7e10,
"c45": 1.2e9,
"c46": 2.2e9,
"c55": 3.56e10,
"c56": 1.6e9,
"c66": 2.3e10,
# fmt: on
}
)
create_results
. This function takes care of creating the
simulations, launching them, and waiting for them to finish.create_results(p, "sc_material_1", material_1)
[2025-05-21 02:43:42,162] INFO: Creating mesh. Hang on. [2025-05-21 02:43:42,602] INFO: Submitting job array with 2 jobs ...
RESOLUTION
._ = wavefield_helpers.visualize_magnitude(
p,
"sc_material_1",
events,
OUTPUT_FIELD,
visualization_time,
resolution=RESOLUTION,
)
create_results(p, "sc_material_2", material_2)
_ = wavefield_helpers.visualize_magnitude(
p,
"sc_material_2",
events,
OUTPUT_FIELD,
visualization_time,
resolution=RESOLUTION,
)
[2025-05-21 02:45:29,660] INFO: Creating mesh. Hang on. [2025-05-21 02:45:30,428] INFO: Submitting job array with 2 jobs ...
create_results(p, "sc_material_3", material_3)
_ = wavefield_helpers.visualize_magnitude(
p,
"sc_material_3",
events,
OUTPUT_FIELD,
visualization_time,
resolution=RESOLUTION,
)
[2025-05-21 02:49:21,100] INFO: Creating mesh. Hang on. [2025-05-21 02:49:21,608] INFO: Submitting job array with 2 jobs ...
_ = wavefield_helpers.visualize_vector(
p,
"sc_material_1",
events,
OUTPUT_FIELD,
visualization_time,
resolution=RESOLUTION,
scale=2.5e-4,
)
orientation_material_2 = sn.material.orientation.AzimuthDip.from_params(
azimuth=90, dip=30
)
material_2_oriented = material_2.with_orientation(orientation_material_2)
# Create results will take care of casting the material to triclinic and
# "burning in" the orientation to material.
create_results(p, "sc_material_2_oriented", material_2_oriented)
[2025-05-21 02:51:59,057] INFO: Creating mesh. Hang on. [2025-05-21 02:51:59,584] INFO: Submitting job array with 2 jobs ...
_ = wavefield_helpers.visualize_magnitude(
p,
"sc_material_2_oriented",
events,
OUTPUT_FIELD,
visualization_time,
resolution=RESOLUTION,
)
DirectOrthonormalBasis
object from the local basis
vectors.material_3
, but with the new orientation.# Create voronoi cells in 3D on a grid
# set random seed for reproducibility
np.random.seed(42)
grid_x_linear = np.linspace(0, DOMAIN_SIZE, RESOLUTION)
grid_y_linear = np.linspace(0, DOMAIN_SIZE, RESOLUTION)
grid_z_linear = np.linspace(0, DOMAIN_SIZE, RESOLUTION)
grid_x_mesh, grid_y_mesh, grid_z_mesh = np.meshgrid(
grid_x_linear, grid_y_linear, grid_z_linear
)
grid_x = grid_x_mesh.flatten()
grid_y = grid_y_mesh.flatten()
grid_z = grid_z_mesh.flatten()
# Create random points in 3D
n_voronoi_points = 1500
points = np.random.rand(n_voronoi_points, 3) * DOMAIN_SIZE
# Get random local basis vectors for each point
v1 = np.random.randn(n_voronoi_points, 3)
v1 /= np.linalg.norm(v1, axis=1)[:, np.newaxis]
v2 = np.random.randn(n_voronoi_points, 3)
v2 /= np.linalg.norm(v2, axis=1)[:, np.newaxis]
v2 -= np.sum(v1 * v2, axis=1)[:, np.newaxis] * v1
v3 = np.cross(v1, v2)
v3 /= np.linalg.norm(v3, axis=1)[:, np.newaxis]
# Compute which grid point belongs to which voronoi cell
vor = Voronoi(points)
tree = cKDTree(points)
distances, indices = tree.query(np.c_[grid_x, grid_y, grid_z], k=1)
# Get the indices of the voronoi cells for each grid point
cell_indices = vor.point_region[indices]
# Ensure the max index is less than the number of voronoi points
cell_indices[cell_indices >= n_voronoi_points] = 0
# Create grid normal vectors
grid_v1 = np.zeros((RESOLUTION, RESOLUTION, RESOLUTION, 3))
grid_v2 = np.zeros((RESOLUTION, RESOLUTION, RESOLUTION, 3))
grid_v3 = np.zeros((RESOLUTION, RESOLUTION, RESOLUTION, 3))
for i in range(RESOLUTION):
for j in range(RESOLUTION):
for k in range(RESOLUTION):
# Get the index of the cell this grid point belongs to
index = cell_indices[
i * RESOLUTION * RESOLUTION + j * RESOLUTION + k
]
# Get the local basis vectors for this cell
grid_v1[i, j, k] = v1[index]
grid_v2[i, j, k] = v2[index]
grid_v3[i, j, k] = v3[index]
# Create xarray DataArrays for each basis vector component in a concise loop
basis_names = ["m00", "m01", "m02", "m10", "m11", "m12", "m20", "m21", "m22"]
basis_arrays = {}
for idx, (vec, name_prefix) in enumerate(
zip([grid_v1, grid_v2, grid_v3], ["m0", "m1", "m2"])
):
for comp in range(3):
basis_arrays[f"{name_prefix}{comp}"] = xr.DataArray(
vec[:, :, :, comp],
dims=("x", "y", "z"),
coords={
"x": grid_x_linear,
"y": grid_y_linear,
"z": grid_z_linear,
},
)
DirectOrthonormalBasis
object. Behind the scenes, this class will also run
Gram-Schmidt orthonormalization to ensure the basis vectors are orthonormal.
One can also skip this step by setting skip_gram_schmidt=True
, but this is
not recommended unless you know that the basis vectors are definitely
orthonormal.# Create a DirectOrthonormalBasis object from the basis arrays
orientation_3d = sn.material.orientation.DirectOrthonormalBasis.from_params(
**basis_arrays,
skip_orthonormalization=False,
)
orientation_3d
<IPython.core.display.HTML object>
salvus.material.orientation.DirectOrthonormalBasis
material_3
, but with the new orientation. Salvus
will automatically broadcast the spatial information in the orientation to
the material itself, so we don't need to define a material for every grid
point.# Orient the material with the new orientation
material_3_3d_oriented = to_solver_form(
material_3.with_orientation(orientation_3d)
)
create_results
will take care of meshing the material, we can
create a mesh for visualization purposes, to see exactly how the material is
oriented in space.# Create a dummy mesh for visualization (recreated in `create_results`)
mesh_3d_viz = sn.layered_meshing.mesh_from_domain(
domain=box_domain,
model=material_3_3d_oriented,
mesh_resolution=sn.MeshResolution(reference_frequency=MAX_FREQ),
)
# Retain only C11 for visualization by removing all other elemental fields
for field in list(mesh_3d_viz.elemental_fields.keys()):
if field != "C11":
mesh_3d_viz.elemental_fields.pop(field)
mesh_3d_viz
<salvus.mesh.data_structures.unstructured_mesh.unstructured_mesh.UnstructuredMesh object at 0x790f6d5c60d0>
create_results(p, "sc_material_3_oriented", material_3_3d_oriented)
[2025-05-21 02:56:12,872] INFO: Submitting job array with 2 jobs ...
wavefield_helpers.visualize_magnitude(
p,
"sc_material_3_oriented",
events,
OUTPUT_FIELD,
visualization_time,
resolution=RESOLUTION,
)