diff --git a/NEWS.md b/NEWS.md index 1cf25c4cf3..f46b41029a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,25 +4,34 @@ TrixiParticles.jl follows the interpretation of [semantic versioning (semver)](https://julialang.github.io/Pkg.jl/dev/compatibility/#Version-specifier-format-1) used in the Julia ecosystem. Notable changes will be documented in this file for human readability. -## Version 0.5.4 - -### API Changes - -- Replaced the experimental `MechanicalWorkCalculatorCallback` with - `MechanicalWorkCalculator`, which can be passed as a custom quantity to - `PostprocessCallback` (#1228). - -### Important Bugfixes - -- Fixed mathematical inconsistencies in the SPH documentation, including incorrect - formulas, inconsistent force-vs-acceleration notation, and wrong LaTeX text-mode - commands (#1086). -- Fixed restarting with EDAC from solution objects (#1213) and from VTK files (#1297). -- Fixed the custom quantities `kinetic_energy`, `total_mass`, `max_pressure`, `min_pressure`, - `avg_pressure`, `max_density`, `min_density` and `avg_density` to only take active particles - into account (#1184). - -## Version 0.5.3 +## Version 0.5.4 + +### API Changes + +- Replaced the experimental `MechanicalWorkCalculatorCallback` with + `MechanicalWorkCalculator`, which can be passed as a custom quantity to + `PostprocessCallback` (#1228). + +### Important Bugfixes + +- Fixed mathematical inconsistencies in the SPH documentation, including incorrect + formulas, inconsistent force-vs-acceleration notation, and wrong LaTeX text-mode + commands (#1086). +- Fixed restarting with EDAC from solution objects (#1213) and from VTK files (#1297). +- Fixed the custom quantities `kinetic_energy`, `total_mass`, `max_pressure`, `min_pressure`, + `avg_pressure`, `max_density`, `min_density` and `avg_density` to only take active particles + into account (#1184). + +- Hardened surface tension model configuration by validating coefficients and surface-normal + thresholds, avoiding unnecessary normal allocation for `CohesionForceAkinci`, and stabilizing + Akinci cohesion and adhesion kernels across floating-point scales. + +### Features + +- Added an optional Makie recipe for rendering two- and three-dimensional particle systems + with `plot`, `plot!`, `trixi2makie`, and `trixi2makie!`. + +## Version 0.5.3 ### Features diff --git a/Project.toml b/Project.toml index 7425f7f7bf..de22c92760 100644 --- a/Project.toml +++ b/Project.toml @@ -35,6 +35,7 @@ WriteVTK = "64499a7a-5c06-52f2-abe2-ccb03c286192" [weakdeps] CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba" +Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" OrdinaryDiffEqCore = "bbf590c4-e513-4bbe-9b18-05decba2e5d8" OrdinaryDiffEqLowStorageRK = "b0944070-b475-4768-8dec-fb6eb410534d" OrdinaryDiffEqSymplecticRK = "fa646aed-7ef9-47eb-84c4-9443fc8cbfa8" @@ -43,6 +44,7 @@ Static = "aedffcd0-7271-4cad-89d0-dc628f76c6d3" [extensions] TrixiParticlesOrdinaryDiffEqSymplecticRKExt = ["OrdinaryDiffEqSymplecticRK", "OrdinaryDiffEqCore"] TrixiParticlesCUDAExt = "CUDA" +TrixiParticlesMakieExt = "Makie" [compat] Accessors = "0.1.43" @@ -60,6 +62,7 @@ GPUArraysCore = "0.2" JSON = "1" KernelAbstractions = "0.9" LinearAlgebra = "1" +Makie = "0.24" OrdinaryDiffEqLowStorageRK = "3" OrdinaryDiffEqCore = "4" OrdinaryDiffEqSymplecticRK = "2" diff --git a/README.md b/README.md index 71e353b967..8543c72733 100644 --- a/README.md +++ b/README.md @@ -62,24 +62,24 @@ It offers intuitive configuration, robust pre- and post-processing, and vendor-a We provide several example simulation setups in the `examples` folder (which can be accessed from Julia via `examples_dir()`).
|
- + |
- + |
|
- + |
- + |
|
- + |
- + |
|
- + |
- + |
+```
+
+Load 2D geometry files, fill them with particles using `ComplexShape`, and build
+setups such as a curved pipe and a coastline dam break.
+
+- Focus: `load_geometry`, `ComplexShape`, and set operations
+- Choose this if: you want to build a 2D setup from geometry files
+
### [Particle packing tutorial](tutorials/tut_packing.md)
```@raw html
diff --git a/docs/src/visualization.md b/docs/src/visualization.md
index 9dda94bfba..a7b7f3d99c 100644
--- a/docs/src/visualization.md
+++ b/docs/src/visualization.md
@@ -1,12 +1,36 @@
# Visualization
-## Export VTK files
+## Export VTK Files
You can export particle data as VTK files by using the [`SolutionSavingCallback`](@ref).
All [predefined examples](examples.md) already use this callback to export VTK files to the `out`
directory relative to the current working directory.
VTK files can be opened in visualization tools such as [ParaView](https://www.paraview.org/)
and [VisIt](https://visit.llnl.gov/).
+## Makie
+
+Load a Makie backend to inspect any two- or three-dimensional solution frame directly. This
+particle-level diagnostic renders spheres whose diameter follows the system's particle spacing;
+it does not reconstruct a continuous fluid surface.
+
+```julia
+using CairoMakie
+
+figure, axis, plot_object = plot(sol)
+```
+
+This uses the TrixiParticles Makie recipe. `trixi2makie(sol)` is an equivalent explicit entry
+point, and `plot!(axis, sol)` or `trixi2makie!(axis, sol)` add the visualization to an existing
+axis. Use `frame` to select another saved ODE frame. The keywords `system_indices`,
+`system_colors`, and `marker_size_scales` select and style systems. Colors and size scales can
+be scalars, vectors indexed by system number, or functions with the signature
+`(system, system_index)`. Additional Makie plot attributes are forwarded to the particle
+markers.
+
+```@docs
+trixi2makie
+```
+
### ParaView
Follow these steps to view the exported VTK files in ParaView:
@@ -18,6 +42,7 @@ Follow these steps to view the exported VTK files in ParaView:
5. Hold the left mouse button to move the solution around.
You will now see the following:
+

It is useful to make the dot size dependent on the actual particle size.
@@ -28,9 +53,10 @@ Then, in the Properties panel (bottom left), adjust the following settings:
3. Activate "Scale by Array" and select "`particle_spacing`" in "Gaussian Scale Array".
4. Deactivate "Use Scale Function".
5. Set the "Gaussian Radius" to "`0.5`".
+

-#### Visualization with Macro
+#### Visualization with a Macro
To simplify visualization of particle data in ParaView, you can use a macro.
It reduces the manual steps from the previous section to a single click.
Install the macro as follows.
@@ -44,7 +70,6 @@ Install the macro as follows.
6. Click on the macro name in the **Macros** menu (or toolbar, if pinned) to run it.
7. The Point Gaussian representation with `particle_spacing` scaling will be applied automatically.
-
---
#### Macro Code
@@ -78,11 +103,12 @@ sourceDisplay.UseScaleFunction = 0
sourceDisplay.GaussianRadius = 0.5
```
-#### Show results
+#### Show Results
To view the result variables, first make sure that "fluid_1.pvd" is highlighted in the
"Pipeline Browser", then select a variable in the variable-selection combo box
(see the image below). For example, choose "density". To view the time evolution,
press the play button (also shown below).
+

## API
diff --git a/examples/fluid/dam_break_2d.jl b/examples/fluid/dam_break_2d.jl
index 11ee987599..22969a427c 100644
--- a/examples/fluid/dam_break_2d.jl
+++ b/examples/fluid/dam_break_2d.jl
@@ -80,12 +80,8 @@ viscosity_wall = nothing
# viscosity_wall = viscosity_fluid
# Clip negative boundary pressure values to avoid sticking artifacts at the boundary.
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- smoothing_kernel, smoothing_length;
- state_equation,
- correction=nothing,
- reference_particle_spacing=0,
viscosity=viscosity_wall,
clip_negative_pressure=true)
diff --git a/examples/fluid/dam_break_3d.jl b/examples/fluid/dam_break_3d.jl
index ae9b580960..0e5438ac53 100644
--- a/examples/fluid/dam_break_3d.jl
+++ b/examples/fluid/dam_break_3d.jl
@@ -57,10 +57,8 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid; smoothing_kernel, smoothi
boundary_density_calculator = AdamiPressureExtrapolation()
# Clip negative boundary pressure values to avoid sticking artifacts at the boundary.
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- smoothing_kernel, smoothing_length;
- state_equation,
clip_negative_pressure=true)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model)
diff --git a/examples/fluid/falling_water_column_2d.jl b/examples/fluid/falling_water_column_2d.jl
index 694c13ab76..c9c4b00fa5 100644
--- a/examples/fluid/falling_water_column_2d.jl
+++ b/examples/fluid/falling_water_column_2d.jl
@@ -55,10 +55,8 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid; smoothing_kernel, smoothi
boundary_density_calculator = AdamiPressureExtrapolation()
# Clip negative boundary pressure values to avoid sticking artifacts at the boundary.
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- smoothing_kernel, smoothing_length;
- state_equation,
clip_negative_pressure=true)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model)
diff --git a/examples/fluid/falling_water_spheres_2d.jl b/examples/fluid/falling_water_spheres_2d.jl
index 1f0016ac6d..657f496a48 100644
--- a/examples/fluid/falling_water_spheres_2d.jl
+++ b/examples/fluid/falling_water_spheres_2d.jl
@@ -82,12 +82,11 @@ boundary_density_calculator = AdamiPressureExtrapolation()
wall_viscosity = nu
# Clip negative boundary pressure values to avoid sticking artifacts at the boundary.
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary;
+ fluid_system=sphere_surface_tension,
boundary_density_calculator,
- fluid_smoothing_kernel, fluid_smoothing_length;
state_equation,
viscosity=ViscosityAdami(nu=wall_viscosity),
- reference_particle_spacing=fluid_particle_spacing,
clip_negative_pressure=true)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model;
diff --git a/examples/fluid/hydrostatic_water_column_2d.jl b/examples/fluid/hydrostatic_water_column_2d.jl
index 58b381c0c0..93e6665c8f 100644
--- a/examples/fluid/hydrostatic_water_column_2d.jl
+++ b/examples/fluid/hydrostatic_water_column_2d.jl
@@ -60,10 +60,8 @@ boundary_density_calculator = AdamiPressureExtrapolation()
# This is to set wall viscosity with `trixi_include`
viscosity_wall = nothing
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- smoothing_kernel, smoothing_length;
- state_equation,
viscosity=viscosity_wall)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model,
prescribed_motion=nothing)
diff --git a/examples/fluid/lid_driven_cavity_2d.jl b/examples/fluid/lid_driven_cavity_2d.jl
index c1aa4ae2a8..747caf2763 100644
--- a/examples/fluid/lid_driven_cavity_2d.jl
+++ b/examples/fluid/lid_driven_cavity_2d.jl
@@ -84,20 +84,14 @@ is_moving(t) = true
lid_movement = PrescribedMotion(lid_movement_function, is_moving)
-boundary_model_cavity = BoundaryModelDummyParticles(cavity.boundary.density,
- cavity.boundary.mass,
- AdamiPressureExtrapolation(),
- smoothing_kernel, smoothing_length;
- viscosity, state_equation)
-
-boundary_model_lid = BoundaryModelDummyParticles(lid.density, lid.mass,
- AdamiPressureExtrapolation(),
- smoothing_kernel, smoothing_length;
- viscosity, state_equation)
-
-boundary_system_cavity = WallBoundarySystem(cavity.boundary, boundary_model_cavity)
-
-boundary_system_lid = WallBoundarySystem(lid, boundary_model_lid,
+cavity_boundary_model = BoundaryModelDummyParticles(cavity.boundary;
+ fluid_system=fluid_system,
+ viscosity=viscosity)
+boundary_system_cavity = WallBoundarySystem(cavity.boundary, cavity_boundary_model)
+
+lid_boundary_model = BoundaryModelDummyParticles(lid; fluid_system=fluid_system,
+ viscosity=viscosity)
+boundary_system_lid = WallBoundarySystem(lid, lid_boundary_model,
prescribed_motion=lid_movement)
# ==========================================================================================
diff --git a/examples/fluid/moving_wall_2d.jl b/examples/fluid/moving_wall_2d.jl
index 6e0b0f9dfe..253386981c 100644
--- a/examples/fluid/moving_wall_2d.jl
+++ b/examples/fluid/moving_wall_2d.jl
@@ -59,10 +59,8 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid; smoothing_kernel, smoothi
# ==========================================================================================
# ==== Boundary
boundary_density_calculator = AdamiPressureExtrapolation()
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
- boundary_density_calculator,
- smoothing_kernel, smoothing_length;
- state_equation)
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
+ boundary_density_calculator)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model,
prescribed_motion=boundary_movement)
diff --git a/examples/fluid/periodic_array_of_cylinders_2d.jl b/examples/fluid/periodic_array_of_cylinders_2d.jl
index f2899b6bfd..ec1344eafc 100644
--- a/examples/fluid/periodic_array_of_cylinders_2d.jl
+++ b/examples/fluid/periodic_array_of_cylinders_2d.jl
@@ -71,10 +71,8 @@ fluid_system = WeaklyCompressibleSPHSystem(fluid; smoothing_kernel, smoothing_le
# ==========================================================================================
# ==== Boundary
-boundary_model = BoundaryModelDummyParticles(boundary.density, boundary.mass,
- AdamiPressureExtrapolation(), smoothing_kernel,
- smoothing_length;
- viscosity=ViscosityAdami(; nu), state_equation)
+boundary_model = BoundaryModelDummyParticles(boundary; fluid_system=fluid_system,
+ viscosity=ViscosityAdami(; nu))
boundary_system = WallBoundarySystem(boundary, boundary_model)
diff --git a/examples/fluid/periodic_channel_2d.jl b/examples/fluid/periodic_channel_2d.jl
index 9635f48a78..097e9b5f16 100644
--- a/examples/fluid/periodic_channel_2d.jl
+++ b/examples/fluid/periodic_channel_2d.jl
@@ -59,10 +59,8 @@ viscosity_wall = nothing
# Activate to switch to no-slip walls
#viscosity_wall = ViscosityAdami(nu=0.0025 * smoothing_length * sound_speed / 8)
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- smoothing_kernel, smoothing_length;
- state_equation,
viscosity=viscosity_wall)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model)
diff --git a/examples/fluid/pipe_flow_2d.jl b/examples/fluid/pipe_flow_2d.jl
index 6022b980e4..1539d9c0f0 100644
--- a/examples/fluid/pipe_flow_2d.jl
+++ b/examples/fluid/pipe_flow_2d.jl
@@ -136,16 +136,13 @@ outflow = BoundaryZone(; boundary_face=face_out, face_normal=(-flow_direction),
initial_condition=outlet.fluid, boundary_type=boundary_type_out)
open_boundary = OpenBoundarySystem(inflow, outflow; fluid_system,
- boundary_model=open_boundary_model,
- buffer_size=n_buffer_particles)
+ boundary_model=open_boundary_model)
# ==========================================================================================
# ==== Boundary
wall = union(pipe.boundary, inlet.boundary, outlet.boundary)
viscosity_boundary = viscosity
-boundary_model = BoundaryModelDummyParticles(wall.density, wall.mass,
- AdamiPressureExtrapolation(), smoothing_kernel,
- smoothing_length; state_equation,
+boundary_model = BoundaryModelDummyParticles(wall; fluid_system=fluid_system,
viscosity=viscosity_boundary)
boundary_system = WallBoundarySystem(wall, boundary_model)
diff --git a/examples/fluid/poiseuille_flow_2d.jl b/examples/fluid/poiseuille_flow_2d.jl
index ba36a2f471..175571a8f2 100644
--- a/examples/fluid/poiseuille_flow_2d.jl
+++ b/examples/fluid/poiseuille_flow_2d.jl
@@ -145,16 +145,14 @@ outlet_boundary_zone = BoundaryZone(; boundary_face=outlet_face,
open_boundary = OpenBoundarySystem(inlet_boundary_zone, outlet_boundary_zone; fluid_system,
boundary_model=open_boundary_model,
- calculate_flow_rate=true,
- buffer_size=n_buffer_particles)
+ calculate_flow_rate=true)
# ==========================================================================================
# ==== Boundary
wall_boundary = union(channel.boundary)
-boundary_model = BoundaryModelDummyParticles(wall_boundary.density, wall_boundary.mass,
- AdamiPressureExtrapolation(), smoothing_kernel,
- smoothing_length; state_equation, viscosity)
+boundary_model = BoundaryModelDummyParticles(wall_boundary; fluid_system=fluid_system,
+ viscosity)
boundary_system = WallBoundarySystem(wall_boundary, boundary_model)
diff --git a/examples/fluid/poiseuille_flow_3d.jl b/examples/fluid/poiseuille_flow_3d.jl
index 4459df0648..7d76cc8ffb 100644
--- a/examples/fluid/poiseuille_flow_3d.jl
+++ b/examples/fluid/poiseuille_flow_3d.jl
@@ -162,14 +162,12 @@ outlet_zone = BoundaryZone(; boundary_face=outlet_face,
boundary_type=outlet_boundary_type)
open_boundary = OpenBoundarySystem(inlet_zone, outlet_zone; fluid_system,
- boundary_model=open_boundary_model,
- buffer_size=n_buffer_particles)
+ boundary_model=open_boundary_model)
# ==========================================================================================
# ==== Boundary
-boundary_model = BoundaryModelDummyParticles(wall_boundary.density, wall_boundary.mass,
- AdamiPressureExtrapolation(), smoothing_kernel,
- smoothing_length; state_equation, viscosity)
+boundary_model = BoundaryModelDummyParticles(wall_boundary; fluid_system=fluid_system,
+ viscosity)
boundary_system = WallBoundarySystem(wall_boundary, boundary_model)
diff --git a/examples/fsi/dam_break_gate_2d.jl b/examples/fsi/dam_break_gate_2d.jl
index 49d72f2aee..9cc9e66271 100644
--- a/examples/fsi/dam_break_gate_2d.jl
+++ b/examples/fsi/dam_break_gate_2d.jl
@@ -120,20 +120,15 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid; smoothing_kernel, smoothi
boundary_density_calculator = AdamiPressureExtrapolation()
# Clip negative boundary pressure values to avoid sticking artifacts at the boundary.
-boundary_model_tank = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+tank_boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- smoothing_kernel, smoothing_length;
- state_equation,
clip_negative_pressure=true)
-
-boundary_model_gate = BoundaryModelDummyParticles(gate.density, gate.mass,
+gate_boundary_model = BoundaryModelDummyParticles(gate; fluid_system=fluid_system,
boundary_density_calculator,
- smoothing_kernel, smoothing_length;
- state_equation,
clip_negative_pressure=true)
-boundary_system_tank = WallBoundarySystem(tank.boundary, boundary_model_tank)
-boundary_system_gate = WallBoundarySystem(gate, boundary_model_gate,
+boundary_system_tank = WallBoundarySystem(tank.boundary, tank_boundary_model)
+boundary_system_gate = WallBoundarySystem(gate, gate_boundary_model,
prescribed_motion=gate_movement)
# ==========================================================================================
diff --git a/examples/fsi/dam_break_plate_2d.jl b/examples/fsi/dam_break_plate_2d.jl
index 00edc5c216..2bfd4f609e 100644
--- a/examples/fsi/dam_break_plate_2d.jl
+++ b/examples/fsi/dam_break_plate_2d.jl
@@ -94,10 +94,8 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid; smoothing_kernel, smoothi
boundary_density_calculator = AdamiPressureExtrapolation()
# Clip negative boundary pressure values to avoid sticking artifacts at the boundary.
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- smoothing_kernel, smoothing_length;
- state_equation,
clip_negative_pressure=true)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model)
diff --git a/examples/fsi/falling_rigid_spheres_2d.jl b/examples/fsi/falling_rigid_spheres_2d.jl
index 7eaa2b4dcb..c435e7a009 100644
--- a/examples/fsi/falling_rigid_spheres_2d.jl
+++ b/examples/fsi/falling_rigid_spheres_2d.jl
@@ -70,10 +70,8 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid;
boundary_density_calculator = AdamiPressureExtrapolation()
# Clip negative boundary pressure values to avoid sticking artifacts at the boundary.
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- fluid_smoothing_kernel, fluid_smoothing_length;
- state_equation,
clip_negative_pressure=true)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model)
@@ -103,9 +101,14 @@ boundary_model_structure_2 = BoundaryModelDummyParticles(hydrodynamic_densities_
fluid_smoothing_length;
state_equation)
-# Basic rigid contact model used for both rigid bodies.
+# Use the frictional rigid-wall contact path, which requires `UpdateCallback()` to keep
+# the tangential contact history in sync between time steps.
contact_model = RigidContactModel(; normal_stiffness=2.0e5,
normal_damping=200.0,
+ static_friction_coefficient=0.6,
+ kinetic_friction_coefficient=0.4,
+ tangential_stiffness=1.0e5,
+ tangential_damping=150.0,
contact_distance=2.0 *
structure_particle_spacing)
@@ -125,7 +128,7 @@ ode = semidiscretize(semi, tspan)
info_callback = InfoCallback(interval=50)
saving_callback = SolutionSavingCallback(dt=0.01, output_directory="out", prefix="")
-callbacks = CallbackSet(info_callback, saving_callback)
+callbacks = CallbackSet(info_callback, saving_callback, UpdateCallback())
# Use a Runge-Kutta method with automatic (error based) time step size control.
sol = solve(ode, RDPK3SpFSAL49(),
diff --git a/examples/fsi/falling_rotating_rigid_squares_2d.jl b/examples/fsi/falling_rotating_rigid_squares_2d.jl
index 4a14905cf0..10597e6807 100644
--- a/examples/fsi/falling_rotating_rigid_squares_2d.jl
+++ b/examples/fsi/falling_rotating_rigid_squares_2d.jl
@@ -86,10 +86,9 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid;
boundary_density_calculator = AdamiPressureExtrapolation()
# Clip negative boundary pressure values to avoid sticking artifacts at the boundary.
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- fluid_smoothing_kernel, fluid_smoothing_length;
- state_equation, clip_negative_pressure=true)
+ clip_negative_pressure=true)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model)
@@ -110,13 +109,24 @@ end
boundary_model_structure_1 = structure_boundary_model(square1)
boundary_model_structure_2 = structure_boundary_model(square2)
-# Use a less dissipative wall contact for the denser square so its rebound is more visible.
+# Use frictional rigid-wall contact so the rotating squares exchange tangential impulses with
+# the tank floor as well as normal contact forces. This requires `UpdateCallback()`.
contact_model_1 = RigidContactModel(; normal_stiffness=2.0e5,
normal_damping=200.0,
- contact_distance=2.0 * structure_particle_spacing)
+ static_friction_coefficient=0.6,
+ kinetic_friction_coefficient=0.4,
+ tangential_stiffness=1.0e5,
+ tangential_damping=180.0,
+ contact_distance=2.0 *
+ structure_particle_spacing)
contact_model_2 = RigidContactModel(; normal_stiffness=2.0e5,
normal_damping=80.0,
- contact_distance=2.0 * structure_particle_spacing)
+ static_friction_coefficient=0.5,
+ kinetic_friction_coefficient=0.3,
+ tangential_stiffness=8.0e4,
+ tangential_damping=120.0,
+ contact_distance=2.0 *
+ structure_particle_spacing)
structure_system_1 = RigidBodySystem(square1;
boundary_model=boundary_model_structure_1,
@@ -141,7 +151,7 @@ ode = semidiscretize(semi, tspan)
info_callback = InfoCallback(interval=50)
saving_callback = SolutionSavingCallback(dt=0.01, output_directory="out", prefix="")
-callbacks = CallbackSet(info_callback, saving_callback)
+callbacks = CallbackSet(info_callback, saving_callback, UpdateCallback())
# Use a Runge-Kutta method with automatic (error based) time step size control.
# To prevent penetration of fluid particles through the rigid bodies or the boundary
diff --git a/examples/fsi/falling_rotating_rigid_squares_w_buoys_2d.jl b/examples/fsi/falling_rotating_rigid_squares_w_buoys_2d.jl
index ee7ec68cba..280bbd71ee 100644
--- a/examples/fsi/falling_rotating_rigid_squares_w_buoys_2d.jl
+++ b/examples/fsi/falling_rotating_rigid_squares_w_buoys_2d.jl
@@ -20,6 +20,10 @@ small_sphere_y = initial_fluid_size[2] + small_sphere_radius
small_sphere_x_positions = 0.2:(3 * small_sphere_radius):1.8
small_sphere_contact_model = RigidContactModel(; normal_stiffness=2.0e5,
normal_damping=120.0,
+ static_friction_coefficient=0.6,
+ kinetic_friction_coefficient=0.4,
+ tangential_stiffness=1.0e5,
+ tangential_damping=150.0,
contact_distance=2.0 *
structure_particle_spacing)
extra_structure_systems = [begin
diff --git a/examples/fsi/falling_spheres_2d.jl b/examples/fsi/falling_spheres_2d.jl
index 035f9ad3da..136161624f 100644
--- a/examples/fsi/falling_spheres_2d.jl
+++ b/examples/fsi/falling_spheres_2d.jl
@@ -74,10 +74,8 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid;
boundary_density_calculator = BernoulliPressureExtrapolation()
# Clip negative boundary pressure values to avoid sticking artifacts at the boundary.
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- fluid_smoothing_kernel, fluid_smoothing_length;
- state_equation,
clip_negative_pressure=true)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model)
diff --git a/examples/fsi/hydrostatic_water_column_2d.jl b/examples/fsi/hydrostatic_water_column_2d.jl
index 9fd7e15a36..3815a28d68 100644
--- a/examples/fsi/hydrostatic_water_column_2d.jl
+++ b/examples/fsi/hydrostatic_water_column_2d.jl
@@ -113,9 +113,8 @@ else
damping_coefficient=0.05))
end
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- smoothing_kernel, smoothing_length_fluid;
state_equation)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model)
boundary_model_structure = BoundaryModelDummyParticles(hydrodynamic_densities,
diff --git a/examples/preprocessing/complex_shape_2d.jl b/examples/preprocessing/complex_shape_2d.jl
index fa2b762988..62de84ad49 100644
--- a/examples/preprocessing/complex_shape_2d.jl
+++ b/examples/preprocessing/complex_shape_2d.jl
@@ -7,8 +7,8 @@
# 3. Utilize the Winding Number algorithm to determine if points are inside or outside.
# 4. Visualize the sampled particles and the winding number field.
#
-# The example uses an "inverted_open_curve" geometry, where standard inside/outside
-# definitions might be ambiguous without a robust point-in-polygon test like winding numbers.
+# The example uses a polygonal star geometry, where standard inside/outside
+# definitions benefit from a robust point-in-polygon test like winding numbers.
# ==========================================================================================
using TrixiParticles
@@ -16,7 +16,7 @@ using Plots
particle_spacing = 0.05
-filename = "inverted_open_curve"
+filename = "star"
file = joinpath("examples", "preprocessing", "data", filename * ".asc")
geometry = load_geometry(file)
@@ -24,7 +24,6 @@ geometry = load_geometry(file)
trixi2vtk(geometry)
point_in_geometry_algorithm = WindingNumberJacobson(; geometry,
- winding_number_factor=0.4,
hierarchical_winding=true)
# Returns `InitialCondition`
diff --git a/examples/preprocessing/data/coastline_profile_2d.asc b/examples/preprocessing/data/coastline_profile_2d.asc
new file mode 100644
index 0000000000..c250c87be4
--- /dev/null
+++ b/examples/preprocessing/data/coastline_profile_2d.asc
@@ -0,0 +1,20 @@
+# ASCII
+0.15 -0.12 0
+2.68 -0.12 0
+2.68 1.08 0
+2.62 0.66 0
+2.53 0.52 0
+2.42 0.40 0
+2.30 0.42 0
+2.18 0.33 0
+2.05 0.24 0
+1.92 0.26 0
+1.78 0.18 0
+1.62 0.11 0
+1.46 0.14 0
+1.28 0.06 0
+1.05 0.02 0
+0.82 0.05 0
+0.55 0.03 0
+0.15 0.03 0
+0.15 -0.12 0
diff --git a/examples/preprocessing/data/curved_pipe_channel_2d.asc b/examples/preprocessing/data/curved_pipe_channel_2d.asc
new file mode 100644
index 0000000000..76a1d7b8fe
--- /dev/null
+++ b/examples/preprocessing/data/curved_pipe_channel_2d.asc
@@ -0,0 +1,20 @@
+# ASCII
+0.00 0.12 0
+0.60 0.12 0
+0.72423 0.13646 0
+0.84000 0.18431 0
+0.93941 0.26059 0
+1.01569 0.36000 0
+1.06354 0.47577 0
+1.08 0.60 0
+1.08 1.20 0
+0.72 1.20 0
+0.72 0.60 0
+0.71591 0.56894 0
+0.70392 0.54000 0
+0.68485 0.51515 0
+0.66000 0.49608 0
+0.63106 0.48409 0
+0.60 0.48 0
+0.00 0.48 0
+0.00 0.12 0
diff --git a/examples/preprocessing/data/curved_pipe_outer_2d.asc b/examples/preprocessing/data/curved_pipe_outer_2d.asc
new file mode 100644
index 0000000000..1b26afac92
--- /dev/null
+++ b/examples/preprocessing/data/curved_pipe_outer_2d.asc
@@ -0,0 +1,14 @@
+# ASCII
+0.00 0.00 0
+0.60 0.00 0
+0.75529 0.02044 0
+0.90000 0.08038 0
+1.02426 0.17574 0
+1.11962 0.30000 0
+1.17956 0.44471 0
+1.20 0.60 0
+1.20 1.20 0
+0.60 1.20 0
+0.60 0.60 0
+0.00 0.60 0
+0.00 0.00 0
diff --git a/examples/preprocessing/packing_2d.jl b/examples/preprocessing/packing_2d.jl
index fcf0d40c64..7055a2dd98 100644
--- a/examples/preprocessing/packing_2d.jl
+++ b/examples/preprocessing/packing_2d.jl
@@ -71,7 +71,8 @@ packing_system = ParticlePackingSystem(shape_sampled; smoothing_length,
boundary_system = ParticlePackingSystem(boundary_sampled; smoothing_length,
is_boundary=true, signed_distance_field,
- place_on_shell, boundary_compress_factor=0.8,
+ place_on_shell, boundary_thickness,
+ boundary_compress_factor=0.8,
background_pressure)
# ==========================================================================================
diff --git a/examples/structure/sliding_rigid_squares_friction_2d.jl b/examples/structure/sliding_rigid_squares_friction_2d.jl
new file mode 100644
index 0000000000..d70515799b
--- /dev/null
+++ b/examples/structure/sliding_rigid_squares_friction_2d.jl
@@ -0,0 +1,104 @@
+# ==========================================================================================
+# 2D Sliding Rigid Squares with and without Wall Friction
+#
+# Two identical rigid squares slide on the same floor. The left square uses normal-only rigid
+# contact, while the right square also uses Coulomb friction. The frictional square slows down
+# and starts rotating due to tangential wall forces, whereas the normal-only square keeps
+# sliding without spin-up.
+#
+# In ParaView, compare the trajectories and the rigid-body field data such as
+# `angular_velocity`, `contact_count`, and `max_contact_penetration`.
+# ==========================================================================================
+
+using TrixiParticles
+using OrdinaryDiffEqLowStorageRK
+
+# ==========================================================================================
+# ==== Resolution
+particle_spacing = 0.03
+# Finer wall sampling makes its discrete contact normals approximate a flat floor.
+wall_particle_spacing = particle_spacing / 3
+boundary_layers = 3
+contact_distance = 2.0 * particle_spacing
+
+# ==========================================================================================
+# ==== Experiment Setup
+gravity = 9.81
+tspan = (0.0, 0.8)
+
+square_side_length = 0.18
+square_density = 1000.0
+square_particles_per_side = round(Int, square_side_length / particle_spacing)
+# Place the lowest square particles one contact distance above the top wall particles.
+square_bottom_y = contact_distance - (particle_spacing + wall_particle_spacing) / 2
+
+square_frictionless = RectangularShape(particle_spacing,
+ (square_particles_per_side,
+ square_particles_per_side),
+ (-1.0, square_bottom_y),
+ density=square_density,
+ velocity=(1.0, 0.0))
+square_frictional = RectangularShape(particle_spacing,
+ (square_particles_per_side,
+ square_particles_per_side),
+ (0.55, square_bottom_y),
+ density=square_density,
+ velocity=(1.0, 0.0))
+
+# ==========================================================================================
+# ==== Wall Boundary
+floor_length = 3.0
+floor_height = 0.03
+wall_density = 1000.0
+
+floor = RectangularTank(wall_particle_spacing, (0.0, 0.0),
+ (floor_length, floor_height),
+ wall_density, n_layers=boundary_layers,
+ min_coordinates=(-1.5, 0.0),
+ faces=(false, false, true, false))
+
+boundary_model = BoundaryModelMonaghanKajtar(10.0, 1.0, wall_particle_spacing,
+ floor.boundary.mass)
+boundary_system = WallBoundarySystem(floor.boundary, boundary_model)
+
+# ==========================================================================================
+# ==== Rigid Structures
+contact_model_frictionless = RigidContactModel(; normal_stiffness=2.0e5,
+ normal_damping=100.0,
+ contact_distance)
+
+contact_model_frictional = RigidContactModel(; normal_stiffness=2.0e5,
+ normal_damping=100.0,
+ static_friction_coefficient=0.6,
+ kinetic_friction_coefficient=0.4,
+ tangential_stiffness=1.0e5,
+ tangential_damping=150.0,
+ contact_distance)
+
+structure_system_frictionless = RigidBodySystem(square_frictionless;
+ contact_model=contact_model_frictionless,
+ acceleration=(0.0, -gravity),
+ particle_spacing=particle_spacing,
+ color_value=1)
+structure_system_frictional = RigidBodySystem(square_frictional;
+ contact_model=contact_model_frictional,
+ acceleration=(0.0, -gravity),
+ particle_spacing=particle_spacing,
+ color_value=2)
+
+# ==========================================================================================
+# ==== Simulation
+semi = Semidiscretization(structure_system_frictionless, structure_system_frictional,
+ boundary_system)
+ode = semidiscretize(semi, tspan)
+
+info_callback = InfoCallback(interval=50)
+saving_callback = SolutionSavingCallback(dt=0.02, output_directory="out", prefix="")
+
+callbacks = CallbackSet(info_callback, saving_callback, UpdateCallback())
+
+sol = solve(ode, RDPK3SpFSAL49(),
+ abstol=1e-6,
+ reltol=1e-4,
+ dtmax=1e-3,
+ save_everystep=false, callback=callbacks);
diff --git a/ext/TrixiParticlesMakieExt.jl b/ext/TrixiParticlesMakieExt.jl
new file mode 100644
index 0000000000..982f14a50c
--- /dev/null
+++ b/ext/TrixiParticlesMakieExt.jl
@@ -0,0 +1,189 @@
+module TrixiParticlesMakieExt
+
+using Makie
+using TrixiParticles
+
+import TrixiParticles: trixi2makie, trixi2makie!
+
+const TP = TrixiParticles
+
+function default_system_color(system, system_index)
+ if system isa TP.AbstractFluidSystem
+ return Makie.RGBf(0.02, 0.32, 0.85)
+ elseif system isa TP.AbstractBoundarySystem
+ return Makie.RGBf(0.62, 0.66, 0.72)
+ elseif system isa TP.AbstractStructureSystem
+ return Makie.RGBf(0.95, 0.48, 0.08)
+ elseif system isa TP.OpenBoundarySystem
+ return Makie.RGBf(0.30, 0.58, 0.82)
+ end
+
+ return Makie.wong_colors()[mod1(system_index, length(Makie.wong_colors()))]
+end
+
+function default_marker_size_scale(system, system_index)
+ if system isa TP.AbstractBoundarySystem || system isa TP.OpenBoundarySystem
+ return 0.55
+ end
+
+ return 0.9
+end
+
+@inline function style_value(style::Function, system, system_index)
+ return style(system, system_index)
+end
+
+@inline function style_value(style::AbstractVector, system, system_index)
+ return style[system_index]
+end
+
+@inline style_value(style, system, system_index) = style
+
+"""
+ trixi2makie(solution; frame=Makie.automatic, kwargs...)
+ trixi2makie(v_ode, u_ode, semi; kwargs...)
+
+Plot a TrixiParticles solution using Makie. See `TrixiParticles.trixi2makie` for details.
+"""
+Makie.@recipe Trixi2Makie begin
+ "Frame of the ODE solution to plot. `Makie.automatic` selects the last frame."
+ frame = Makie.automatic
+ "Indices of the systems to plot. `Makie.automatic` selects all systems."
+ system_indices = Makie.automatic
+ "A color, vector indexed by system number, or `(system, index)` function."
+ system_colors = default_system_color
+ "A size scale, vector indexed by system number, or `(system, index)` function."
+ marker_size_scales = default_marker_size_scale
+ Makie.documented_attributes(Makie.MeshScatter)...
+ marker = Makie.automatic
+ markerspace = :data
+end
+
+Makie.plottype(::TP.TrixiParticlesODESolution) = Trixi2Makie
+function Makie.plottype(::AbstractArray, ::AbstractArray, ::TP.Semidiscretization)
+ return Trixi2Makie
+end
+
+# Avoid the generic SciMLBase Makie conversion for ODE solutions after `plottype` selects
+# this recipe.
+function Makie.convert_arguments(::Type{<:Trixi2Makie},
+ solution::TP.TrixiParticlesODESolution)
+ return (solution,)
+end
+
+function visualization_state(args::Tuple{TP.TrixiParticlesODESolution}, frame)
+ solution = only(args)
+ frame_index = frame isa Makie.Automatic ? lastindex(solution.u) : frame
+ v_ode, u_ode = solution.u[frame_index].x
+ return v_ode, u_ode, solution.prob.p.semi
+end
+
+function visualization_state(args::Tuple{<:AbstractArray, <:AbstractArray,
+ <:TP.Semidiscretization}, frame)
+ return args
+end
+
+function semidiscretization(args::Tuple{TP.TrixiParticlesODESolution})
+ return only(args).prob.p.semi
+end
+
+function semidiscretization(args::Tuple{<:AbstractArray, <:AbstractArray,
+ <:TP.Semidiscretization})
+ return last(args)
+end
+
+function Makie.preferred_axis_type(plot::Trixi2Makie)
+ semi = semidiscretization(plot.args[])
+ ndims_ = ndims(first(semi.systems))
+
+ if ndims_ == 2
+ return Makie.Axis
+ elseif ndims_ == 3
+ return Makie.Axis3
+ end
+
+ throw(ArgumentError("Makie visualization is only supported in two or three dimensions"))
+end
+
+function Makie.preferred_axis_attributes(::Type{Makie.Axis}, ::Trixi2Makie)
+ return (; aspect=Makie.DataAspect())
+end
+
+function Makie.preferred_axis_attributes(::Type{Makie.Axis3}, ::Trixi2Makie)
+ return (; aspect=:data)
+end
+
+function Makie.plot!(plot::Trixi2Makie)
+ v_ode, u_ode, semi = visualization_state(plot.args[], plot.frame[])
+ system_indices = plot.system_indices[]
+ system_indices isa Makie.Automatic && (system_indices = eachindex(semi.systems))
+
+ if ndims(first(semi.systems)) == 3
+ return plot_3d!(plot, u_ode, semi, system_indices)
+ end
+
+ for system_index in system_indices
+ system = semi.systems[system_index]
+ particles = TP.eachparticle(system)
+ isempty(particles) && continue
+
+ u = TP.wrap_u(u_ode, system, semi)
+ coordinates = Array(TP.active_coordinates(u, system))
+ spacing = TP.particle_spacing(system, first(particles))
+ color = style_value(plot.system_colors[], system, system_index)
+ marker_size_scale = style_value(plot.marker_size_scales[], system, system_index)
+ marker = plot.marker[]
+
+ marker isa Makie.Automatic && (marker = Makie.Circle(Makie.Point2f(0), 0.5f0))
+ points = makie_points(coordinates, Val(2))
+ Makie.meshscatter!(plot, plot.attributes, points; marker,
+ markersize=marker_size_scale * spacing, color)
+ end
+
+ return plot
+end
+
+function plot_3d!(plot, u_ode, semi, system_indices)
+ # CairoMakie depth-sorts particles within one MeshScatter, but not across separate plots.
+ points = Makie.Point3f[]
+ colors = Makie.RGBAf[]
+ marker_sizes = Float64[]
+
+ for system_index in system_indices
+ system = semi.systems[system_index]
+ particles = TP.eachparticle(system)
+ isempty(particles) && continue
+
+ u = TP.wrap_u(u_ode, system, semi)
+ coordinates = Array(TP.active_coordinates(u, system))
+ system_points = makie_points(coordinates, Val(3))
+ spacing = TP.particle_spacing(system, first(particles))
+ color = Makie.to_color(style_value(plot.system_colors[], system, system_index))
+ marker_size_scale = style_value(plot.marker_size_scales[], system, system_index)
+
+ append!(points, system_points)
+ append!(colors, Iterators.repeated(color, length(system_points)))
+ append!(marker_sizes,
+ Iterators.repeated(marker_size_scale * spacing, length(system_points)))
+ end
+
+ marker = plot.marker[]
+ marker isa Makie.Automatic && (marker = Makie.Sphere(Makie.Point3f(0), 0.5f0))
+ Makie.meshscatter!(plot, plot.attributes, points; marker,
+ markersize=marker_sizes, color=colors)
+
+ return plot
+end
+
+function makie_points(coordinates, ::Val{2})
+ return [Makie.Point2f(coordinates[1, particle], coordinates[2, particle])
+ for particle in axes(coordinates, 2)]
+end
+
+function makie_points(coordinates, ::Val{3})
+ return [Makie.Point3f(coordinates[1, particle], coordinates[2, particle],
+ coordinates[3, particle])
+ for particle in axes(coordinates, 2)]
+end
+
+end # module
diff --git a/src/TrixiParticles.jl b/src/TrixiParticles.jl
index 92db502606..74047e0af2 100644
--- a/src/TrixiParticles.jl
+++ b/src/TrixiParticles.jl
@@ -21,7 +21,7 @@ using Polyester: Polyester, @batch
using Printf: @printf, @sprintf
using ReadVTK: ReadVTK
using RecipesBase: RecipesBase, @series
-using Random: seed!
+using Random: MersenneTwister
using SciMLBase: SciMLBase, CallbackSet, DiscreteCallback, DynamicalODEProblem,
derivative_discontinuity!, get_tmp_cache, set_proposed_dt!,
ODESolution, ODEProblem, terminate!, add_tstop!
@@ -58,14 +58,16 @@ include("general/neighborhood_search.jl")
# `callbacks.jl` requires the system types to be defined
include("callbacks/callbacks.jl")
-# Note that `semidiscretization.jl` depends on the system types and has to be
-# included separately. `gpu.jl` in turn depends on the semidiscretization type.
+# Note that `semidiscretization.jl` and `time_integration.jl` depend on the system types
+# and has to be included separately. `gpu.jl` in turn depends on the semidiscretization type.
include("general/semidiscretization.jl")
+include("general/time_integration.jl")
include("general/gpu.jl")
include("preprocessing/preprocessing.jl")
include("io/io.jl")
include("general/restart.jl")
include("visualization/recipes_plots.jl")
+include("visualization/makie.jl")
export Semidiscretization, semidiscretize, restart_with!
export PairsNHSHandler, SharedNHSHandler
@@ -101,10 +103,11 @@ export PrescribedMotion, OscillatingMotion2D
export RCRWindkesselModel
export examples_dir, validation_dir
export trixi2vtk, vtk2trixi
+export trixi2makie, trixi2makie!
export RectangularTank, RectangularShape, SphereShape, ComplexShape
export ParticlePackingSystem, SignedDistanceField
export WindingNumberHormann, WindingNumberJacobson
-export VoxelSphere, RoundSphere, reset_wall!, extrude_geometry, load_geometry,
+export VoxelSphere, RoundSphere, reset_wall!, extrude_geometry, load_geometry, delete_faces,
sample_boundary, planar_geometry_to_face
export SourceTermDamping
export ShepardKernelCorrection, KernelCorrection, AkinciFreeSurfaceCorrection,
diff --git a/src/callbacks/callbacks.jl b/src/callbacks/callbacks.jl
index de8b09986a..3f601a1ed1 100644
--- a/src/callbacks/callbacks.jl
+++ b/src/callbacks/callbacks.jl
@@ -54,6 +54,7 @@ function set_callbacks_used!(semi, integrator)
update_callback_used = any(cb -> cb isa UpdateCB,
integrator.opts.callback.discrete_callbacks)
semi.update_callback_used[] = update_callback_used
+ update_callback_used && validate_rigid_contact_update_callbacks!(semi, integrator)
integrate_tlsph = !any(cb -> cb isa DiscreteCallback{<:Any, <:SplitIntegrationCallback},
integrator.opts.callback.discrete_callbacks)
diff --git a/src/callbacks/density_reinit.jl b/src/callbacks/density_reinit.jl
index 430f3b5644..5f1f608c2b 100644
--- a/src/callbacks/density_reinit.jl
+++ b/src/callbacks/density_reinit.jl
@@ -32,7 +32,8 @@ end
Callback to reinitialize the density field when using [`ContinuityDensity`](@ref) [Panizzo2007](@cite).
-Pass `system` and the [`Semidiscretization`](@ref) containing it. The callback stores
+Pass `system` and the [`Semidiscretization`](@ref TrixiParticles.Semidiscretization)
+containing it. The callback stores
the system index and uses the corresponding system from the integrator semidiscretization
at runtime, which remains valid if [`semidiscretize`](@ref) replaces systems internally.
@@ -80,13 +81,13 @@ function initialize_reinit_cb!(cb::DensityReinitializationCallback, u, t, integr
semi = integrator.p.semi
check_density_reinit_system(current_reinit_system(cb.system_index, semi))
- if cb.reinit_initial_solution
+ if cb.reinit_initial_solution && cb.last_t != t
# Update systems to compute quantities like density and pressure.
v_ode, u_ode = u.x
update_systems_and_nhs(v_ode, u_ode, semi, t)
- # Apply the callback.
- cb(integrator)
+ callbacks = simultaneous_reinit_callbacks(cb, integrator; initial=true)
+ apply_reinitialization!(callbacks, integrator)
end
cb.last_t = t
@@ -99,47 +100,97 @@ function (reinit_callback::DensityReinitializationCallback{<:Integer})(u, t,
integrator)
(; interval) = reinit_callback
- return condition_integrator_interval(integrator, interval, save_final_solution=false)
+ return reinit_callback.last_t != t &&
+ condition_integrator_interval(integrator, interval, save_final_solution=false)
end
# condition with dt
function (reinit_callback::DensityReinitializationCallback)(u, t, integrator)
(; interval, last_t) = reinit_callback
- return (t - last_t) > interval
+ return last_t != t && (t - last_t) > interval
end
# affect!
function (reinit_callback::DensityReinitializationCallback)(integrator)
+ reinit_callback.last_t == integrator.t && return integrator
+
+ callbacks = simultaneous_reinit_callbacks(reinit_callback, integrator)
+ apply_reinitialization!(callbacks, integrator)
+
+ return integrator
+end
+
+function apply_reinitialization!(callbacks, integrator)
vu_ode = integrator.u
semi = integrator.p.semi
- @trixi_timeit timer() "reinit density" reinitialize_density!(reinit_callback, vu_ode,
- semi)
+ @trixi_timeit timer() "reinit density" begin
+ if length(callbacks) == 1
+ reinitialize_density!(only(callbacks), vu_ode, semi)
+ else
+ reinitialize_density!(callbacks, vu_ode, semi)
+ end
+ end
- reinit_callback.last_t = integrator.t
+ foreach(callback -> callback.last_t = integrator.t, callbacks)
# Reinitializing density changes the ODE state and introduces a derivative discontinuity.
derivative_discontinuity!(integrator, true)
- return integrator
+ return callbacks
+end
+
+function simultaneous_reinit_callbacks(reinit_callback, integrator; initial=false)
+ hasproperty(integrator, :opts) || return (reinit_callback,)
+ opts = integrator.opts
+ isnothing(opts) && return (reinit_callback,)
+ callback_set = opts.callback
+ isnothing(callback_set) && return (reinit_callback,)
+ callback_set isa CallbackSet || return (reinit_callback,)
+
+ callbacks = Any[]
+ for callback in callback_set.discrete_callbacks
+ affect! = callback.affect!
+ if affect! isa DensityReinitializationCallback &&
+ (affect! === reinit_callback ||
+ (initial ? affect!.reinit_initial_solution :
+ affect!(integrator.u, integrator.t,
+ integrator)))
+ push!(callbacks, affect!)
+ end
+ end
+
+ return Tuple(callbacks)
end
function reinitialize_density!(reinit_callback::DensityReinitializationCallback,
vu_ode, semi)
v_ode, u_ode = vu_ode.x
-
particle_system = current_reinit_system(reinit_callback.system_index, semi)
check_density_reinit_system(particle_system)
v = wrap_v(v_ode, particle_system, semi)
u = wrap_u(u_ode, particle_system, semi)
-
reinit_density!(particle_system, v, u, v_ode, u_ode, semi)
return reinit_callback
end
+function reinitialize_density!(reinit_callbacks, vu_ode, semi)
+ v_ode, u_ode = vu_ode.x
+
+ systems = map(reinit_callbacks) do reinit_callback
+ particle_system = current_reinit_system(reinit_callback.system_index, semi)
+ check_density_reinit_system(particle_system)
+ particle_system
+ end
+
+ reinit_density!(Tuple(systems), v_ode, u_ode, semi)
+
+ return reinit_callbacks
+end
+
function current_reinit_system(system_index, semi)
if !(1 <= system_index <= length(semi.systems))
throw(ArgumentError("system index $system_index is out of bounds for a " *
diff --git a/src/callbacks/update.jl b/src/callbacks/update.jl
index 4b5346ad1f..c0dc392e1a 100644
--- a/src/callbacks/update.jl
+++ b/src/callbacks/update.jl
@@ -9,6 +9,10 @@ Callback to update quantities either at the end of every `interval` time steps o
in intervals of `dt` in terms of integration time by adding additional `tstops`
(note that this may change the solution).
+Rigid contact with tangential spring history requires exactly one
+`UpdateCallback(interval=1)`. Sparse step intervals and `dt`-based schedules cannot advance
+that path-dependent state correctly and are rejected when such contact is present.
+
# Keywords
- `interval=1`: Update quantities at the end of every `interval` time steps.
- `dt`: Update quantities in regular intervals of `dt` in terms of integration time
@@ -56,6 +60,8 @@ function initial_update!(cb::UpdateCallback, vu_ode, t, integrator)
v_ode, u_ode = vu_ode.x
semi = integrator.p.semi
+ validate_rigid_contact_update_callbacks!(semi, integrator)
+
# Tell the semidiscretization that the `UpdateCallback` is used
semi.update_callback_used[] = true
@@ -67,7 +73,7 @@ function initial_update!(cb::UpdateCallback, vu_ode, t, integrator)
end
end
- return cb(integrator)
+ return run_update_callback!(cb, integrator; initial=true)
end
# `condition`
@@ -78,11 +84,20 @@ function (update_callback!::UpdateCallback)(u, t, integrator)
end
# `affect!`
-function (update_callback!::UpdateCallback)(integrator)
+function (callback::UpdateCallback)(integrator)
+ return run_update_callback!(callback, integrator; initial=false)
+end
+
+function run_update_callback!(callback::UpdateCallback, integrator; initial)
t = integrator.t
semi = integrator.p.semi
v_ode, u_ode = integrator.u.x
+ # Contact history is endpoint state, not ODE stage state. Initialization discovers
+ # contacts with zero elapsed time; subsequent calls use the last accepted step length.
+ # In particular, `integrator.dt` can already contain the proposal for the next step.
+ history_dt = initial ? zero(t) : t - integrator.tprev
+
# An empty update without calling any of the functions below does not modify
# the results of the right-hand side.
# The functions that add a discontinuity call `derivative_discontinuity!` themselves.
@@ -105,6 +120,17 @@ function (update_callback!::UpdateCallback)(integrator)
update_particle_packing(system, v_ode, u_ode, semi, integrator)
end
+ contact_history_changed = false
+ foreach_system(semi) do system
+ contact_history_changed |= update_rigid_contact_eachstep!(system, v_ode, u_ode,
+ semi, t, history_dt)
+ end
+
+ # FSAL methods cache the endpoint derivative for reuse as the next first stage.
+ # Tangential-history changes alter contact forces without changing `u`, so that
+ # derivative must be recomputed.
+ contact_history_changed && derivative_discontinuity!(integrator, true)
+
# This is only used by the particle packing system and should be removed in the future
foreach_system(semi) do system
update_transport_velocity!(system, v_ode, semi, integrator)
@@ -127,6 +153,35 @@ function (update_callback!::UpdateCallback)(integrator)
return integrator
end
+function validate_rigid_contact_update_callbacks!(semi, integrator)
+ hasproperty(semi, :systems) || return semi
+ any(system -> system isa RigidBodySystem &&
+ requires_update_callback(system, semi), semi.systems) || return semi
+
+ UpdateCB = Union{DiscreteCallback{<:Any, <:UpdateCallback},
+ DiscreteCallback{<:Any, <:PeriodicCallbackAffect{<:UpdateCallback}}}
+ # SciML wraps step-based and time-periodic callbacks differently. Normalize both forms
+ # here so contact history has one unambiguous owner and one accepted-step schedule.
+ callbacks = filter(cb -> cb isa UpdateCB,
+ integrator.opts.callback.discrete_callbacks)
+
+ length(callbacks) == 1 ||
+ throw(ArgumentError("rigid contact history requires exactly one `UpdateCallback`"))
+
+ callback = only(callbacks)
+ update_callback = callback.affect! isa UpdateCallback ? callback.affect! :
+ callback.affect!.affect!
+ valid_schedule = update_callback.interval isa Integer && update_callback.interval == 1
+ valid_schedule ||
+ throw(ArgumentError("rigid contact history requires `UpdateCallback(interval=1)`"))
+
+ if semi.parallelization_backend isa KernelAbstractions.GPU
+ throw(ArgumentError("rigid contact history is not supported on GPU backends"))
+ end
+
+ return semi
+end
+
function Base.show(io::IO, cb::DiscreteCallback{<:Any, <:UpdateCallback})
@nospecialize cb # reduce precompilation time
print(io, "UpdateCallback(interval=", cb.affect!.interval, ")")
diff --git a/src/general/abstract_system.jl b/src/general/abstract_system.jl
index f52953d7e8..0f4322f69f 100644
--- a/src/general/abstract_system.jl
+++ b/src/general/abstract_system.jl
@@ -162,6 +162,24 @@ end
system_correction(system), system, particle)
end
+# Hydrodynamic corrections of structure systems are stored in their boundary model and are
+# independent of corrections used by the structural scheme itself.
+@inline hydrodynamic_correction(system) = system_correction(system)
+
+@inline function hydrodynamic_smoothing_kernel_grad(system, pos_diff, distance, particle)
+ h = smoothing_length(system, particle)
+ correction = hydrodynamic_correction(system)
+ compact_support_ = compact_support(system_smoothing_kernel(system), h)
+
+ if distance >= compact_support_ ||
+ (skip_zero_distance(correction) && distance^2 < eps(h^2))
+ return zero(pos_diff)
+ end
+
+ return corrected_kernel_grad_unsafe(system_smoothing_kernel(system), pos_diff,
+ distance, h, correction, system, particle)
+end
+
# System updates do nothing by default, but can be dispatched if needed
function update_positions!(system, v, u, v_ode, u_ode, semi, t)
return system
@@ -171,6 +189,14 @@ function update_quantities!(system, v, u, v_ode, u_ode, semi, t)
return system
end
+function update_density_correction_values!(system, v, u, v_ode, u_ode, semi, t)
+ return system
+end
+
+function update_density_correction!(system, v, u, v_ode, u_ode, semi, t)
+ return system
+end
+
function update_pressure!(system, v, u, v_ode, u_ode, semi, t)
return system
end
@@ -179,6 +205,14 @@ function update_boundary_interpolation!(system, v, u, v_ode, u_ode, semi, t)
return system
end
+function update_gradient_correction!(system, v, u, v_ode, u_ode, semi, t)
+ return system
+end
+
+function update_surface_quantities!(system, v, u, v_ode, u_ode, semi, t)
+ return system
+end
+
function update_final!(system, v, u, v_ode, u_ode, semi, t; kwargs...)
return system
end
diff --git a/src/general/corrections.jl b/src/general/corrections.jl
index d97eeaf341..470085dbd7 100644
--- a/src/general/corrections.jl
+++ b/src/general/corrections.jl
@@ -52,7 +52,8 @@ end
ShepardKernelCorrection()
Kernel correction, as explained by [Bonet (1999)](@cite Bonet1999), uses Shepard interpolation
-to obtain a 0-th order accurate result, which was first proposed by [Li et al. (1996)](@cite Li1996).
+to obtain a zeroth-order consistent result (exact reproduction of constants), which was first
+proposed by [Li et al. (1996)](@cite Li1996).
The kernel correction coefficient is determined by
```math
@@ -61,7 +62,11 @@ c(x) = \sum_{b=1} V_b W_b(x),
where ``V_b = m_b / \rho_b`` is the volume of particle ``b``.
This correction is applied with [`SummationDensity`](@ref) to correct the density and leads
-to an improvement, especially at free surfaces.
+to an improvement, especially at free surfaces. With summation density, the current one-pass
+implementation uses the density available when each system is processed and therefore reduces
+the free-surface error without guaranteeing convergence for multiple interacting systems.
+[`DensityReinitializationCallback`](@ref) computes all simultaneously requested corrections
+from the independently evolved continuity density before replacing any density.
!!! note
- It is also referred to as "0th order correction".
@@ -73,7 +78,9 @@ struct ShepardKernelCorrection end
KernelCorrection()
Kernel correction, as explained by [Bonet (1999)](@cite Bonet1999), uses Shepard interpolation
-to obtain a 0-th order accurate result, which was first proposed by Li et al.
+to obtain a zeroth-order consistent kernel gradient (an exact zero gradient for constants
+when the correction coefficient is valid),
+which was first proposed by Li et al.
This can be further extended to obtain a kernel corrected gradient as shown by [Basa et al. (2008)](@cite Basa2008).
The kernel correction coefficient is determined by
@@ -89,6 +96,13 @@ The gradient of corrected kernel is determined by
This correction can be applied with [`SummationDensity`](@ref) and
[`ContinuityDensity`](@ref), which leads to an improvement, especially at free surfaces.
+When the kernel correction coefficient is non-finite or not larger than
+`sqrt(eps(T))` for the coefficient element type `T`, the correction is disabled
+for that particle by setting the coefficient to one and the gradient offset
+`γ` (`dw_gamma`) to zero. The corrected gradient then falls back to the
+uncorrected kernel gradient and zeroth-order gradient consistency is not
+retained for the degenerate particle.
+
!!! note
- This only works when the boundary model uses [`SummationDensity`](@ref) (yet).
- It is also referred to as "0th order correction".
@@ -108,6 +122,16 @@ which results in a 1st-order-accurate SPH method (see [Bonet, 1999](@cite Bonet1
"""
struct MixedKernelGradientCorrection end
+correction_density(::Any) = nothing
+correction_density(correction::ShepardKernelCorrection) = correction
+
+correction_gradient(::Nothing) = nothing
+correction_gradient(::ShepardKernelCorrection) = nothing
+correction_gradient(::AkinciFreeSurfaceCorrection) = nothing
+correction_gradient(correction) = correction
+
+correction_force(correction) = correction
+
function kernel_correction_coefficient(system::AbstractFluidSystem, particle)
return system.cache.kernel_correction_coefficient[particle]
end
@@ -136,8 +160,10 @@ function compute_correction_values!(system::AbstractBoundarySystem,
end
function compute_shepard_coeff!(system, system_coords, v_ode, u_ode, semi,
- kernel_correction_coefficient)
+ kernel_correction_coefficient,
+ density_numerator=nothing)
set_zero!(kernel_correction_coefficient)
+ reset_density_numerator!(density_numerator)
# Use enabled neighbor systems for the correction value.
@trixi_timeit timer() "compute correction value" begin
@@ -156,19 +182,46 @@ function compute_shepard_coeff!(system, system_coords, v_ode, u_ode, semi,
semi) do particle, neighbor, pos_diff, distance
rho_b = current_density(v_neighbor_system, neighbor_system, neighbor)
m_b = hydrodynamic_mass(neighbor_system, neighbor)
- volume = m_b / rho_b
+ W = smoothing_kernel(system, distance, particle)
- kernel_correction_coefficient[particle] += volume *
- smoothing_kernel(system,
- distance,
- particle)
+ accumulate_shepard_values!(kernel_correction_coefficient,
+ density_numerator, particle, m_b, rho_b, W)
end
end
end
+ sanitize_kernel_correction_coefficient!(kernel_correction_coefficient, system, semi)
+
return kernel_correction_coefficient
end
+@inline reset_density_numerator!(::Nothing) = nothing
+@inline reset_density_numerator!(density_numerator) = set_zero!(density_numerator)
+
+@inline function accumulate_shepard_values!(coefficient, ::Nothing, particle, mass,
+ density, W)
+ @inbounds coefficient[particle] += (mass / density) * W
+ return coefficient
+end
+
+@inline function accumulate_shepard_values!(coefficient, density_numerator, particle, mass,
+ density, W)
+ weighted_mass = mass * W
+ @inbounds coefficient[particle] += weighted_mass / density
+ @inbounds density_numerator[particle] += weighted_mass
+ return coefficient
+end
+
+function sanitize_kernel_correction_coefficient!(coefficient, system, semi)
+ @threaded semi for particle in eachindex(coefficient)
+ value = coefficient[particle]
+ if !isfinite(value) || value <= zero(value)
+ coefficient[particle] = one(value)
+ end
+ end
+ return coefficient
+end
+
function dw_gamma(system::AbstractFluidSystem, particle)
return extract_svector(system.cache.dw_gamma, system, particle)
end
@@ -255,9 +308,22 @@ function compute_correction_values!(system,
end
end
- for particle in eachparticle(system), i in axes(dw_gamma, 1)
- dw_gamma[i, particle] /= kernel_correction_coefficient[particle]
+ minimum_coefficient = sqrt(eps(eltype(kernel_correction_coefficient)))
+ @threaded semi for particle in eachparticle(system)
+ coefficient = kernel_correction_coefficient[particle]
+ if !isfinite(coefficient) || coefficient <= minimum_coefficient
+ kernel_correction_coefficient[particle] = one(coefficient)
+ for i in axes(dw_gamma, 1)
+ dw_gamma[i, particle] = zero(eltype(dw_gamma))
+ end
+ else
+ for i in axes(dw_gamma, 1)
+ dw_gamma[i, particle] /= coefficient
+ end
+ end
end
+
+ return kernel_correction_coefficient
end
@doc raw"""
@@ -284,6 +350,12 @@ The gradient correction, as commonly proposed, involves multiplying this gradien
The correction matrix $\bm{L}_a$ is computed based on the provided particle configuration,
aiming to make the corrected gradient more accurate, especially near domain boundaries.
+When its first-moment matrix is full rank and passes the singularity threshold, the
+correction gives a first-order consistent gradient by differentiating every affine field
+exactly. Rejected matrices fall back to the uncorrected gradient and do not retain this
+property.
+For smooth fields, the local truncation error is generally ``O(h)`` on asymmetric supports and
+``O(h^2)`` on symmetric interior supports.
To satisfy
```math
@@ -313,6 +385,8 @@ This calculates the following,
\tilde\nabla A_i = (1-\lambda) \nabla A_i + \lambda L_i \nabla A_i
```
with ``0 \leq \lambda \leq 1`` being the blending factor.
+For a fixed ``\lambda < 1``, the uncorrected first-moment error remains and no asymptotic order
+improvement is guaranteed.
# Arguments
- `blending_factor`: Blending factor between corrected and regular SPH gradient.
@@ -321,6 +395,10 @@ struct BlendedGradientCorrection{ELTYPE <: Real}
blending_factor::ELTYPE
function BlendedGradientCorrection(blending_factor)
+ if !(zero(blending_factor) <= blending_factor <= one(blending_factor))
+ throw(ArgumentError("`blending_factor` must be between 0 and 1"))
+ end
+
return new{eltype(blending_factor)}(blending_factor)
end
end
@@ -376,8 +454,10 @@ function compute_gradient_correction_matrix!(corr_matrix::AbstractArray, system,
semi) do particle, neighbor, pos_diff, distance
function kernel_grad_local(correction, smoothing_kernel, pos_diff, distance,
smoothing_length_, system, particle)
- return smoothing_kernel_grad_unsafe(system, pos_diff, distance,
- particle)
+ # Do not dispatch through `system`: the correction matrix being used
+ # by that path is the matrix currently being assembled here.
+ return kernel_grad_unsafe(smoothing_kernel, pos_diff, distance,
+ smoothing_length_)
end
# Compute gradient of corrected kernel
@@ -426,8 +506,9 @@ function correction_matrix_inversion_step!(corr_matrix, system, semi)
@threaded semi for particle in eachparticle(system)
L = extract_smatrix(corr_matrix, system, particle)
- # The matrix `L` only becomes singular when the particle and all neighbors
- # are collinear (in 2D) or lie all in the same plane (in 3D).
+ # The matrix `L` becomes singular when the particle and all neighbors are collinear
+ # (in 2D) or lie all in the same plane (in 3D). Nearly singular matrices are also
+ # rejected below to avoid amplifying particle disorder.
# This happens only when two (in 2D) or three (in 3D) particles are isolated,
# or in cases where there is only one layer of fluid particles on a wall.
# In these edge cases, we just disable the correction and set the corrected
@@ -441,10 +522,28 @@ function correction_matrix_inversion_step!(corr_matrix, system, semi)
# so `L` is singular if and only if the position vectors X_ab don't span the
# full space, i.e., particle a and all neighbors lie on the same line (in 2D)
# or plane (in 3D).
- if abs(det(L)) < 1.0f-9
- L_inv = I
+ minimum_relative_determinant = sqrt(eps(eltype(L)))
+ scale = maximum(abs, L)
+
+ if isfinite(scale) && !iszero(scale)
+ L_scaled = L / scale
+ relative_determinant = abs(det(L_scaled))
+
+ if isfinite(relative_determinant) &&
+ relative_determinant >= minimum_relative_determinant
+ # Avoid rescaling roundoff when the direct determinant is representable.
+ raw_determinant = det(L)
+ if isfinite(raw_determinant) && !iszero(raw_determinant)
+ candidate = inv(L)
+ else
+ candidate = inv(L_scaled) / scale
+ end
+ L_inv = all(isfinite, candidate) ? candidate : one(L)
+ else
+ L_inv = one(L)
+ end
else
- L_inv = inv(L)
+ L_inv = one(L)
end
# Write inverse back to `corr_matrix`
diff --git a/src/general/general.jl b/src/general/general.jl
index 3e9c868507..80f24786ce 100644
--- a/src/general/general.jl
+++ b/src/general/general.jl
@@ -8,4 +8,4 @@ include("initial_condition.jl")
include("buffer.jl")
include("interpolation.jl")
include("custom_quantities.jl")
-include("time_integration.jl")
+include("source_terms.jl")
diff --git a/src/general/interpolation.jl b/src/general/interpolation.jl
index 961224fab7..2389b89fe0 100644
--- a/src/general/interpolation.jl
+++ b/src/general/interpolation.jl
@@ -198,10 +198,9 @@ function interpolate_plane_2d(min_corner, max_corner, resolution, semi, ref_syst
x_range = range(min_corner[1], max_corner[1], length=n_points_per_dimension[1])
y_range = range(min_corner[2], max_corner[2], length=n_points_per_dimension[2])
- # Generate points within the plane. Use `place_on_shell=true` to generate points
- # on the shell of the geometry.
- point_coords = rectangular_shape_coords(resolution, n_points_per_dimension, min_corner,
- place_on_shell=true)
+ # Generate points from the exact ranges used for VTK output. This keeps interpolation
+ # points inside the requested box even when `resolution` does not divide its side lengths.
+ point_coords = plane_point_coords(x_range, y_range)
results = interpolate_points(point_coords, semi, ref_system, v_ode, u_ode;
smoothing_length, cut_off_bnd, include_wall_velocity,
@@ -211,14 +210,8 @@ function interpolate_plane_2d(min_corner, max_corner, resolution, semi, ref_syst
# Find indices where neighbor_count > 0
indices = findall(x -> x > 0, results.neighbor_count)
- # Filter all arrays in the named tuple using these indices
- results = map(results) do x
- if isa(x, AbstractVector)
- return x[indices]
- else
- return x[:, indices]
- end
- end
+ # Filter all arrays in the named tuple using these indices.
+ results = filter_interpolation_results(results, indices)
end
return results, x_range, y_range
@@ -309,13 +302,7 @@ function interpolate_plane_3d(point1, point2, point3, resolution, semi, ref_syst
# Filter results
indices = findall(x -> x > 0, results.neighbor_count)
- filtered_results = map(results) do x
- if isa(x, AbstractVector)
- return x[indices]
- else
- return x[:, indices]
- end
- end
+ filtered_results = filter_interpolation_results(results, indices)
return filtered_results
end
@@ -489,6 +476,9 @@ function interpolate_points(point_coords, semi, ref_system, v_ode, u_ode;
smoothing_length, cut_off_bnd, clip_negative_pressure)
end
+"""Prepare interpolation neighborhood searches for all systems.
+Reuse cached search when valid, otherwise rebuild from interpolation points and adapt
+between CPU/GPU backends as needed."""
function process_neighborhood_searches(semi, u_ode, ref_system, smoothing_length,
point_coords)
if isapprox(smoothing_length, initial_smoothing_length(ref_system))
@@ -523,7 +513,7 @@ function process_neighborhood_searches(semi, u_ode, ref_system, smoothing_length
end
nhs_cpu = PointNeighbors.copy_neighborhood_search(old_nhs_cpu, search_radius,
- nparticles(system))
+ nparticles(system))
PointNeighbors.initialize!(nhs_cpu, point_coords_cpu, system_coords_cpu;
eachindex_y=each_active_particle(system))
@@ -610,7 +600,9 @@ end
interpolate_system!(cache, v, neighbor_system,
point, neighbor, volume_b, W_ab, clip_negative_pressure)
else
- other_density[point] += m_b * W_ab
+ if cut_off_bnd
+ other_density[point] += m_b * W_ab
+ end
if include_wall_velocity
velocity_neighbor_ = current_velocity(v, neighbor_system, neighbor)
@@ -661,6 +653,35 @@ end
return (; computed_density, point_coords, neighbor_count, cache...)
end
+"""Create a 2D point matrix from x and y ranges.
+Returns a 2×N array in y-major order so points match interpolation VTK ranges."""
+function plane_point_coords(x_range, y_range)
+ ELTYPE = promote_type(eltype(x_range), eltype(y_range))
+ point_coords = Array{ELTYPE, 2}(undef, 2, length(x_range) * length(y_range))
+
+ point = 1
+ for y in y_range, x in x_range
+ point_coords[1, point] = x
+ point_coords[2, point] = y
+ point += 1
+ end
+
+ return point_coords
+end
+
+"""Filter interpolation results by point indices.
+Each field in the tuple is filtered consistently across its point dimension."""
+function filter_interpolation_results(results, indices)
+ return map(field -> filter_interpolation_field(field, indices), results)
+end
+
+"""Filter one interpolation field at selected points.
+For vectors this is direct indexing; for tensors, only the last axis is sliced."""
+function filter_interpolation_field(field::AbstractArray, indices)
+ selectors = ntuple(dim -> dim == ndims(field) ? indices : Colon(), ndims(field))
+ return field[selectors...]
+end
+
@inline function create_cache_interpolation(ref_system::AbstractFluidSystem, n_points, semi)
(; parallelization_backend) = semi
diff --git a/src/general/ode_rhs.jl b/src/general/ode_rhs.jl
new file mode 100644
index 0000000000..08cb785135
--- /dev/null
+++ b/src/general/ode_rhs.jl
@@ -0,0 +1,349 @@
+function drift!(du_ode, v_ode, u_ode, p, t)
+ (; semi) = p
+
+ @trixi_timeit timer() "drift!" begin
+ foreach_system(semi) do system
+ du = wrap_u(du_ode, system, semi)
+ v = wrap_v(v_ode, system, semi)
+ u = wrap_u(u_ode, system, semi)
+
+ set_velocity!(du, v, u, system, semi, t)
+ end
+ end
+
+ return du_ode
+end
+
+# Generic fallback for all systems that don't define this function
+function set_velocity!(du, v, u, system, semi, t)
+ set_velocity_default!(du, v, u, system, semi, t)
+end
+
+# Only set velocity for TLSPH systems if they are integrated
+function set_velocity!(du, v, u, system::TotalLagrangianSPHSystem, semi, t)
+ if semi.integrate_tlsph[]
+ set_velocity_default!(du, v, u, system, semi, t)
+ else
+ set_zero!(du)
+ end
+
+ return du
+end
+
+# Solid wall boundary system doesn't integrate the particle positions
+function set_velocity!(du, v, u, system::WallBoundarySystem, semi, t)
+ # Note that `du` is of length zero, so we don't have to set it to zero
+ return du
+end
+
+# Fluid systems integrate the particle positions and can have a shifting velocity
+function set_velocity!(du, v, u, system::AbstractFluidSystem, semi, t)
+ @threaded semi for particle in each_integrated_particle(system)
+ delta_v_ = @inbounds delta_v(system, particle)
+
+ for i in 1:ndims(system)
+ @inbounds du[i, particle] = v[i, particle] + delta_v_[i]
+ end
+ end
+
+ return du
+end
+
+function set_velocity_default!(du, v, u, system, semi, t)
+ @threaded semi for particle in each_integrated_particle(system)
+ for i in 1:ndims(system)
+ @inbounds du[i, particle] = v[i, particle]
+ end
+ end
+
+ return du
+end
+
+# This defaults to optimized GPU copy that is about 4x faster than the threaded version above
+function set_velocity_default!(du::AbstractGPUArray, v, u, system, semi, t)
+ indices = CartesianIndices(du)
+ copyto!(du, indices, v, indices)
+end
+
+function kick!(dv_ode, v_ode, u_ode, p, t)
+ (; semi, split_integration_data) = p
+
+ # This is a no-op if no split integration
+ # or split integration without stage-coupling is used.
+ split_integrate_stage!(v_ode, u_ode, t, split_integration_data)
+
+ @trixi_timeit timer() "kick!" begin
+ # Check that the `UpdateCallback` is used if required
+ check_update_callback(semi)
+
+ @trixi_timeit timer() "reset ∂v/∂t" set_zero!(dv_ode)
+
+ @trixi_timeit timer() "update systems and nhs" update_systems_and_nhs(v_ode, u_ode,
+ semi, t)
+
+ @trixi_timeit timer() "system interaction" system_interaction!(dv_ode, v_ode, u_ode,
+ semi)
+
+ add_source_terms!(dv_ode, v_ode, u_ode, semi, t)
+ end
+
+ return dv_ode
+end
+
+# Update the systems and neighborhood searches (NHS) for a simulation
+# before calling `interact!` to compute forces.
+function update_systems_and_nhs(v_ode, u_ode, semi, t)
+ # First update step before updating the NHS
+ # (for example for writing the current coordinates in the TLSPH system)
+ foreach_system_wrapped(semi, v_ode, u_ode) do system, v, u
+ update_positions!(system, v, u, v_ode, u_ode, semi, t)
+ end
+
+ # Update NHS
+ @trixi_timeit timer() "update nhs" update_nhs!(semi, u_ode)
+
+ # Second update step.
+ # This is used to calculate density and pressure of the fluid systems
+ # before updating the boundary systems,
+ # since the fluid pressure is needed by the Adami interpolation.
+ foreach_system_wrapped(semi, v_ode, u_ode) do system, v, u
+ update_quantities!(system, v, u, v_ode, u_ode, semi, t)
+ end
+
+ update_inter_system_quantities!(semi, v_ode, u_ode, t)
+
+ # Perform correction and pressure calculation
+ foreach_system_wrapped(semi, v_ode, u_ode) do system, v, u
+ update_pressure!(system, v, u, v_ode, u_ode, semi, t)
+ end
+
+ # This update depends on the computed quantities of the fluid system and therefore
+ # needs to be after `update_quantities!`.
+ foreach_system_wrapped(semi, v_ode, u_ode) do system, v, u
+ update_boundary_interpolation!(system, v, u, v_ode, u_ode, semi, t)
+ end
+
+ # Final update step for all remaining systems
+ foreach_system_wrapped(semi, v_ode, u_ode) do system, v, u
+ update_final!(system, v, u, v_ode, u_ode, semi, t)
+ end
+end
+
+# Some systems accumulate pairwise interaction state outside `dv_ode`. Reset that state once
+# at the beginning of every explicitly assembled interaction pass.
+function reset_interaction_caches!(semi::Union{NamedTuple, Semidiscretization})
+ foreach_system(semi) do system
+ reset_interaction_caches!(system)
+ end
+
+ return semi
+end
+
+# The `SplitIntegrationCallback` overwrites `semi_wrap` to use a different
+# semidiscretization for wrapping arrays.
+# `semi_wrap` is the small semidiscretization, `semi` is the large semidiscretization.
+# TODO `semi` is not used yet, but will be used when the source terms API is modified
+# to match the custom quantities API.
+function add_source_terms!(dv_ode, v_ode, u_ode, semi, t; semi_wrap=semi)
+ foreach_system_wrapped(semi_wrap, v_ode, u_ode) do system, v, u
+ dv = wrap_v(dv_ode, system, semi_wrap)
+
+ # `integrate_tlsph` is extracted from the `semi_wrap`, so that this function
+ # can be used in the `SplitIntegrationCallback` as well.
+ # In this case, `semi_wrap` will be the small sub-integration semidiscretization.
+ add_source_terms!(dv, v, u, system, semi, t, semi_wrap.integrate_tlsph[])
+ end
+
+ return dv_ode
+end
+
+# This is a no-op by default but can be dispatched by system type
+function add_source_terms!(dv, v, u, system, semi, t, integrate_tlsph)
+ return dv
+end
+
+function add_source_terms!(dv, v, u,
+ system::Union{AbstractFluidSystem, AbstractStructureSystem},
+ semi, t, integrate_tlsph)
+ add_source_terms_inner!(dv, v, u, system, semi, t)
+end
+
+function add_source_terms!(dv, v, u, system::TotalLagrangianSPHSystem,
+ semi, t, integrate_tlsph)
+ if integrate_tlsph
+ add_source_terms_inner!(dv, v, u, system, semi, t)
+ end
+
+ return dv
+end
+
+function add_source_terms_inner!(dv, v, u,
+ system::Union{AbstractFluidSystem,
+ AbstractStructureSystem},
+ semi, t)
+ if iszero(system.acceleration) && isnothing(source_terms(system))
+ # Nothing to do
+ return dv
+ end
+
+ @trixi_timeit timer() "source terms" begin
+ @threaded semi for particle in each_integrated_particle(system)
+ add_acceleration!(dv, system, particle)
+ add_source_terms_inner!(dv, v, u, particle, system, source_terms(system), t)
+ end
+ end
+
+ return dv
+end
+
+@inline source_terms(system) = nothing
+@inline source_terms(system::Union{AbstractFluidSystem, AbstractStructureSystem}) = system.source_terms
+
+@inline function add_acceleration!(dv, system, particle)
+ (; acceleration) = system
+
+ for i in 1:ndims(system)
+ @inbounds dv[i, particle] += acceleration[i]
+ end
+
+ return dv
+end
+
+@propagate_inbounds function add_source_terms_inner!(dv, v, u, particle,
+ system::RigidBodySystem,
+ source_terms_, t)
+ coords = current_coords(u, system, particle)
+ velocity = current_velocity(v, system, particle)
+ density = system.material_density[particle]
+ pressure = 0 # Rigid body systems don't have a pressure, but some source terms might depend on it
+
+ source = source_terms_(coords, velocity, density, pressure, t)
+
+ for i in eachindex(source)
+ dv[i, particle] += source[i]
+ end
+
+ return dv
+end
+
+@inline add_source_terms_inner!(dv, v, u, particle,
+ system::RigidBodySystem,
+ source_terms_::Nothing, t) = dv
+
+@propagate_inbounds function add_source_terms_inner!(dv, v, u, particle, system,
+ source_terms_, t)
+ coords = current_coords(u, system, particle)
+ velocity = current_velocity(v, system, particle)
+ density = current_density(v, system, particle)
+ pressure = current_pressure(v, system, particle)
+
+ source = source_terms_(coords, velocity, density, pressure, t)
+
+ # Loop over `eachindex(source)`, so that users could also pass source terms for
+ # the density when using `ContinuityDensity`.
+ for i in eachindex(source)
+ dv[i, particle] += source[i]
+ end
+
+ return dv
+end
+
+@inline add_source_terms_inner!(dv, v, u, particle, system, source_terms_::Nothing, t) = dv
+
+function system_interaction!(dv_ode, v_ode, u_ode, semi)
+ reset_interaction_caches!(semi)
+
+ # Call `interact!` for each ordered pair of systems.
+ foreach_system(semi) do system
+ foreach_system(semi) do neighbor
+ has_system_interaction(system, neighbor, semi) || return dv_ode
+
+ # Construct string for the interactions timer.
+ # Avoid allocations from string construction when no timers are used.
+ if timeit_debug_enabled()
+ system_index = system_indices(system, semi)
+ neighbor_index = system_indices(neighbor, semi)
+ timer_str = "$(timer_name(system))$system_index-$(timer_name(neighbor))$neighbor_index"
+ else
+ timer_str = ""
+ end
+
+ interact!(dv_ode, v_ode, u_ode, system, neighbor, semi; timer_str)
+ end
+ end
+
+ # Finalize systems that need to reduce accumulated interaction data afterward.
+ foreach_system_wrapped(semi, v_ode, u_ode) do system, v, u
+ dv = wrap_v(dv_ode, system, semi)
+
+ finalize_interaction!(system, dv, v, u, dv_ode, v_ode, u_ode, semi)
+ end
+
+ return dv_ode
+end
+
+# Function barrier to make benchmarking interactions easier.
+# One can benchmark, e.g. the fluid-fluid interaction, with:
+# dv_ode, du_ode = copy(sol.u[end]).x; v_ode, u_ode = copy(sol.u[end]).x;
+# For manual multi-pair interaction assembly, call `reset_interaction_caches!(semi)` once
+# before the first direct `interact!` call.
+# @btime TrixiParticles.interact!($dv_ode, $v_ode, $u_ode, $fluid_system, $fluid_system, $semi);
+@inline function interact!(dv_ode, v_ode, u_ode, system, neighbor, semi; timer_str="")
+ dv = wrap_v(dv_ode, system, semi)
+ v_system = wrap_v(v_ode, system, semi)
+ u_system = wrap_u(u_ode, system, semi)
+
+ v_neighbor = wrap_v(v_ode, neighbor, semi)
+ u_neighbor = wrap_u(u_ode, neighbor, semi)
+
+ @trixi_timeit timer() timer_str begin
+ apply_system_interaction!(dv, v_system, u_system, v_neighbor, u_neighbor,
+ system, neighbor, semi)
+ end
+
+ return dv_ode
+end
+
+@inline function apply_system_interaction!(dv, v_system, u_system, v_neighbor,
+ u_neighbor, system, neighbor, semi; kwargs...)
+ interaction = system_interaction(system, neighbor, semi)
+ return apply_interaction!(interaction, dv, v_system, u_system, v_neighbor,
+ u_neighbor, system, neighbor, semi; kwargs...)
+end
+
+@inline function apply_system_interaction!(dv, v_system, u_system, v_neighbor,
+ u_neighbor, system::TotalLagrangianSPHSystem,
+ neighbor, semi;
+ integrate_tlsph=semi.integrate_tlsph[],
+ kwargs...)
+ integrate_tlsph || return dv
+
+ interaction = system_interaction(system, neighbor, semi)
+ return apply_interaction!(interaction, dv, v_system, u_system, v_neighbor,
+ u_neighbor, system, neighbor, semi; kwargs...)
+end
+
+@inline function apply_interaction!(interaction::Bool, dv, v_system, u_system,
+ v_neighbor, u_neighbor, system, neighbor, semi;
+ kwargs...)
+ interaction || return dv
+ return interact!(dv, v_system, u_system, v_neighbor, u_neighbor, system, neighbor,
+ semi; kwargs...)
+end
+
+@inline function apply_interaction!(interaction, dv, v_system, u_system,
+ v_neighbor, u_neighbor, system, neighbor, semi;
+ kwargs...)
+ return interaction(dv, v_system, u_system, v_neighbor, u_neighbor, system, neighbor,
+ semi; kwargs...)
+end
+
+function check_update_callback(semi)
+ foreach_system(semi) do system
+ # This check will be optimized away if the system does not require the callback
+ if requires_update_callback(system, semi) && !semi.update_callback_used[]
+ system_name = system |> typeof |> nameof
+ throw(ArgumentError("`UpdateCallback` is required for `$system_name`"))
+ end
+ end
+end
diff --git a/src/general/semidiscretization.jl b/src/general/semidiscretization.jl
index cb70eec437..6c835d4ccb 100644
--- a/src/general/semidiscretization.jl
+++ b/src/general/semidiscretization.jl
@@ -252,7 +252,8 @@ end
Create an `ODEProblem` from the semidiscretization with the specified `tspan`.
# Arguments
-- `semi`: A [`Semidiscretization`](@ref) holding the systems involved in the simulation.
+- `semi`: A [`Semidiscretization`](@ref TrixiParticles.Semidiscretization)
+ holding the systems involved in the simulation.
- `tspan`: The time span over which the simulation will be run.
# Keywords
@@ -263,11 +264,12 @@ Create an `ODEProblem` from the semidiscretization with the specified `tspan`.
[trixi-framework/Trixi.jl#1583](https://github.com/trixi-framework/Trixi.jl/issues/1583).
- `restart_with=nothing`: Restart the simulation from VTK solution files created by
[`SolutionSavingCallback`](@ref). This can be either `nothing` (default, no restart) or
- a tuple of filenames, one for each system in the [`Semidiscretization`](@ref). The tuple
- order must match the system order. When restarting, `semidiscretize` replaces the initial
- time (`tspan[1]`) with the timestamp read from the VTK files. If the provided `tspan[1]`
- does not match the restart time, it is adjusted and an info message is logged. Timestamps
- in multiple files must match.
+ a tuple of filenames, one for each system in the
+ [`Semidiscretization`](@ref TrixiParticles.Semidiscretization). The tuple order must match
+ the system order. When restarting, `semidiscretize` replaces the initial time (`tspan[1]`)
+ with the timestamp read from the VTK files. If the provided `tspan[1]` does not match the
+ restart time, it is adjusted and an info message is logged. Timestamps in multiple files
+ must match.
# Returns
A `DynamicalODEProblem` (see [the OrdinaryDiffEq.jl docs](https://docs.sciml.ai/DiffEqDocs/stable/types/dynamical_types/))
@@ -293,6 +295,16 @@ u0: ([...], [...]) *this line is ignored by filter*
function semidiscretize(semi, tspan; reset_threads=true, restart_with=nothing)
(; systems) = semi
+ # Tangential contact uses CPU dictionaries for accepted-step history and persistent wall
+ # descriptors. Reject it before adapting state arrays so GPU runs cannot fail later in an
+ # RHS kernel with an opaque host-container error.
+ if semi.parallelization_backend isa KernelAbstractions.GPU &&
+ any(system -> system isa RigidBodySystem &&
+ !isnothing(system.contact_model) &&
+ has_tangential_contact(system.contact_model), systems)
+ throw(ArgumentError("rigid contact friction is not supported on GPU backends"))
+ end
+
if restart_with isa String
restart_with = (restart_with,)
elseif !isnothing(restart_with) && !(restart_with isa NTuple{<:Any, String})
@@ -420,7 +432,8 @@ Set the restartable state of all systems in `semi` to the final values in the so
`sol`. This includes coordinates and velocities as well as integrated state variables such
as density or pressure where applicable.
[`semidiscretize`](@ref) has to be called again afterwards, or another
-[`Semidiscretization`](@ref) can be created with the updated systems.
+[`Semidiscretization`](@ref TrixiParticles.Semidiscretization) can be created
+with the updated systems.
# Arguments
- `semi`: The semidiscretization to update.
@@ -507,6 +520,7 @@ end
return reshape(view(array, range), Int.(size))
end
+
function calculate_dt(v_ode, u_ode, cfl_number, semi::Semidiscretization)
(; systems) = semi
@@ -666,7 +680,17 @@ function update_systems_and_nhs(v_ode, u_ode, semi, t)
update_implicit_sph!(semi, v_ode, u_ode, t)
- # Perform correction and pressure calculation
+ # Correction moments can use densities from every interacting system. Assemble every
+ # coefficient before correcting any density so all systems observe the same state.
+ foreach_system_wrapped(semi, v_ode, u_ode) do system, v, u
+ update_density_correction_values!(system, v, u, v_ode, u_ode, semi, t)
+ end
+
+ foreach_system_wrapped(semi, v_ode, u_ode) do system, v, u
+ update_density_correction!(system, v, u, v_ode, u_ode, semi, t)
+ end
+
+ # Fluid pressure must be available before boundary pressure interpolation.
foreach_system_wrapped(semi, v_ode, u_ode) do system, v, u
update_pressure!(system, v, u, v_ode, u_ode, semi, t)
end
@@ -677,6 +701,18 @@ function update_systems_and_nhs(v_ode, u_ode, semi, t)
update_boundary_interpolation!(system, v, u, v_ode, u_ode, semi, t)
end
+ # Boundary interpolation can update boundary density. Assemble all gradient corrections
+ # only after every interacting system exposes its final density.
+ foreach_system_wrapped(semi, v_ode, u_ode) do system, v, u
+ update_gradient_correction!(system, v, u, v_ode, u_ode, semi, t)
+ end
+
+ # Surface quantities can depend on corrected gradients and must be complete for every
+ # system before curvature and stress are computed in `update_final!`.
+ foreach_system_wrapped(semi, v_ode, u_ode) do system, v, u
+ update_surface_quantities!(system, v, u, v_ode, u_ode, semi, t)
+ end
+
# Final update step for all remaining systems
foreach_system_wrapped(semi, v_ode, u_ode) do system, v, u
update_final!(system, v, u, v_ode, u_ode, semi, t)
@@ -804,41 +840,6 @@ end
@inline add_source_terms_inner!(dv, v, u, particle, system, source_terms_::Nothing, t) = dv
-@doc raw"""
- SourceTermDamping(; damping_coefficient)
-
-A source term to be used when a damping step is required before running a full simulation.
-The term ``-c \cdot v_a`` is added to the acceleration ``\frac{\mathrm{d}v_a}{\mathrm{d}t}``
-of particle ``a``, where ``c`` is the damping coefficient and ``v_a`` is the velocity of
-particle ``a``.
-
-# Keywords
-- `damping_coefficient`: The coefficient ``c`` above. A higher coefficient means more
- damping. A coefficient of `1e-4` is a good starting point for
- damping a fluid at rest.
-
-# Examples
-```jldoctest; output = false
-source_terms = SourceTermDamping(; damping_coefficient=1e-4)
-
-# output
-SourceTermDamping{Float64}(0.0001)
-```
-"""
-struct SourceTermDamping{ELTYPE}
- damping_coefficient::ELTYPE
-
- function SourceTermDamping(; damping_coefficient)
- return new{typeof(damping_coefficient)}(damping_coefficient)
- end
-end
-
-@inline function (source_term::SourceTermDamping)(coords, velocity, density, pressure, t)
- (; damping_coefficient) = source_term
-
- return -damping_coefficient * velocity
-end
-
function system_interaction!(dv_ode, v_ode, u_ode, semi)
reset_interaction_caches!(semi)
@@ -928,15 +929,18 @@ end
end
function check_update_callback(semi)
+ semi.update_callback_used[] && return
+
foreach_system(semi) do system
# This check will be optimized away if the system does not require the callback
- if requires_update_callback(system, semi) && !semi.update_callback_used[]
+ if requires_update_callback(system, semi)
system_name = system |> typeof |> nameof
throw(ArgumentError("`UpdateCallback` is required for `$system_name`"))
end
end
end
+
function check_configuration(systems,
nhs::Union{Nothing, AbstractNeighborhoodSearch})
foreach_system(systems) do system
diff --git a/src/general/source_terms.jl b/src/general/source_terms.jl
new file mode 100644
index 0000000000..506dbe4707
--- /dev/null
+++ b/src/general/source_terms.jl
@@ -0,0 +1,34 @@
+@doc raw"""
+ SourceTermDamping(; damping_coefficient)
+
+A source term to be used when a damping step is required before running a full simulation.
+The term ``-c \cdot v_a`` is added to the acceleration ``\frac{\mathrm{d}v_a}{\mathrm{d}t}``
+of particle ``a``, where ``c`` is the damping coefficient and ``v_a`` is the velocity of
+particle ``a``.
+
+# Keywords
+- `damping_coefficient`: The coefficient ``c`` above. A higher coefficient means more
+ damping. A coefficient of `1e-4` is a good starting point for
+ damping a fluid at rest.
+
+# Examples
+```jldoctest; output = false
+source_terms = SourceTermDamping(; damping_coefficient=1e-4)
+
+# output
+SourceTermDamping{Float64}(0.0001)
+```
+"""
+struct SourceTermDamping{ELTYPE}
+ damping_coefficient::ELTYPE
+
+ function SourceTermDamping(; damping_coefficient)
+ return new{typeof(damping_coefficient)}(damping_coefficient)
+ end
+end
+
+@inline function (source_term::SourceTermDamping)(coords, velocity, density, pressure, t)
+ (; damping_coefficient) = source_term
+
+ return -damping_coefficient * velocity
+end
diff --git a/src/io/io.jl b/src/io/io.jl
index 692dfd8528..9a729f7609 100644
--- a/src/io/io.jl
+++ b/src/io/io.jl
@@ -36,8 +36,8 @@ function create_meta_data_dict(callback, integrator)
systems = Dict{String, Any}()
foreach_system(semi) do system
- idx = system_indices(system, semi)
- name = add_underscore_to_optional_prefix(prefix) * names[idx]
+ system_index = system_indices(system, semi)
+ name = add_underscore_to_optional_prefix(prefix) * names[system_index]
system_data = Dict{String, Any}()
add_system_data!(system_data, system)
@@ -225,7 +225,13 @@ function add_system_data!(system_data, contact_model::RigidContactModel)
system_data["contact_model"]["model"] = type2string(contact_model)
system_data["contact_model"]["normal_stiffness"] = contact_model.normal_stiffness
system_data["contact_model"]["normal_damping"] = contact_model.normal_damping
+ system_data["contact_model"]["static_friction_coefficient"] = contact_model.static_friction_coefficient
+ system_data["contact_model"]["kinetic_friction_coefficient"] = contact_model.kinetic_friction_coefficient
+ system_data["contact_model"]["tangential_stiffness"] = contact_model.tangential_stiffness
+ system_data["contact_model"]["tangential_damping"] = contact_model.tangential_damping
system_data["contact_model"]["contact_distance"] = contact_model.contact_distance
+ system_data["contact_model"]["stick_velocity_tolerance"] = contact_model.stick_velocity_tolerance
+ system_data["contact_model"]["penetration_slop"] = contact_model.penetration_slop
end
function add_system_data!(system_data, state_equation::StateEquationCole)
diff --git a/src/preprocessing/geometries/geometries.jl b/src/preprocessing/geometries/geometries.jl
index c1c0948522..5336c10c67 100644
--- a/src/preprocessing/geometries/geometries.jl
+++ b/src/preprocessing/geometries/geometries.jl
@@ -4,6 +4,70 @@ include("io.jl")
@inline eachface(mesh) = Base.OneTo(nfaces(mesh))
+"""
+ is_closed_geometry(geometry)
+
+Return `true` if a polygon or triangle mesh forms a closed region or surface.
+"""
+function is_closed_geometry(polygon::Polygon)
+ vertex_degrees = polygon_vertex_degrees(polygon)
+
+ return !isempty(vertex_degrees) && all(==(2), values(vertex_degrees))
+end
+
+function polygon_vertex_degrees(polygon)
+ VERTEX = typeof(first(first(polygon.edge_vertices)))
+ vertex_degrees = Dict{VERTEX, Int}()
+
+ for edge in polygon.edge_vertices
+ for vertex in edge
+ vertex_degrees[vertex] = get(vertex_degrees, vertex, 0) + 1
+ end
+ end
+
+ return vertex_degrees
+end
+
+function is_closed_geometry(mesh::TriangleMesh)
+ return all(==(2), edge_face_counts(mesh))
+end
+
+function require_closed_geometry(geometry, operation)
+ is_closed_geometry(geometry) && return nothing
+
+ msg = "`$operation` requires a closed geometry. " *
+ closure_error_detail(geometry)
+
+ throw(ArgumentError(msg))
+end
+
+function closure_error_detail(polygon::Polygon)
+ invalid_vertices = count(!=(2), values(polygon_vertex_degrees(polygon)))
+
+ return "Found $invalid_vertices polygon vertices with an incident-edge count " *
+ "different from 2. If the vertices already trace a complete 2D boundary, " *
+ "construct or load the geometry with `close_curve=true`; otherwise provide " *
+ "a closed boundary."
+end
+
+function closure_error_detail(mesh::TriangleMesh)
+ invalid_edges = count(!=(2), edge_face_counts(mesh))
+
+ return "Found $invalid_edges mesh edges with an incident-face count different from 2."
+end
+
+function edge_face_counts(mesh::TriangleMesh)
+ edge_face_counts = zeros(Int, length(mesh.edge_vertices_ids))
+
+ for face_edges in mesh.face_edges_ids
+ edge_face_counts[face_edges[1]] += 1
+ edge_face_counts[face_edges[2]] += 1
+ edge_face_counts[face_edges[3]] += 1
+ end
+
+ return edge_face_counts
+end
+
function Base.setdiff(initial_condition::InitialCondition,
geometries::Union{Polygon, TriangleMesh}...)
geometry = first(geometries)
@@ -11,6 +75,7 @@ function Base.setdiff(initial_condition::InitialCondition,
if ndims(geometry) != ndims(initial_condition)
throw(ArgumentError("all passed geometries must have the same dimensionality as the initial condition"))
end
+ require_closed_geometry(geometry, "setdiff")
coords = reinterpret(reshape,
SVector{ndims(geometry), eltype(initial_condition.coordinates)},
@@ -41,6 +106,7 @@ function Base.intersect(initial_condition::InitialCondition,
if ndims(geometry) != ndims(initial_condition)
throw(ArgumentError("all passed geometries must have the same dimensionality as the initial condition"))
end
+ require_closed_geometry(geometry, "intersect")
coords = reinterpret(reshape,
SVector{ndims(geometry), eltype(initial_condition.coordinates)},
diff --git a/src/preprocessing/geometries/io.jl b/src/preprocessing/geometries/io.jl
index bbccc276ec..a7c6106827 100644
--- a/src/preprocessing/geometries/io.jl
+++ b/src/preprocessing/geometries/io.jl
@@ -1,5 +1,5 @@
"""
- load_geometry(filename; element_type=Float64)
+ load_geometry(filename; element_type=Float64, close_curve=true)
Load file and return corresponding type for [`ComplexShape`](@ref).
Supported file formats are `.stl`, `.asc` and `dxf`.
@@ -18,16 +18,20 @@ For comprehensive information about the supported file formats, refer to the doc
# Keywords
- `element_type`: Element type (default is `Float64`)
+- `close_curve`: Close 2D `.asc` and `.dxf` curves by appending the first point
+ when it is not already repeated. This assumes the vertices already
+ trace a complete, ordered boundary. Set this to `false` for intentional
+ open curves. Region sampling and classification reject open geometries.
"""
-function load_geometry(filename; element_type=Float64)
+function load_geometry(filename; element_type=Float64, close_curve=true)
ELTYPE = element_type
file_extension = splitext(filename)[end]
if file_extension == ".asc"
- geometry = load_ascii(filename; ELTYPE, skipstart=1)
+ geometry = load_ascii(filename; ELTYPE, skipstart=1, close_curve)
elseif file_extension == ".dxf"
- geometry = load_dxf(filename; ELTYPE)
+ geometry = load_dxf(filename; ELTYPE, close_curve)
elseif file_extension == ".stl"
geometry = load(FileIO.query(filename); ELTYPE)
else
@@ -37,21 +41,21 @@ function load_geometry(filename; element_type=Float64)
return geometry
end
-function load_ascii(filename; ELTYPE=Float64, skipstart=1)
+function load_ascii(filename; ELTYPE=Float64, skipstart=1, close_curve=true)
# Read the data from the ASCII file in as a matrix of coordinates.
# Ignore the first `skipstart` lines of the file (e.g. headers).
points = DelimitedFiles.readdlm(filename, ' ', ELTYPE, '\n'; skipstart)[:, 1:2]
- return Polygon(copy(points'))
+ return Polygon(copy(points'); close_curve)
end
-function load_dxf(filename; ELTYPE=Float64)
+function load_dxf(filename; ELTYPE=Float64, close_curve=true)
points = Tuple{ELTYPE, ELTYPE}[]
load_dxf!(points, filename)
- return Polygon(stack(points))
+ return Polygon(stack(points); close_curve)
end
function load_dxf!(points::Vector{Tuple{T, T}}, filename) where {T}
diff --git a/src/preprocessing/geometries/polygon.jl b/src/preprocessing/geometries/polygon.jl
index c56315b729..c2614f4571 100644
--- a/src/preprocessing/geometries/polygon.jl
+++ b/src/preprocessing/geometries/polygon.jl
@@ -8,21 +8,30 @@ struct Polygon{NDIMS, ELTYPE}
min_corner :: SVector{NDIMS, ELTYPE}
max_corner :: SVector{NDIMS, ELTYPE}
- function Polygon(vertices)
+ function Polygon(vertices; close_curve=true)
NDIMS = size(vertices, 1)
- return Polygon{NDIMS}(vertices)
+ return Polygon{NDIMS}(vertices; close_curve)
end
# Function barrier to make `NDIMS` static and therefore `SVector`s type-stable
- function Polygon{NDIMS}(vertices_) where {NDIMS}
- n_vertices = size(vertices_, 2)
+ function Polygon{NDIMS}(vertices_; close_curve=true) where {NDIMS}
ELTYPE = eltype(vertices_)
- min_corner = SVector{NDIMS}(minimum(vertices_, dims=2))
- max_corner = SVector{NDIMS}(maximum(vertices_, dims=2))
+ vertices = collect(reinterpret(reshape, SVector{NDIMS, ELTYPE}, vertices_))
- vertices = reinterpret(reshape, SVector{NDIMS, ELTYPE}, vertices_)
+ if length(vertices) < 3
+ throw(ArgumentError("polygon requires at least three vertices"))
+ end
+
+ if close_curve && !isapprox(first(vertices), last(vertices))
+ push!(vertices, first(vertices))
+ end
+
+ n_vertices = length(vertices)
+
+ min_corner = SVector([minimum(v[i] for v in vertices) for i in 1:NDIMS]...)
+ max_corner = SVector([maximum(v[i] for v in vertices) for i in 1:NDIMS]...)
# Sum over all the edges and determine if the vertices are in clockwise order
# to make sure that all normals pointing outwards.
@@ -63,6 +72,10 @@ struct Polygon{NDIMS, ELTYPE}
push!(edge_normals, edge_normal)
end
+ if length(edge_vertices) < 3
+ throw(ArgumentError("polygon requires at least three non-degenerate edges"))
+ end
+
vertex_normals = Vector{NTuple{2, SVector{NDIMS, ELTYPE}}}()
# Calculate vertex pseudo-normals.
@@ -95,6 +108,63 @@ struct Polygon{NDIMS, ELTYPE}
return new{NDIMS, ELTYPE}(vertices, edge_vertices, vertex_normals, edge_normals,
edge_vertices_ids, min_corner, max_corner)
end
+
+ function Polygon{NDIMS, ELTYPE}(vertices, edge_vertices, vertex_normals,
+ edge_normals, edge_vertices_ids,
+ min_corner, max_corner) where {NDIMS, ELTYPE}
+ return new{NDIMS, ELTYPE}(vertices, edge_vertices, vertex_normals, edge_normals,
+ edge_vertices_ids, min_corner, max_corner)
+ end
+end
+
+function vertex_normals_from_edges(edge_vertices, edge_normals)
+ VERTEX = typeof(first(first(edge_vertices)))
+ normal_sums = Dict{VERTEX, VERTEX}()
+
+ for (edge, edge_normal) in zip(edge_vertices, edge_normals)
+ for vertex in edge
+ normal_sums[vertex] = get(normal_sums, vertex, zero(edge_normal)) + edge_normal
+ end
+ end
+
+ return map(edge_vertices, edge_normals) do edge, edge_normal
+ normals = map(edge) do vertex
+ normal_sum = normal_sums[vertex]
+ normal_norm = norm(normal_sum)
+
+ return iszero(normal_norm) ? edge_normal : normal_sum / normal_norm
+ end
+
+ return Tuple(normals)
+ end
+end
+
+function rebuild_polygon_from_edges(edge_vertices, edge_normals)
+ NDIMS = length(first(edge_normals))
+ ELTYPE = eltype(first(edge_normals))
+ vertices = SVector{NDIMS, ELTYPE}[]
+ vertex_ids = Dict{SVector{NDIMS, ELTYPE}, Int}()
+
+ edge_vertices_ids = map(edge_vertices) do edge
+ v1, v2 = edge
+ id1 = get!(vertex_ids, v1) do
+ push!(vertices, v1)
+ return length(vertices)
+ end
+ id2 = get!(vertex_ids, v2) do
+ push!(vertices, v2)
+ return length(vertices)
+ end
+
+ return (id1, id2)
+ end
+
+ min_corner = SVector([minimum(v[i] for v in vertices) for i in 1:NDIMS]...)
+ max_corner = SVector([maximum(v[i] for v in vertices) for i in 1:NDIMS]...)
+ vertex_normals = vertex_normals_from_edges(edge_vertices, edge_normals)
+
+ return Polygon{NDIMS, ELTYPE}(vertices, edge_vertices, vertex_normals, edge_normals,
+ edge_vertices_ids, min_corner, max_corner)
end
function Base.show(io::IO, geometry::Polygon)
@@ -119,14 +189,23 @@ end
@inline Base.eltype(::Polygon{NDIMS, ELTYPE}) where {NDIMS, ELTYPE} = ELTYPE
-@inline function Base.deleteat!(polygon::Polygon, indices)
- (; edge_vertices, edge_normals, edge_vertices_ids) = polygon
+"""
+ delete_faces(geometry, indices)
+
+Return a geometry with the faces at `indices` removed and derived geometry data rebuilt.
+"""
+@inline function delete_faces(polygon::Polygon, indices)
+ edge_vertices = copy(polygon.edge_vertices)
+ edge_normals = copy(polygon.edge_normals)
deleteat!(edge_vertices, indices)
- deleteat!(edge_vertices_ids, indices)
deleteat!(edge_normals, indices)
- return polygon
+ if isempty(edge_vertices)
+ throw(ArgumentError("cannot delete all polygon edges"))
+ end
+
+ return rebuild_polygon_from_edges(edge_vertices, edge_normals)
end
@inline nfaces(mesh::Polygon) = length(mesh.edge_normals)
diff --git a/src/preprocessing/geometries/triangle_mesh.jl b/src/preprocessing/geometries/triangle_mesh.jl
index ede02d4682..7677784854 100644
--- a/src/preprocessing/geometries/triangle_mesh.jl
+++ b/src/preprocessing/geometries/triangle_mesh.jl
@@ -129,19 +129,16 @@ struct TriangleMesh{NDIMS, ELTYPE}
min_corner = SVector([minimum(v[i] for v in vertices) for i in 1:NDIMS]...)
max_corner = SVector([maximum(v[i] for v in vertices) for i in 1:NDIMS]...)
- for i in eachindex(edge_normals)
- # Skip zero normals, which would be normalized to `NaN` vectors.
- # The edge normals are only used for the `SignedDistanceField`, which is
- # essential for the packing.
- # Zero normals are caused by exactly or nearly duplicated faces.
- if !iszero(norm(edge_normals[i]))
- edge_normals[i] = normalize(edge_normals[i])
+ for normals in (edge_normals, vertex_normals)
+ for i in eachindex(normals)
+ normals_norm = norm(normals[i])
+ !iszero(normals_norm) && (normals[i] = normals[i] / normals_norm)
end
end
return new{NDIMS, ELTYPE}(vertices, face_vertices, face_vertices_ids,
face_edges_ids, edge_vertices_ids,
- normalize.(vertex_normals), edge_normals,
+ vertex_normals, edge_normals,
face_normals, min_corner, max_corner)
end
end
@@ -171,15 +168,19 @@ end
@inline face_normal(triangle, geometry::TriangleMesh) = geometry.face_normals[triangle]
-@inline function Base.deleteat!(mesh::TriangleMesh, indices)
- (; face_vertices, face_vertices_ids, face_edges_ids, face_normals) = mesh
+@inline function delete_faces(mesh::TriangleMesh, indices)
+ face_vertices = copy(mesh.face_vertices)
+ face_normals = copy(mesh.face_normals)
deleteat!(face_vertices, indices)
- deleteat!(face_vertices_ids, indices)
- deleteat!(face_edges_ids, indices)
deleteat!(face_normals, indices)
- return mesh
+ if isempty(face_vertices)
+ throw(ArgumentError("cannot delete all triangle mesh faces"))
+ end
+
+ vertices = collect(Iterators.flatten(face_vertices))
+ return TriangleMesh(face_vertices, face_normals, vertices)
end
@inline nfaces(mesh::TriangleMesh) = length(mesh.face_normals)
diff --git a/src/preprocessing/particle_packing/signed_distance.jl b/src/preprocessing/particle_packing/signed_distance.jl
index 01e862365f..2ef2c5eae4 100644
--- a/src/preprocessing/particle_packing/signed_distance.jl
+++ b/src/preprocessing/particle_packing/signed_distance.jl
@@ -16,11 +16,15 @@ to this surface.
distance of `abs(max_signed_distance)` to the surface of the shape
will be sampled.
- `points`: Points on which the signed distance is computed.
+ Pass a collection of static vectors or an `NDIMS`-by-`N` matrix with
+ one point per column.
When set to `nothing` (default), the bounding box of the shape will be
sampled with a uniform grid of points.
- `use_for_boundary_packing`: Set to `true` if [`SignedDistanceField`] is used to pack
a boundary [`ParticlePackingSystem`](@ref).
Use the default of `false` when packing without a boundary.
+ This requires a closed geometry, since boundary packing
+ needs a well-defined outside region.
"""
struct SignedDistanceField{ELTYPE, P, N, D}
positions :: P
@@ -38,6 +42,11 @@ function SignedDistanceField(geometry, particle_spacing;
NDIMS = ndims(geometry)
ELTYPE = eltype(particle_spacing)
+ if use_for_boundary_packing
+ require_closed_geometry(geometry,
+ "SignedDistanceField with `use_for_boundary_packing=true`")
+ end
+
sdf_factor = use_for_boundary_packing ? 2 : 1
search_radius = sdf_factor * max_signed_distance
@@ -62,9 +71,11 @@ function SignedDistanceField(geometry, particle_spacing;
min_corner; place_on_shell=true)
points = reinterpret(reshape, SVector{NDIMS, eltype(grid)}, grid)
+ else
+ points = wrap_points(points, Val(NDIMS))
end
- positions = copy(points)
+ positions = collect(points)
# This gives a performance boost for large geometries
delete_positions_in_empty_cells!(positions, nhs)
diff --git a/src/preprocessing/particle_packing/system.jl b/src/preprocessing/particle_packing/system.jl
index a4bb7e9aa7..45585541ee 100644
--- a/src/preprocessing/particle_packing/system.jl
+++ b/src/preprocessing/particle_packing/system.jl
@@ -5,6 +5,7 @@
smoothing_length=shape.particle_spacing,
smoothing_length_interpolation=smoothing_length,
is_boundary=false, boundary_compress_factor=1,
+ boundary_thickness=nothing,
neighborhood_search=GridNeighborhoodSearch{ndims(shape)}(),
background_pressure, place_on_shell=false, fixed_system=false)
@@ -26,10 +27,6 @@ For more information on the methods, see [particle packing](@ref particle_packin
- `is_boundary`: When `shape` is inside the geometry that was used to create
`signed_distance_field`, set `is_boundary=false`.
Otherwise (`shape` is the sampled boundary), set `is_boundary=true`.
- The thickness of the boundary is specified by creating
- `signed_distance_field` with:
- - `use_for_boundary_packing=true`
- - `max_signed_distance=boundary_thickness`
See [`SignedDistanceField`](@ref).
- `fixed_system`: When set to `true`, the system remains static, meaning particles
will not move and the `InitialCondition` will stay unchanged.
@@ -54,6 +51,10 @@ For more information on the methods, see [particle packing](@ref particle_packin
Compression can be useful for highly convex geometries,
where the boundary volume increases significantly while the mass of the boundary particles remains constant.
Recommended values are `0.8` or `0.9`.
+- `boundary_thickness`: Thickness of the sampled boundary when `is_boundary=true`.
+ By default, this is `signed_distance_field.max_signed_distance`.
+ If [`sample_boundary`](@ref) used a smaller `boundary_thickness`
+ than the `SignedDistanceField`, pass the same value here.
"""
struct ParticlePackingSystem{S, F, NDIMS, ELTYPE <: Real, PR, C, AV,
IC, M, D, K, N, SD} <: AbstractFluidSystem{NDIMS}
@@ -106,6 +107,7 @@ function ParticlePackingSystem(shape::InitialCondition;
smoothing_length=shape.particle_spacing,
smoothing_length_interpolation=smoothing_length,
is_boundary=false, boundary_compress_factor=1,
+ boundary_thickness=nothing,
neighborhood_search=GridNeighborhoodSearch{ndims(shape)}(),
background_pressure, place_on_shell=false,
fixed_system=false)
@@ -147,10 +149,30 @@ function ParticlePackingSystem(shape::InitialCondition;
# Its value is negative if the particle is inside the geometry.
# Otherwise (if outside), the value is positive.
if is_boundary
- offset = place_on_shell ? shape.particle_spacing : shape.particle_spacing / 2
+ if isnothing(signed_distance_field)
+ fixed_system ||
+ throw(ArgumentError("`signed_distance_field` is required when `is_boundary=true`"))
+
+ shift_length = zero(ELTYPE)
+ else
+ boundary_thickness_ = isnothing(boundary_thickness) ?
+ signed_distance_field.max_signed_distance :
+ convert(ELTYPE, boundary_thickness)
+
+ if boundary_thickness_ > signed_distance_field.max_signed_distance
+ throw(ArgumentError("`boundary_thickness` is greater than " *
+ "`max_signed_distance` of `SignedDistanceField`."))
+ end
+
+ if boundary_thickness_ < zero(boundary_thickness_)
+ throw(ArgumentError("`boundary_thickness` must be non-negative"))
+ end
- shift_length = -boundary_compress_factor *
- signed_distance_field.max_signed_distance - offset
+ offset = place_on_shell ? shape.particle_spacing : shape.particle_spacing / 2
+
+ shift_length = -boundary_compress_factor *
+ boundary_thickness_ - offset
+ end
else
shift_length = place_on_shell ? zero(ELTYPE) : shape.particle_spacing / 2
end
diff --git a/src/preprocessing/point_in_poly/winding_number_hormann.jl b/src/preprocessing/point_in_poly/winding_number_hormann.jl
index a93cf789c4..9f9b849acb 100644
--- a/src/preprocessing/point_in_poly/winding_number_hormann.jl
+++ b/src/preprocessing/point_in_poly/winding_number_hormann.jl
@@ -14,6 +14,7 @@ struct WindingNumberHormann end
# https://doi.org/10.1016/S0925-7721(01)00012-8
function (point_in_poly::WindingNumberHormann)(geometry, points; store_winding_number=false)
(; edge_vertices) = geometry
+ points = wrap_points(points, Val(ndims(geometry)))
# We cannot use a `BitVector` here, as writing to a `BitVector` is not thread-safe
inpoly = fill(false, length(points))
diff --git a/src/preprocessing/point_in_poly/winding_number_jacobson.jl b/src/preprocessing/point_in_poly/winding_number_jacobson.jl
index 145a6d9a28..14a0d62cae 100644
--- a/src/preprocessing/point_in_poly/winding_number_jacobson.jl
+++ b/src/preprocessing/point_in_poly/winding_number_jacobson.jl
@@ -51,14 +51,16 @@ end
"""
WindingNumberJacobson(; geometry=nothing, winding_number_factor=sqrt(eps()),
- hierarchical_winding=false)
+ hierarchical_winding=!isnothing(geometry))
Algorithm for inside-outside segmentation of a complex geometry proposed by [Jacobson2013](@cite).
# Keywords
- `geometry`: Complex geometry returned by [`load_geometry`](@ref) and is only required when using
`hierarchical_winding=true`.
- `hierarchical_winding`: If set to `true`, an optimized hierarchical approach will be used,
- which gives a significant speedup. For further information see [Hierarchical Winding](@ref hierarchical_winding).
+ which gives a significant speedup. It defaults to `true` when `geometry`
+ is passed and `false` otherwise. For further information see
+ [Hierarchical Winding](@ref hierarchical_winding).
- `winding_number_factor`: For leaky geometries, a factor of `0.4` will give a better inside-outside segmentation.
!!! warning "Experimental Implementation"
@@ -69,7 +71,7 @@ struct WindingNumberJacobson{ELTYPE, W}
winding :: W
function WindingNumberJacobson(; geometry=nothing, winding_number_factor=sqrt(eps()),
- hierarchical_winding=true)
+ hierarchical_winding=(!isnothing(geometry)))
if hierarchical_winding && geometry isa Nothing
throw(ArgumentError("`geometry` must be of type `Polygon` (2D) or `TriangleMesh` (3D) when using hierarchical winding"))
end
@@ -104,6 +106,7 @@ end
function (point_in_poly::WindingNumberJacobson)(geometry, points;
store_winding_number=false)
(; winding_number_factor, winding) = point_in_poly
+ points = wrap_points(points, Val(ndims(geometry)))
# We cannot use a `BitVector` here, as writing to a `BitVector` is not thread-safe
inpoly = fill(false, length(points))
diff --git a/src/preprocessing/preprocessing.jl b/src/preprocessing/preprocessing.jl
index c538280bb4..1f29263cae 100644
--- a/src/preprocessing/preprocessing.jl
+++ b/src/preprocessing/preprocessing.jl
@@ -1,3 +1,19 @@
+function wrap_points(points, ::Val{NDIMS}) where {NDIMS}
+ if points isa AbstractMatrix
+ if size(points, 1) != NDIMS
+ throw(ArgumentError("point matrix must have $NDIMS rows"))
+ end
+
+ # Interpret an `NDIMS`-by-`N` matrix as one static vector per column. Constructing
+ # the vectors explicitly also supports non-contiguous matrix views.
+ return map(eachcol(points)) do point
+ return SVector{NDIMS, eltype(points)}(point)
+ end
+ end
+
+ return points
+end
+
include("geometries/geometries.jl")
include("point_in_poly/point_in_poly.jl")
include("particle_packing/particle_packing.jl")
diff --git a/src/schemes/boundary/open_boundary/boundary_zones.jl b/src/schemes/boundary/open_boundary/boundary_zones.jl
index c7f29b1e36..175eee289b 100644
--- a/src/schemes/boundary/open_boundary/boundary_zones.jl
+++ b/src/schemes/boundary/open_boundary/boundary_zones.jl
@@ -384,6 +384,16 @@ function set_up_boundary_zone(boundary_face, face_normal, density, particle_spac
flow_direction = zero(face_normal)
end
+ # Validate boundary geometry before sampling particles.
+ unit_spanning_set,
+ _ = calculate_spanning_vectors(boundary_face,
+ one(eltype(face_normal)))
+ dot_face_normal = dot(normalize(unit_spanning_set[:, 1]), face_normal)
+
+ if !isapprox(abs(dot_face_normal), 1)
+ throw(ArgumentError("`face_normal` is not normal to the boundary face"))
+ end
+
# Sample particles in boundary zone
if isnothing(initial_condition) && isnothing(extrude_geometry)
initial_condition = TrixiParticles.extrude_geometry(boundary_face; particle_spacing,
@@ -408,13 +418,6 @@ function set_up_boundary_zone(boundary_face, face_normal, density, particle_spac
# Vectors spanning the boundary zone/box
spanning_set, zone_origin = calculate_spanning_vectors(boundary_face, zone_width)
- # First vector of `spanning_vectors` is normal to the boundary face.
- dot_face_normal = dot(normalize(spanning_set[:, 1]), face_normal)
-
- if !isapprox(abs(dot_face_normal), 1)
- throw(ArgumentError("`face_normal` is not normal to the boundary face"))
- end
-
if boundary_type isa InFlow
# First vector of `spanning_vectors` is normal to the boundary face
dot_flow = dot(normalize(spanning_set[:, 1]), flow_direction)
@@ -463,11 +466,22 @@ function spanning_vectors(face_vertices::NTuple{3}, zone_width)
edge1 = face_vertices[2] - face_vertices[1]
edge2 = face_vertices[3] - face_vertices[1]
+ edge1_norm = norm(edge1)
+ edge2_norm = norm(edge2)
+ edge_tolerance = sqrt(eps(typeof(edge1_norm * edge2_norm))) * edge1_norm *
+ edge2_norm
+
# Check if the edges are linearly dependent (to avoid degenerate planes)
- if isapprox(norm(cross(edge1, edge2)), 0.0; atol=eps())
+ cross_norm = norm(cross(edge1, edge2))
+ if isapprox(cross_norm, zero(cross_norm); atol=edge_tolerance)
throw(ArgumentError("the vectors `AB` and `AC` must not be collinear"))
end
+ edge_dot = dot(edge1, edge2)
+ if !isapprox(edge_dot, zero(edge_dot); atol=edge_tolerance)
+ throw(ArgumentError("the vectors `AB` and `AC` must be orthogonal"))
+ end
+
# Calculate normal vector of `boundary_face`
c = Vector(normalize(cross(edge2, edge1)) * zone_width)
diff --git a/src/schemes/boundary/open_boundary/method_of_characteristics.jl b/src/schemes/boundary/open_boundary/method_of_characteristics.jl
index ce04478a0c..5023b9023f 100644
--- a/src/schemes/boundary/open_boundary/method_of_characteristics.jl
+++ b/src/schemes/boundary/open_boundary/method_of_characteristics.jl
@@ -169,6 +169,7 @@ function evaluate_characteristics!(system, v, u, v_ode, u_ode, semi, t)
# Particle is outside of the influence of fluid particles.
# `volume` is in the order of 1 / h^d, so volume * h^d is in the order of 1.
if volume[particle] * smoothing_length^ndims(system) < eps(eltype(smoothing_length))
+ zone_id = system.boundary_zone_indices[particle]
# Using the average of the values at the previous time step for particles which
# are outside of the influence of fluid particles.
@@ -178,6 +179,8 @@ function evaluate_characteristics!(system, v, u, v_ode, u_ode, semi, t)
counter = 0
for neighbor in each_integrated_particle(system)
+ system.boundary_zone_indices[neighbor] == zone_id || continue
+
# Make sure that only neighbors in the influence of
# the fluid particles are used.
# `volume` is in the order of 1 / h^d, so volume * h^d is in the order of 1.
diff --git a/src/schemes/boundary/open_boundary/system.jl b/src/schemes/boundary/open_boundary/system.jl
index 3fcd71b05c..1032a71e51 100644
--- a/src/schemes/boundary/open_boundary/system.jl
+++ b/src/schemes/boundary/open_boundary/system.jl
@@ -1,7 +1,9 @@
@doc raw"""
OpenBoundarySystem(boundary_zone::BoundaryZone;
- fluid_system::AbstractFluidSystem, buffer_size::Integer,
- boundary_model, calculate_flow_rate=false)
+ fluid_system::AbstractFluidSystem,
+ buffer_size=default_open_boundary_buffer_size(fluid_system),
+ boundary_model=BoundaryModelMirroringTafuni(),
+ calculate_flow_rate=false)
Open boundary system for in- and outflow particles.
@@ -10,7 +12,10 @@ Open boundary system for in- and outflow particles.
# Keywords
- `fluid_system`: The corresponding fluid system
-- `boundary_model`: Boundary model (see [Open Boundary Models](@ref open_boundary_models))
+- `buffer_size`: Number of buffer particles for the boundary system.
+ Defaults to the buffer size of `fluid_system`.
+- `boundary_model`: Boundary model (see [Open Boundary Models](@ref open_boundary_models)).
+ Defaults to [`BoundaryModelMirroringTafuni`](@ref).
- `calculate_flow_rate=false`: Set to `true` to calculate the volumetric flow rate through each boundary zone.
This value is automatically enabled when using [`RCRWindkesselModel`](@ref).
Otherwise, it is useful only for postprocessing.
@@ -49,6 +54,18 @@ struct OpenBoundarySystem{BM, ELTYPE, NDIMS, IC, FS, FSI, K, ARRAY1D, BC, FC, BZ
cache :: C
end
+function default_open_boundary_buffer_size(fluid_system)
+ fluid_buffer = buffer(fluid_system)
+
+ if fluid_buffer isa SystemBuffer
+ return fluid_buffer.buffer_size
+ end
+
+ throw(ArgumentError("`buffer_size` could not be inferred for `OpenBoundarySystem` " *
+ "because `fluid_system` has no buffer. Pass `buffer_size=...` " *
+ "explicitly or construct `fluid_system` with `buffer_size=...`."))
+end
+
function OpenBoundarySystem(boundary_model, initial_condition, fluid_system,
fluid_system_index, smoothing_kernel, smoothing_length, mass,
volume, boundary_candidates, fluid_candidates,
@@ -70,8 +87,10 @@ function OpenBoundarySystem(boundary_model, initial_condition, fluid_system,
end
function OpenBoundarySystem(boundary_zones::Union{BoundaryZone, Nothing}...;
- fluid_system::AbstractFluidSystem, buffer_size::Integer,
- boundary_model, calculate_flow_rate=false,
+ fluid_system::AbstractFluidSystem,
+ buffer_size=default_open_boundary_buffer_size(fluid_system),
+ boundary_model=BoundaryModelMirroringTafuni(),
+ calculate_flow_rate=false,
pressure_acceleration=fluid_system.pressure_acceleration_formulation,
shifting_technique=boundary_model isa
BoundaryModelDynamicalPressureZhang ?
@@ -814,9 +833,9 @@ function check_configuration(system::OpenBoundarySystem, systems, neighborhood_s
system.fluid_system_index[] = fluid_system_index
if boundary_model isa BoundaryModelCharacteristicsLastiwka &&
- any(zone -> isnothing(zone.flow_direction), boundary_zones)
- throw(ArgumentError("`BoundaryModelCharacteristicsLastiwka` needs a specific flow direction. " *
- "Please specify `InFlow()` and `OutFlow()`."))
+ any(zone -> zone.is_bidirectional, boundary_zones)
+ throw(ArgumentError("`BoundaryModelCharacteristicsLastiwka` needs a directed boundary zone. " *
+ "Please specify `InFlow()` or `OutFlow()` instead of `BidirectionalFlow()`."))
end
if first(PointNeighbors.requires_update(neighborhood_search))
diff --git a/src/schemes/boundary/wall_boundary/dummy_particles.jl b/src/schemes/boundary/wall_boundary/dummy_particles.jl
index fcde467f2e..d49bbab898 100644
--- a/src/schemes/boundary/wall_boundary/dummy_particles.jl
+++ b/src/schemes/boundary/wall_boundary/dummy_particles.jl
@@ -33,7 +33,7 @@ Boundary model for [`WallBoundarySystem`](@ref).
in areas of low pressure, against which the particle
shifting technique is fighting.
- `reference_particle_spacing`: The reference particle spacing used for weighting values at the boundary,
- which currently is only needed when using surface tension.
+ which is needed when using a surface-normal method.
# Examples
```jldoctest; output = false, setup = :(densities = [1.0, 2.0, 3.0]; masses = [0.1, 0.2, 0.3]; smoothing_kernel = SchoenbergCubicSplineKernel{2}(); smoothing_length = 0.1)
# Free-slip condition
@@ -76,6 +76,46 @@ struct BoundaryModelDummyParticles{DC, SE, CLIP, ELTYPE <: Real, VECTOR, K, V, C
end
end
+@doc raw"""
+ BoundaryModelDummyParticles(initial_condition;
+ fluid_system::AbstractFluidSystem,
+ initial_density=initial_condition.density,
+ hydrodynamic_mass=initial_condition.mass,
+ boundary_density_calculator=AdamiPressureExtrapolation(),
+ smoothing_kernel=system_smoothing_kernel(fluid_system),
+ smoothing_length=initial_smoothing_length(fluid_system),
+ viscosity=nothing,
+ state_equation=system_state_equation(fluid_system),
+ correction=system_correction(fluid_system),
+ clip_negative_pressure=false,
+ reference_particle_spacing=default_reference_particle_spacing(fluid_system))
+
+High-level convenience constructor for dummy-particle wall models that infers the kernel,
+smoothing length, correction, and equation-of-state-related settings from the adjacent
+`fluid_system`.
+"""
+function BoundaryModelDummyParticles(initial_condition;
+ fluid_system::AbstractFluidSystem,
+ initial_density=initial_condition.density,
+ hydrodynamic_mass=initial_condition.mass,
+ boundary_density_calculator=AdamiPressureExtrapolation(),
+ smoothing_kernel=system_smoothing_kernel(fluid_system),
+ smoothing_length=initial_smoothing_length(fluid_system),
+ viscosity=nothing,
+ state_equation=system_state_equation(fluid_system),
+ correction=system_correction(fluid_system),
+ clip_negative_pressure=false,
+ reference_particle_spacing=default_reference_particle_spacing(fluid_system))
+ return BoundaryModelDummyParticles(initial_density, hydrodynamic_mass,
+ boundary_density_calculator, smoothing_kernel,
+ smoothing_length;
+ viscosity, state_equation, correction,
+ clip_negative_pressure,
+ reference_particle_spacing)
+end
+
+# The default constructor needs to be accessible for Adapt.jl to work with this struct.
+# See the comments in general/gpu.jl for more details.
function BoundaryModelDummyParticles(initial_density, hydrodynamic_mass,
density_calculator, smoothing_kernel,
smoothing_length; viscosity=nothing,
@@ -109,6 +149,15 @@ function BoundaryModelDummyParticles(initial_density, hydrodynamic_mass,
clip_negative_pressure)
end
+@inline function default_reference_particle_spacing(fluid_system)
+ if hasproperty(fluid_system, :cache) &&
+ hasproperty(fluid_system.cache, :reference_particle_spacing)
+ return fluid_system.cache.reference_particle_spacing
+ end
+
+ return zero(eltype(fluid_system))
+end
+
@inline function Base.ndims(boundary_model::BoundaryModelDummyParticles)
return ndims(boundary_model.smoothing_kernel)
end
@@ -237,19 +286,19 @@ function create_cache_model(::ShepardKernelCorrection, density, NDIMS, n_particl
end
function create_cache_model(::KernelCorrection, density, NDIMS, n_particles)
- dw_gamma = Array{Float64}(undef, NDIMS, n_particles)
+ dw_gamma = Array{eltype(density)}(undef, NDIMS, n_particles)
return (; kernel_correction_coefficient=similar(density), dw_gamma)
end
function create_cache_model(::Union{GradientCorrection, BlendedGradientCorrection}, density,
NDIMS, n_particles)
- correction_matrix = Array{Float64, 3}(undef, NDIMS, NDIMS, n_particles)
+ correction_matrix = Array{eltype(density), 3}(undef, NDIMS, NDIMS, n_particles)
return (; correction_matrix)
end
function create_cache_model(::MixedKernelGradientCorrection, density, NDIMS, n_particles)
- dw_gamma = Array{Float64}(undef, NDIMS, n_particles)
- correction_matrix = Array{Float64, 3}(undef, NDIMS, NDIMS, n_particles)
+ dw_gamma = Array{eltype(density)}(undef, NDIMS, n_particles)
+ correction_matrix = Array{eltype(density), 3}(undef, NDIMS, NDIMS, n_particles)
return (; kernel_correction_coefficient=similar(density), dw_gamma, correction_matrix)
end
@@ -393,21 +442,71 @@ end
@inline function update_pressure!(boundary_model::BoundaryModelDummyParticles,
system, v, u, v_ode, u_ode, semi)
- (; correction, density_calculator) = boundary_model
+ (; density_calculator) = boundary_model
compute_pressure!(boundary_model, density_calculator, system, v, u, v_ode, u_ode, semi)
- # These are only computed when using corrections
- compute_correction_values!(system, correction, u, v_ode, u_ode, semi)
- compute_gradient_correction_matrix!(correction, boundary_model, system, u, v_ode, u_ode,
- semi)
- # `kernel_correct_density!` only performed for `SummationDensity`
- kernel_correct_density!(boundary_model, v, u, v_ode, u_ode, semi, correction,
+ return boundary_model
+end
+
+@inline function update_density_correction_values!(boundary_model::BoundaryModelDummyParticles,
+ system, v, u, v_ode, u_ode, semi)
+ (; correction) = boundary_model
+ density_correction = correction_density(correction)
+
+ compute_boundary_correction_values!(boundary_model, system, density_correction, u,
+ v_ode, u_ode, semi)
+
+ return boundary_model
+end
+
+@inline function update_density_correction!(boundary_model::BoundaryModelDummyParticles,
+ system, v, u, v_ode, u_ode, semi)
+ (; correction, density_calculator) = boundary_model
+ density_correction = correction_density(correction)
+
+ kernel_correct_density!(boundary_model, v, u, v_ode, u_ode, semi,
+ density_correction,
density_calculator)
return boundary_model
end
+@inline function update_gradient_correction!(boundary_model::BoundaryModelDummyParticles,
+ system, v, u, v_ode, u_ode, semi)
+ gradient_correction = correction_gradient(boundary_model.correction)
+
+ compute_boundary_correction_values!(boundary_model, system, gradient_correction, u,
+ v_ode, u_ode, semi)
+ compute_gradient_correction_matrix!(gradient_correction, boundary_model, system, u,
+ v_ode, u_ode, semi)
+
+ return boundary_model
+end
+
+@inline function compute_boundary_correction_values!(boundary_model, system, correction, u,
+ v_ode, u_ode, semi)
+ return boundary_model
+end
+
+function compute_boundary_correction_values!(boundary_model, system,
+ ::ShepardKernelCorrection, u,
+ v_ode, u_ode, semi)
+ return compute_shepard_coeff!(system, current_coordinates(u, system), v_ode, u_ode,
+ semi,
+ boundary_model.cache.kernel_correction_coefficient)
+end
+
+function compute_boundary_correction_values!(boundary_model, system,
+ correction::Union{KernelCorrection,
+ MixedKernelGradientCorrection},
+ u, v_ode, u_ode, semi)
+ return compute_correction_values!(system, correction, current_coordinates(u, system),
+ v_ode, u_ode, semi,
+ boundary_model.cache.kernel_correction_coefficient,
+ boundary_model.cache.dw_gamma)
+end
+
function kernel_correct_density!(boundary_model, v, u, v_ode, u_ode, semi,
correction, density_calculator)
return boundary_model
@@ -428,13 +527,13 @@ function compute_gradient_correction_matrix!(corr::Union{GradientCorrection,
MixedKernelGradientCorrection},
boundary_model,
system, u, v_ode, u_ode, semi)
- (; cache, correction, smoothing_kernel) = boundary_model
+ (; cache, smoothing_kernel) = boundary_model
(; correction_matrix) = cache
system_coords = current_coordinates(u, system)
compute_gradient_correction_matrix!(correction_matrix, system, system_coords,
- v_ode, u_ode, semi, correction, smoothing_kernel)
+ v_ode, u_ode, semi, corr, smoothing_kernel)
end
function compute_density!(boundary_model, ::SummationDensity, system, v, u, v_ode, u_ode,
diff --git a/src/schemes/boundary/wall_boundary/system.jl b/src/schemes/boundary/wall_boundary/system.jl
index f9864ecc05..f7ea1d7192 100644
--- a/src/schemes/boundary/wall_boundary/system.jl
+++ b/src/schemes/boundary/wall_boundary/system.jl
@@ -218,6 +218,21 @@ function update_quantities!(system::WallBoundarySystem, v, u, v_ode, u_ode, semi
return system
end
+function update_density_correction_values!(system::WallBoundarySystem{<:BoundaryModelDummyParticles},
+ v, u, v_ode, u_ode, semi, t)
+ update_density_correction_values!(system.boundary_model, system, v, u, v_ode, u_ode,
+ semi)
+
+ return system
+end
+
+function update_density_correction!(system::WallBoundarySystem{<:BoundaryModelDummyParticles},
+ v, u, v_ode, u_ode, semi, t)
+ update_density_correction!(system.boundary_model, system, v, u, v_ode, u_ode, semi)
+
+ return system
+end
+
# This update depends on the computed quantities of the fluid system and therefore
# has to be in `update_boundary_interpolation!` after `update_quantities!`.
function update_boundary_interpolation!(system::WallBoundarySystem, v, u, v_ode, u_ode,
@@ -231,6 +246,13 @@ function update_boundary_interpolation!(system::WallBoundarySystem, v, u, v_ode,
return system
end
+function update_gradient_correction!(system::WallBoundarySystem{<:BoundaryModelDummyParticles},
+ v, u, v_ode, u_ode, semi, t)
+ update_gradient_correction!(system.boundary_model, system, v, u, v_ode, u_ode, semi)
+
+ return system
+end
+
function write_u0!(u0, ::WallBoundarySystem)
return u0
end
@@ -329,7 +351,7 @@ function system_smoothing_kernel(system::WallBoundarySystem{<:BoundaryModelDummy
end
function system_correction(system::WallBoundarySystem{<:BoundaryModelDummyParticles})
- return system.boundary_model.correction
+ return correction_gradient(system.boundary_model.correction)
end
@inline function density_calculator(system::WallBoundarySystem)
diff --git a/src/schemes/fluid/entropically_damped_sph/rhs.jl b/src/schemes/fluid/entropically_damped_sph/rhs.jl
index d4c526f175..2f20c66a55 100644
--- a/src/schemes/fluid/entropically_damped_sph/rhs.jl
+++ b/src/schemes/fluid/entropically_damped_sph/rhs.jl
@@ -4,6 +4,7 @@ function interact!(dv, v_particle_system, u_particle_system,
particle_system::EntropicallyDampedSPHSystem,
neighbor_system, semi)
(; sound_speed, density_calculator, correction, nu_edac) = particle_system
+ gradient_correction = correction_gradient(correction)
system_coords = current_coordinates(u_particle_system, particle_system)
neighbor_coords = current_coordinates(u_neighbor_system, neighbor_system)
@@ -63,7 +64,7 @@ function interact!(dv, v_particle_system, u_particle_system,
particle, neighbor,
m_a, m_b, p_a - p_avg, p_b - p_avg, rho_a,
rho_b, pos_diff, distance, grad_kernel,
- correction)
+ gradient_correction)
dv_particle = @inbounds add_dv_viscosity(dv_pressure, particle_system,
neighbor_system,
@@ -79,7 +80,8 @@ function interact!(dv, v_particle_system, u_particle_system,
v_particle_system, v_neighbor_system,
particle, neighbor, m_a, m_b, rho_a, rho_b,
v_a, v_b,
- pos_diff, distance, grad_kernel, correction)
+ pos_diff, distance, grad_kernel,
+ gradient_correction)
dv_particle = @inbounds add_dv_surface_tension(dv_particle,
surface_tension_a, surface_tension_b,
diff --git a/src/schemes/fluid/entropically_damped_sph/system.jl b/src/schemes/fluid/entropically_damped_sph/system.jl
index 426e79e537..fca0a73035 100644
--- a/src/schemes/fluid/entropically_damped_sph/system.jl
+++ b/src/schemes/fluid/entropically_damped_sph/system.jl
@@ -8,7 +8,7 @@
acceleration=ntuple(_ -> 0.0, NDIMS), surface_tension=nothing,
surface_normal_method=nothing, buffer_size=nothing,
reference_particle_spacing=0.0, color_value=1,
- source_terms=nothing)
+ correction=nothing, source_terms=nothing)
System for particles of a fluid.
As opposed to the [weakly compressible SPH scheme](@ref wcsph), which uses an equation of state,
@@ -51,9 +51,10 @@ See [Entropically Damped Artificial Compressibility for SPH](@ref edac) for more
gravity-like source terms.
- `surface_tension`: Surface tension model used for this SPH system. (default: no surface tension)
- `surface_normal_method`: The surface normal method to be used for this SPH system.
- (default: no surface normal method or `ColorfieldSurfaceNormal()` if a surface_tension model is used)
+ (default: no surface normal method or `ColorfieldSurfaceNormal()`
+ if the surface tension model requires normals)
- `reference_particle_spacing`: The reference particle spacing used for weighting values at the boundary,
- which currently is only needed when using surface tension.
+ which is needed when using a surface-normal method.
- `color_value`: Integer label used for calculation of surface normals.
Currently this is only used together with [`BoundaryModelDummyParticles`](@ref) and
[`ColorfieldSurfaceNormal`](@ref): fluid-boundary normal evaluation
@@ -110,6 +111,9 @@ function EntropicallyDampedSPHSystem(initial_condition; smoothing_kernel, smooth
mass = copy(initial_condition.mass)
n_particles = length(initial_condition.mass)
+ density_correction_ = correction_density(correction)
+ gradient_correction_ = correction_gradient(correction)
+
if ndims(smoothing_kernel) != NDIMS
throw(ArgumentError("smoothing kernel dimensionality must be $NDIMS for a $(NDIMS)D problem"))
end
@@ -119,15 +123,14 @@ function EntropicallyDampedSPHSystem(initial_condition; smoothing_kernel, smooth
throw(ArgumentError("`acceleration` must be of length $NDIMS for a $(NDIMS)D problem"))
end
- if surface_tension !== nothing && surface_normal_method === nothing
- surface_normal_method = ColorfieldSurfaceNormal()
- end
+ surface_normal_method = default_surface_normal_method(surface_tension,
+ surface_normal_method)
if surface_normal_method !== nothing && reference_particle_spacing < eps()
- throw(ArgumentError("`reference_particle_spacing` must be set to a positive value when using `ColorfieldSurfaceNormal` or a surface tension model"))
+ throw(ArgumentError("`reference_particle_spacing` must be set to a positive value when using a surface-normal method"))
end
- if correction isa ShepardKernelCorrection &&
+ if density_correction_ isa ShepardKernelCorrection &&
density_calculator isa ContinuityDensity
throw(ArgumentError("`ShepardKernelCorrection` cannot be used with `ContinuityDensity`"))
end
@@ -135,7 +138,7 @@ function EntropicallyDampedSPHSystem(initial_condition; smoothing_kernel, smooth
pressure_acceleration = choose_pressure_acceleration_formulation(pressure_acceleration,
density_calculator,
NDIMS, ELTYPE,
- correction)
+ gradient_correction_)
avg_pressure_reduction = Val(average_pressure_reduction)
@@ -251,7 +254,9 @@ end
@inline buffer(system::EntropicallyDampedSPHSystem) = system.buffer
-system_correction(system::EntropicallyDampedSPHSystem) = system.correction
+function system_correction(system::EntropicallyDampedSPHSystem)
+ correction_gradient(system.correction)
+end
@inline function current_velocity(v, system::EntropicallyDampedSPHSystem)
return view(v, 1:ndims(system), :)
@@ -298,12 +303,78 @@ function update_quantities!(system::EntropicallyDampedSPHSystem, v, u,
compute_density!(system, u, u_ode, semi, system.density_calculator)
end
-function update_pressure!(system::EntropicallyDampedSPHSystem, v, u, v_ode, u_ode, semi, t)
+function update_density_correction_values!(system::EntropicallyDampedSPHSystem, v, u,
+ v_ode, u_ode, semi, t)
+ (; correction) = system
+ density_correction = correction_density(correction)
+
+ compute_correction_values!(system, density_correction, u, v_ode, u_ode, semi)
+
+ return system
+end
+
+function update_density_correction!(system::EntropicallyDampedSPHSystem, v, u, v_ode,
+ u_ode, semi, t)
+ (; correction, density_calculator) = system
+ density_correction = correction_density(correction)
+
+ kernel_correct_density!(system, v, u, v_ode, u_ode, semi, density_correction,
+ density_calculator)
+
+ return system
+end
+
+function update_gradient_correction!(system::EntropicallyDampedSPHSystem, v, u, v_ode,
+ u_ode, semi, t)
+ gradient_correction = correction_gradient(system.correction)
+
+ compute_correction_values!(system, gradient_correction, u, v_ode, u_ode, semi)
+ compute_gradient_correction_matrix!(gradient_correction, system, u, v_ode, u_ode, semi)
+
+ return system
+end
+
+function update_surface_quantities!(system::EntropicallyDampedSPHSystem, v, u, v_ode,
+ u_ode, semi, t)
compute_surface_normal!(system, system.surface_normal_method, v, u, v_ode, u_ode, semi,
t)
compute_surface_delta_function!(system, system.surface_tension, semi)
end
+function kernel_correct_density!(system::EntropicallyDampedSPHSystem, v, u, v_ode, u_ode,
+ semi, correction, density_calculator)
+ return system
+end
+
+function kernel_correct_density!(system::EntropicallyDampedSPHSystem, v, u, v_ode, u_ode,
+ semi, ::ShepardKernelCorrection, ::SummationDensity)
+ system.cache.density ./= system.cache.kernel_correction_coefficient
+end
+
+function compute_gradient_correction_matrix!(correction,
+ system::EntropicallyDampedSPHSystem, u,
+ v_ode, u_ode, semi)
+ return system
+end
+
+function compute_gradient_correction_matrix!(corr::Union{GradientCorrection,
+ BlendedGradientCorrection,
+ MixedKernelGradientCorrection},
+ system::EntropicallyDampedSPHSystem, u,
+ v_ode, u_ode, semi)
+ (; cache, smoothing_kernel) = system
+ (; correction_matrix) = cache
+
+ system_coords = current_coordinates(u, system)
+
+ compute_gradient_correction_matrix!(correction_matrix, system, system_coords,
+ v_ode, u_ode, semi, corr, smoothing_kernel)
+end
+
+@inline function correction_matrix(system::EntropicallyDampedSPHSystem, particle)
+ extract_smatrix(system.cache.correction_matrix, system, particle)
+end
+
function update_final!(system::EntropicallyDampedSPHSystem, v, u, v_ode, u_ode, semi, t;
kwargs...)
(; surface_tension) = system
@@ -389,4 +460,6 @@ function restart_with!(system::EntropicallyDampedSPHSystem, v, u)
system.initial_condition.velocity[:, particle] .= v[1:ndims(system), particle]
system.initial_condition.pressure[particle] = v[ndims(system) + 1, particle]
end
+
+ return restart_with!(system, system.density_calculator, v, u)
end
diff --git a/src/schemes/fluid/fluid.jl b/src/schemes/fluid/fluid.jl
index 3bb1296158..3b85e5f228 100644
--- a/src/schemes/fluid/fluid.jl
+++ b/src/schemes/fluid/fluid.jl
@@ -233,10 +233,13 @@ function calculate_dt(v_ode, u_ode, cfl_number, system::AbstractFluidSystem, sem
if surface_tension isa SurfaceTensionMorris ||
surface_tension isa SurfaceTensionMomentumMorris
- v = wrap_v(v_ode, system, semi)
- dt_surface_tension = sqrt(current_density(v, system, 1) * smoothing_length_^3 /
- (2 * pi * surface_tension.surface_tension_coefficient))
- dt = min(dt, dt_surface_tension)
+ coefficient = surface_tension.surface_tension_coefficient
+ if !iszero(coefficient)
+ v = wrap_v(v_ode, system, semi)
+ dt_surface_tension = sqrt(current_density(v, system, 1) * smoothing_length_^3 /
+ (2 * pi * coefficient))
+ dt = min(dt, dt_surface_tension)
+ end
end
return dt
@@ -315,11 +318,13 @@ function restart_v(system::AbstractFluidSystem, data)
end
function check_configuration(fluid_system::AbstractFluidSystem, systems, nhs)
- if !(fluid_system isa ParticlePackingSystem) && !isnothing(fluid_system.surface_tension)
+ if !(fluid_system isa ParticlePackingSystem) &&
+ (!isnothing(fluid_system.surface_tension) ||
+ !isnothing(fluid_system.surface_normal_method))
foreach_system(systems) do neighbor
- if neighbor isa AbstractFluidSystem &&
- isnothing(fluid_system.surface_tension) &&
- isnothing(fluid_system.surface_normal_method)
+ if neighbor isa AbstractFluidSystem && !(neighbor isa ParticlePackingSystem) &&
+ isnothing(neighbor.surface_tension) &&
+ isnothing(neighbor.surface_normal_method)
throw(ArgumentError("either none or all fluid systems in a simulation need " *
"to use a surface tension model or a surface normal method."))
end
diff --git a/src/schemes/fluid/implicit_incompressible_sph/system.jl b/src/schemes/fluid/implicit_incompressible_sph/system.jl
index 9527c46392..c74513e89d 100644
--- a/src/schemes/fluid/implicit_incompressible_sph/system.jl
+++ b/src/schemes/fluid/implicit_incompressible_sph/system.jl
@@ -199,6 +199,8 @@ end
return system.density
end
+@inline system_state_equation(system::ImplicitIncompressibleSPHSystem) = nothing
+
# TODO: What do we do with the sound speed? This is needed for the viscosity.
@inline system_sound_speed(system::ImplicitIncompressibleSPHSystem) = system.artificial_sound_speed
@@ -227,6 +229,10 @@ function update_implicit_sph!(semi, v_ode, u_ode, t)
return semi
end
+function update_inter_system_quantities!(semi, v_ode, u_ode, t)
+ return update_implicit_sph!(semi, v_ode, u_ode, t)
+end
+
function predict_advection!(system::Union{ImplicitIncompressibleSPHSystem,
WallBoundarySystem{<:BoundaryModelDummyParticles{<:PressureBoundaries}}},
v, u, v_ode, u_ode, semi)
@@ -497,7 +503,8 @@ function calculate_sum_d_ij_pj!(sum_d_ij_pj, system,
(; time_step) = system
system_coords = current_coordinates(u, system)
- neighbor_coords = current_coordinates(u, neighbor_system)
+ u_neighbor_system = wrap_u(u_ode, neighbor_system, semi)
+ neighbor_coords = current_coordinates(u_neighbor_system, neighbor_system)
foreach_point_neighbor(system, neighbor_system, system_coords, neighbor_coords, semi;
points=each_integrated_particle(system)) do particle, neighbor,
@@ -586,11 +593,13 @@ function pressure_update(system, pressure, reference_density, a_ii, sum_term, om
pressure[particle] = zero(pressure[particle])
end
# Calculate the average density error for the termination condition
- if (pressure[particle] != 0.0)
+ if pressure[particle] != 0.0
new_density = a_ii[particle] * pressure[particle] + sum_term[particle] -
iisph_source_term(system, particle) +
reference_density
- density_error[particle] = (new_density - reference_density)
+ density_error[particle] = abs(new_density - reference_density)
+ else
+ density_error[particle] = zero(eltype(density_error))
end
end
relative_density_error = sum(density_error) / reference_density
@@ -748,9 +757,11 @@ end
function check_configuration(system::ImplicitIncompressibleSPHSystem, systems, nhs)
(; time_step, omega) = system
foreach_system(systems) do neighbor
- if neighbor isa WeaklyCompressibleSPHSystem
- throw(ArgumentError("`ImplicitIncompressibleSPHSystem` cannot be used together with
- `WeaklyCompressibleSPHSystem`"))
+ if neighbor isa WeaklyCompressibleSPHSystem ||
+ neighbor isa EntropicallyDampedSPHSystem
+ neighbor_name = neighbor |> typeof |> nameof
+ throw(ArgumentError("`ImplicitIncompressibleSPHSystem` cannot be used " *
+ "together with `$neighbor_name`"))
end
if neighbor isa WallBoundarySystem
if (neighbor.boundary_model isa BoundaryModelDummyParticles &&
diff --git a/src/schemes/fluid/pressure_acceleration.jl b/src/schemes/fluid/pressure_acceleration.jl
index b6114c0bc1..748e27a8d7 100644
--- a/src/schemes/fluid/pressure_acceleration.jl
+++ b/src/schemes/fluid/pressure_acceleration.jl
@@ -161,7 +161,8 @@ end
GradientCorrection,
BlendedGradientCorrection,
MixedKernelGradientCorrection})
- W_b = smoothing_kernel_grad(neighbor_system, -pos_diff, distance, neighbor)
+ W_b = hydrodynamic_smoothing_kernel_grad(neighbor_system, -pos_diff, distance,
+ neighbor)
# With correction, the kernel gradient is not necessarily symmetric, so call the
# asymmetric version of the pressure acceleration formulation.
diff --git a/src/schemes/fluid/surface_normal_sph.jl b/src/schemes/fluid/surface_normal_sph.jl
index adbc9d7dbe..40824530c2 100644
--- a/src/schemes/fluid/surface_normal_sph.jl
+++ b/src/schemes/fluid/surface_normal_sph.jl
@@ -17,8 +17,22 @@ end
function ColorfieldSurfaceNormal(; boundary_contact_threshold=0.1, interface_threshold=0.01,
ideal_density_threshold=0.0)
- return ColorfieldSurfaceNormal(boundary_contact_threshold, interface_threshold,
- ideal_density_threshold)
+ thresholds = (boundary_contact_threshold, interface_threshold,
+ ideal_density_threshold)
+ if !all(threshold -> threshold isa Real && isfinite(threshold), thresholds)
+ throw(ArgumentError("surface-normal thresholds must be finite real numbers"))
+ end
+
+ thresholds = promote(thresholds...)
+ return ColorfieldSurfaceNormal(thresholds...)
+end
+
+@inline function default_surface_normal_method(surface_tension, surface_normal_method)
+ if isnothing(surface_normal_method) && requires_surface_normal(surface_tension)
+ return ColorfieldSurfaceNormal()
+ end
+
+ return surface_normal_method
end
function create_cache_surface_normal(surface_normal_method, ELTYPE, NDIMS, nparticles)
diff --git a/src/schemes/fluid/surface_tension.jl b/src/schemes/fluid/surface_tension.jl
index 4c86316864..657c35d932 100644
--- a/src/schemes/fluid/surface_tension.jl
+++ b/src/schemes/fluid/surface_tension.jl
@@ -1,22 +1,38 @@
abstract type AbstractSurfaceTension end
abstract type AkinciTypeSurfaceTension <: AbstractSurfaceTension end
+function validate_surface_tension_coefficient(surface_tension_coefficient)
+ if !(surface_tension_coefficient isa Real) ||
+ !isfinite(surface_tension_coefficient) || surface_tension_coefficient < 0
+ throw(ArgumentError("`surface_tension_coefficient` must be a finite, non-negative real number"))
+ end
+
+ return surface_tension_coefficient
+end
+
@doc raw"""
CohesionForceAkinci(surface_tension_coefficient=1.0)
This model only implements the cohesion force of the Akinci [Akinci2013](@cite) surface tension model.
+It does not require a surface-normal method.
+
+The published Akinci cohesion kernel uses a three-dimensional normalization. In two-dimensional
+simulations, `surface_tension_coefficient` is therefore an empirical numerical parameter and
+may need to be adjusted when changing the resolution.
See [`surface_tension`](@ref) for more details.
# Keywords
-- `surface_tension_coefficient=1.0`: Modifies the intensity of the surface tension-induced force,
- enabling the tuning of the fluid's surface tension properties within the simulation.
+- `surface_tension_coefficient=1.0`: Finite, non-negative coefficient modifying the
+ fluid-fluid cohesion force. Zero disables this force; wall adhesion is controlled by the
+ boundary's `adhesion_coefficient`.
"""
struct CohesionForceAkinci{ELTYPE} <: AkinciTypeSurfaceTension
surface_tension_coefficient::ELTYPE
function CohesionForceAkinci(; surface_tension_coefficient=1.0)
- new{typeof(surface_tension_coefficient)}(surface_tension_coefficient)
+ coefficient = validate_surface_tension_coefficient(surface_tension_coefficient)
+ new{typeof(coefficient)}(coefficient)
end
end
@@ -28,18 +44,22 @@ principles outlined by Akinci [Akinci2013](@cite). This model is instrumental in
behaviors of fluid surfaces, such as droplet formation and the dynamics of merging or
separation, by utilizing intra-particle forces.
+The published Akinci cohesion kernel uses a three-dimensional normalization. In two-dimensional
+simulations, `surface_tension_coefficient` is therefore an empirical numerical parameter and
+may need to be adjusted when changing the resolution.
+
See [`surface_tension`](@ref) for more details.
# Keywords
-- `surface_tension_coefficient=1.0`: A parameter to adjust the magnitude of
- surface tension forces, facilitating the fine-tuning of how surface tension phenomena
- are represented in the simulation.
+- `surface_tension_coefficient=1.0`: Finite, non-negative coefficient adjusting the
+ magnitude of surface tension forces. Zero disables the fluid-fluid force.
"""
struct SurfaceTensionAkinci{ELTYPE} <: AkinciTypeSurfaceTension
surface_tension_coefficient::ELTYPE
function SurfaceTensionAkinci(; surface_tension_coefficient=1.0)
- new{typeof(surface_tension_coefficient)}(surface_tension_coefficient)
+ coefficient = validate_surface_tension_coefficient(surface_tension_coefficient)
+ new{typeof(coefficient)}(coefficient)
end
end
@@ -55,14 +75,15 @@ See [`surface_tension`](@ref) for more details.
# Keywords
-- `surface_tension_coefficient=1.0`: Adjusts the magnitude of the surface tension
- forces, enabling tuning of fluid surface behaviors in simulations.
+- `surface_tension_coefficient=1.0`: Finite, non-negative coefficient adjusting the
+ magnitude of surface tension forces. Zero disables the force.
"""
struct SurfaceTensionMorris{ELTYPE} <: AbstractSurfaceTension
surface_tension_coefficient::ELTYPE
function SurfaceTensionMorris(; surface_tension_coefficient=1.0)
- new{typeof(surface_tension_coefficient)}(surface_tension_coefficient)
+ coefficient = validate_surface_tension_coefficient(surface_tension_coefficient)
+ new{typeof(coefficient)}(coefficient)
end
end
@@ -87,14 +108,15 @@ numerical adjustments at higher resolutions.
See [`surface_tension`](@ref) for more details.
# Keywords
-- `surface_tension_coefficient=1.0`: A parameter to adjust the strength of surface tension
- forces, allowing fine-tuning to replicate physical behavior.
+- `surface_tension_coefficient=1.0`: Finite, non-negative coefficient adjusting the
+ strength of surface tension forces. Zero disables the force.
"""
struct SurfaceTensionMomentumMorris{ELTYPE} <: AbstractSurfaceTension
surface_tension_coefficient::ELTYPE
function SurfaceTensionMomentumMorris(; surface_tension_coefficient=1.0)
- new{typeof(surface_tension_coefficient)}(surface_tension_coefficient)
+ coefficient = validate_surface_tension_coefficient(surface_tension_coefficient)
+ new{typeof(coefficient)}(coefficient)
end
end
@@ -132,6 +154,12 @@ function calculate_surface_tension_dt(v_ode, system, neighbor_system, semi,
(4 * pi * surface_tension_coefficient))
end
+# Surface-model capabilities are expressed through dispatch so that constructors do not need
+# to duplicate concrete model checks.
+@inline requires_surface_normal(::Nothing) = false
+@inline requires_surface_normal(::CohesionForceAkinci) = false
+@inline requires_surface_normal(::Any) = true
+
function create_cache_surface_tension(::SurfaceTensionMomentumMorris, ELTYPE, NDIMS,
nparticles)
delta_s = Array{ELTYPE, 1}(undef, nparticles)
@@ -155,17 +183,24 @@ end
pos_diff, distance)
(; surface_tension_coefficient) = surface_tension
- # Eq. 2
+ distance >= support_radius && return zero(pos_diff)
+
+ # Eq. 2 in dimensionless form avoids scale-dependent powers up to `support_radius^9`.
# We only reach this function when `sqrt(eps()) < distance <= support_radius`
- if distance > 0.5 * support_radius
+ normalized_distance = distance / support_radius
+ if normalized_distance > one(normalized_distance) / 2
# Attractive force
- C = (support_radius - distance)^3 * distance^3
+ C = (1 - normalized_distance)^3 * normalized_distance^3
else
- # `distance < 0.5 * support_radius`
+ # `distance <= 0.5 * support_radius`
# Repulsive force
- C = 2 * (support_radius - distance)^3 * distance^3 - support_radius^6 / 64.0
+ C = 2 * (1 - normalized_distance)^3 * normalized_distance^3 -
+ one(normalized_distance) / 64
end
- C *= 32.0 / (pi * support_radius^9)
+ normalization = oftype(support_radius, 32 / pi)
+ normalization = ((normalization / support_radius) / support_radius) /
+ support_radius
+ C *= normalization
# Eq. 1 in acceleration form
cohesion_force = -surface_tension_coefficient * m_b * C * pos_diff / distance
@@ -175,18 +210,17 @@ end
@inline function adhesion_force_akinci(surface_tension, support_radius, m_b, pos_diff,
distance, adhesion_coefficient)
-
- # The neighborhood search has an `<=` check, but for `distance == support_radius`
- # the term inside the parentheses might be very slightly negative, causing an error with `^0.25`.
- # TODO Change this in the neighborhood search?
- # See https://github.com/trixi-framework/PointNeighbors.jl/issues/19
distance >= support_radius && return zero(pos_diff)
distance <= 0.5 * support_radius && return zero(pos_diff)
- # Eq. 7
- A = 0.007 / support_radius^3.25 *
- (-4 * distance^2 / support_radius + 6 * distance - 2 * support_radius)^0.25
+ # Eq. 7 in dimensionless form avoids cancellation and scale-dependent intermediates.
+ normalized_distance = distance / support_radius
+ radicand = 2 * (2 * normalized_distance - 1) * (1 - normalized_distance)
+ fourth_root = sqrt(sqrt(max(zero(radicand), radicand)))
+ normalization = convert(typeof(support_radius), 0.007)
+ normalization = ((normalization / support_radius) / support_radius) / support_radius
+ A = normalization * fourth_root
# Eq. 6 in acceleration form with `m_b` being the boundary mass calculated as
# `m_b = rho_0 * volume` (Akinci boundary condition treatment)
diff --git a/src/schemes/fluid/weakly_compressible_sph/rhs.jl b/src/schemes/fluid/weakly_compressible_sph/rhs.jl
index f09f56d886..9fd9357765 100644
--- a/src/schemes/fluid/weakly_compressible_sph/rhs.jl
+++ b/src/schemes/fluid/weakly_compressible_sph/rhs.jl
@@ -8,6 +8,8 @@ function interact!(dv, v_particle_system, u_particle_system,
eachparticle=each_integrated_particle(particle_system),
kwargs...)
(; density_calculator, correction) = particle_system
+ gradient_correction = correction_gradient(correction)
+ force_correction = correction_force(correction)
sound_speed = system_sound_speed(particle_system)
@@ -80,7 +82,7 @@ function interact!(dv, v_particle_system, u_particle_system,
# Determine correction factors.
# This can usually be ignored, as these are all 1 when no correction is used.
(viscosity_correction, pressure_correction,
- surface_tension_correction) = free_surface_correction(correction,
+ surface_tension_correction) = free_surface_correction(force_correction,
particle_system,
rho_a, rho_b)
@@ -89,7 +91,7 @@ function interact!(dv, v_particle_system, u_particle_system,
dv_pressure = pressure_acceleration(particle_system, neighbor_system,
particle, neighbor,
m_a, m_b, p_a, p_b, rho_a, rho_b, pos_diff,
- distance, grad_kernel, correction)
+ distance, grad_kernel, gradient_correction)
dv_particle = dv_pressure * pressure_correction
# Propagate `@inbounds` to the viscosity function, which accesses particle data
@@ -108,7 +110,7 @@ function interact!(dv, v_particle_system, u_particle_system,
v_particle_system, v_neighbor_system,
particle, neighbor, m_a, m_b, rho_a,
rho_b, v_a, v_b, pos_diff, distance,
- grad_kernel, correction)
+ grad_kernel, gradient_correction)
dv_particle = @inbounds add_dv_surface_tension(dv_particle,
surface_tension_a,
diff --git a/src/schemes/fluid/weakly_compressible_sph/system.jl b/src/schemes/fluid/weakly_compressible_sph/system.jl
index eea0607d7d..2d931ee85a 100644
--- a/src/schemes/fluid/weakly_compressible_sph/system.jl
+++ b/src/schemes/fluid/weakly_compressible_sph/system.jl
@@ -54,9 +54,10 @@ See [Weakly Compressible SPH](@ref wcsph) for more details on the method.
gravity-like source terms.
- `surface_tension`: Surface tension model used for this SPH system. (default: no surface tension)
- `surface_normal_method`: The surface normal method to be used for this SPH system.
- (default: no surface normal method or `ColorfieldSurfaceNormal()` if a surface_tension model is used)
+ (default: no surface normal method or `ColorfieldSurfaceNormal()`
+ if the surface tension model requires normals)
- `reference_particle_spacing`: The reference particle spacing used for weighting values at the boundary,
- which currently is only needed when using surface tension.
+ which is needed when using a surface-normal method.
- `color_value`: Integer label used for calculation of surface normals.
Currently this is only used together with [`BoundaryModelDummyParticles`](@ref) and
[`ColorfieldSurfaceNormal`](@ref): fluid-boundary normal evaluation
@@ -112,6 +113,9 @@ function WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel,
ELTYPE = eltype(initial_condition)
n_particles = nparticles(initial_condition)
+ density_correction_ = correction_density(correction)
+ gradient_correction_ = correction_gradient(correction)
+
mass = copy(initial_condition.mass)
pressure = similar(initial_condition.pressure)
@@ -125,23 +129,22 @@ function WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel,
throw(ArgumentError("`acceleration` must be of length $NDIMS for a $(NDIMS)D problem"))
end
- if correction isa ShepardKernelCorrection &&
+ if density_correction_ isa ShepardKernelCorrection &&
density_calculator isa ContinuityDensity
throw(ArgumentError("`ShepardKernelCorrection` cannot be used with `ContinuityDensity`"))
end
- if surface_tension !== nothing && surface_normal_method === nothing
- surface_normal_method = ColorfieldSurfaceNormal()
- end
+ surface_normal_method = default_surface_normal_method(surface_tension,
+ surface_normal_method)
if surface_normal_method !== nothing && reference_particle_spacing < eps()
- throw(ArgumentError("`reference_particle_spacing` must be set to a positive value when using `ColorfieldSurfaceNormal` or a surface tension model"))
+ throw(ArgumentError("`reference_particle_spacing` must be set to a positive value when using a surface-normal method"))
end
pressure_acceleration = choose_pressure_acceleration_formulation(pressure_acceleration,
density_calculator,
NDIMS, ELTYPE,
- correction)
+ gradient_correction_)
cache = (; create_cache_density(initial_condition, density_calculator)...,
create_cache_correction(correction, initial_condition.density, NDIMS,
@@ -243,7 +246,9 @@ end
@inline buffer(system::WeaklyCompressibleSPHSystem) = system.buffer
-system_correction(system::WeaklyCompressibleSPHSystem) = system.correction
+function system_correction(system::WeaklyCompressibleSPHSystem)
+ correction_gradient(system.correction)
+end
@propagate_inbounds function current_velocity(v, system::WeaklyCompressibleSPHSystem)
return current_velocity(v, system.density_calculator, system)
@@ -320,18 +325,47 @@ end
return system
end
-function update_pressure!(system::WeaklyCompressibleSPHSystem, v, u, v_ode, u_ode, semi, t)
- (; density_calculator, correction, surface_normal_method, surface_tension) = system
+function update_density_correction_values!(system::WeaklyCompressibleSPHSystem, v, u,
+ v_ode, u_ode, semi, t)
+ (; correction) = system
+ density_correction = correction_density(correction)
- compute_pressure!(system, v, semi)
+ compute_correction_values!(system, density_correction, u, v_ode, u_ode, semi)
+
+ return system
+end
+
+function update_density_correction!(system::WeaklyCompressibleSPHSystem, v, u, v_ode,
+ u_ode, semi, t)
+ (; density_calculator, correction) = system
+ density_correction = correction_density(correction)
- # These are only computed when using corrections
- compute_correction_values!(system, correction, u, v_ode, u_ode, semi)
- compute_gradient_correction_matrix!(correction, system, u, v_ode, u_ode, semi)
- # `kernel_correct_density!` only performed for `SummationDensity`
- kernel_correct_density!(system, v, u, v_ode, u_ode, semi, correction,
+ kernel_correct_density!(system, v, u, v_ode, u_ode, semi, density_correction,
density_calculator)
+ return system
+end
+
+function update_pressure!(system::WeaklyCompressibleSPHSystem, v, u, v_ode, u_ode, semi, t)
+ compute_pressure!(system, v, semi)
+
+ return system
+end
+
+function update_gradient_correction!(system::WeaklyCompressibleSPHSystem, v, u, v_ode,
+ u_ode, semi, t)
+ gradient_correction = correction_gradient(system.correction)
+
+ compute_correction_values!(system, gradient_correction, u, v_ode, u_ode, semi)
+ compute_gradient_correction_matrix!(gradient_correction, system, u, v_ode, u_ode, semi)
+
+ return system
+end
+
+function update_surface_quantities!(system::WeaklyCompressibleSPHSystem, v, u, v_ode,
+ u_ode, semi, t)
+ (; surface_normal_method, surface_tension) = system
+
# These are only computed when using surface tension
compute_surface_normal!(system, surface_normal_method, v, u, v_ode, u_ode, semi, t)
compute_surface_delta_function!(system, surface_tension, semi)
@@ -369,25 +403,53 @@ function compute_gradient_correction_matrix!(corr::Union{GradientCorrection,
MixedKernelGradientCorrection},
system::WeaklyCompressibleSPHSystem, u,
v_ode, u_ode, semi)
- (; cache, correction, smoothing_kernel) = system
+ (; cache, smoothing_kernel) = system
(; correction_matrix) = cache
system_coords = current_coordinates(u, system)
compute_gradient_correction_matrix!(correction_matrix, system, system_coords,
- v_ode, u_ode, semi, correction, smoothing_kernel)
+ v_ode, u_ode, semi, corr, smoothing_kernel)
end
function reinit_density!(vu_ode, semi)
v_ode, u_ode = vu_ode.x
- foreach_system_wrapped(semi, v_ode, u_ode) do system, v, u
- reinit_density!(system, v, u, v_ode, u_ode, semi)
- end
+ reinit_density!(semi.systems, v_ode, u_ode, semi)
return vu_ode
end
+function reinit_density!(systems, v_ode, u_ode, semi)
+ coefficients = prepare_reinit_density!(systems, v_ode, u_ode, semi)
+ apply_reinit_density!(systems, coefficients, v_ode, u_ode, semi)
+
+ return systems
+end
+
+prepare_reinit_density!(::Tuple{}, v_ode, u_ode, semi) = ()
+
+function prepare_reinit_density!(systems, v_ode, u_ode, semi)
+ system = first(systems)
+ v = wrap_v(v_ode, system, semi)
+ u = wrap_u(u_ode, system, semi)
+ coefficient = prepare_reinit_density!(system, v, u, v_ode, u_ode, semi)
+
+ return (coefficient, prepare_reinit_density!(Base.tail(systems), v_ode, u_ode, semi)...)
+end
+
+apply_reinit_density!(::Tuple{}, ::Tuple{}, v_ode, u_ode, semi) = nothing
+
+function apply_reinit_density!(systems, coefficients, v_ode, u_ode, semi)
+ system = first(systems)
+ v = wrap_v(v_ode, system, semi)
+ u = wrap_u(u_ode, system, semi)
+ apply_reinit_density!(system, first(coefficients), v, u, v_ode, u_ode, semi)
+
+ return apply_reinit_density!(Base.tail(systems), Base.tail(coefficients), v_ode, u_ode,
+ semi)
+end
+
function reinit_density!(system::WeaklyCompressibleSPHSystem, v, u,
v_ode, u_ode, semi)
(; density_calculator) = system
@@ -397,16 +459,56 @@ end
function reinit_density!(system::WeaklyCompressibleSPHSystem, ::ContinuityDensity, v, u,
v_ode, u_ode, semi)
+ coefficient = prepare_reinit_density!(system, v, u, v_ode, u_ode, semi)
+ apply_reinit_density!(system, coefficient, v, u, v_ode, u_ode, semi)
+
+ return system
+end
+
+prepare_reinit_density!(system, v, u, v_ode, u_ode, semi) = nothing
+
+function prepare_reinit_density!(system::WeaklyCompressibleSPHSystem, v, u,
+ v_ode, u_ode, semi)
+ return prepare_reinit_density!(system, system.density_calculator, v, u, v_ode, u_ode,
+ semi)
+end
+
+prepare_reinit_density!(system, density_calculator, v, u, v_ode, u_ode, semi) = nothing
+
+function prepare_reinit_density!(system::WeaklyCompressibleSPHSystem, ::ContinuityDensity,
+ v, u, v_ode, u_ode, semi)
+ # Compute all coefficients before any reinitialization overwrites continuity density.
+ coefficient = similar(v, size(v, 2))
+ compute_shepard_coeff!(system, current_coordinates(u, system), v_ode, u_ode, semi,
+ coefficient)
+
+ return coefficient
+end
+
+apply_reinit_density!(system, coefficient, v, u, v_ode, u_ode, semi) = system
+
+function apply_reinit_density!(system::WeaklyCompressibleSPHSystem, coefficient, v, u,
+ v_ode, u_ode, semi)
+ return apply_reinit_density!(system, system.density_calculator, coefficient, v, u,
+ v_ode,
+ u_ode, semi)
+end
+
+function apply_reinit_density!(system, density_calculator, coefficient, v, u, v_ode, u_ode,
+ semi)
+ system
+end
+
+function apply_reinit_density!(system::WeaklyCompressibleSPHSystem, ::ContinuityDensity,
+ coefficient, v, u, v_ode, u_ode, semi)
+ isnothing(coefficient) && return system
+
# Compute density with `SummationDensity` and store the result in `v`,
# overwriting the previous integrated density.
summation_density!(system, semi, u, u_ode, v[end, :])
- # Apply `ShepardKernelCorrection`
- kernel_correction_coefficient = zeros(size(v[end, :]))
- compute_shepard_coeff!(system, current_coordinates(u, system), v_ode, u_ode, semi,
- kernel_correction_coefficient)
@threaded semi for particle in eachparticle(system)
- v[end, particle] /= kernel_correction_coefficient[particle]
+ v[end, particle] /= coefficient[particle]
end
compute_pressure!(system, v, semi)
diff --git a/src/schemes/structure/rigid_body/contact.jl b/src/schemes/structure/rigid_body/contact.jl
index 658be79c04..62b6211bd4 100644
--- a/src/schemes/structure/rigid_body/contact.jl
+++ b/src/schemes/structure/rigid_body/contact.jl
@@ -1 +1,2 @@
include("contact_models.jl")
+include("contact_forces.jl")
diff --git a/src/schemes/structure/rigid_body/contact_forces.jl b/src/schemes/structure/rigid_body/contact_forces.jl
new file mode 100644
index 0000000000..718ee0f59b
--- /dev/null
+++ b/src/schemes/structure/rigid_body/contact_forces.jl
@@ -0,0 +1,421 @@
+@inline function requires_update_callback(contact_model::RigidContactModel)
+ return has_tangential_contact(contact_model) &&
+ contact_model.tangential_stiffness > 0
+end
+
+function create_cache_contact_history(contact_model::RigidContactModel, ::Val{NDIMS},
+ ::Type{ELTYPE}) where {NDIMS, ELTYPE}
+ if has_tangential_contact(contact_model)
+ # These dictionaries are persistent accepted-step state. In contrast, the manifold
+ # arrays in `create_cache_contact_manifold` are rebuilt during every RHS evaluation.
+ contact_tangential_displacement = Dict{RigidContactKey,
+ SVector{NDIMS, ELTYPE}}()
+ wall_contact_descriptors = Dict{RigidContactKey,
+ WallContactDescriptor{NDIMS, ELTYPE}}()
+ next_wall_contact_id = Ref(1)
+ else
+ contact_tangential_displacement = nothing
+ wall_contact_descriptors = nothing
+ next_wall_contact_id = nothing
+ end
+
+ return (; contact_tangential_displacement, wall_contact_descriptors,
+ next_wall_contact_id)
+end
+
+@inline function requires_update_callback(system::RigidBodySystem)
+ return !isnothing(system.contact_model) &&
+ requires_update_callback(system.contact_model)
+end
+
+@inline function requires_update_callback(system::RigidBodySystem, semi)
+ contact_model = system.contact_model
+ isnothing(contact_model) && return false
+
+ # A model cannot decide this in isolation: rigid-rigid contact uses pair parameters and
+ # a normal-only neighbor can disable friction through the minimum-coefficient rule.
+ for neighbor_system in semi.systems
+ neighbor_system === system && continue
+ has_system_interaction(system, neighbor_system, semi) || continue
+
+ if neighbor_system isa WallBoundarySystem
+ requires_update_callback(contact_model) && return true
+ elseif neighbor_system isa RigidBodySystem &&
+ !isnothing(neighbor_system.contact_model)
+ pair_parameters = rigid_contact_pair_parameters(contact_model,
+ neighbor_system.contact_model)
+ if has_tangential_contact(pair_parameters) &&
+ pair_parameters.tangential_stiffness > 0
+ return true
+ end
+ end
+ end
+
+ return false
+end
+
+@inline function normal_friction_reference_force(contact_model,
+ penetration, normal_velocity)
+ # `normal_velocity < 0` means approaching contact, so the dashpot contribution is
+ # positive while bodies approach. Clamping prevents an attractive contact force.
+ elastic_force = contact_model.normal_stiffness * penetration
+ damping_force = -contact_model.normal_damping * normal_velocity
+
+ return max(elastic_force + damping_force, zero(elastic_force))
+end
+
+function tangential_contact_force(contact_model,
+ tangential_displacement,
+ tangential_velocity,
+ normal_force_friction_reference)
+ # First evaluate the tangential spring-dashpot law. It represents sticking while its
+ # magnitude remains inside the static Coulomb cone.
+ force_trial = -contact_model.tangential_stiffness * tangential_displacement -
+ contact_model.tangential_damping * tangential_velocity
+
+ trial_norm = norm(force_trial)
+ static_limit = contact_model.static_friction_coefficient *
+ normal_force_friction_reference
+ if trial_norm <= static_limit
+ return force_trial
+ end
+
+ kinetic_limit = contact_model.kinetic_friction_coefficient *
+ normal_force_friction_reference
+ kinetic_limit <= zero(kinetic_limit) && return zero(force_trial)
+
+ tangential_speed = norm(tangential_velocity)
+
+ if tangential_speed > zero(tangential_speed)
+ # During slip, kinetic friction opposes current motion. `tanh` removes the force
+ # discontinuity at zero speed without introducing an eltype-dependent velocity scale.
+ regularization_velocity = contact_model.stick_velocity_tolerance
+ speed_factor = regularization_velocity > zero(regularization_velocity) ?
+ tanh(tangential_speed / regularization_velocity) :
+ one(tangential_speed)
+ return -kinetic_limit * speed_factor * tangential_velocity / tangential_speed
+ end
+
+ if trial_norm > zero(trial_norm)
+ # At exactly zero slip speed there is no velocity direction. Preserve the restoring
+ # direction of the trial force instead of reversing it.
+ return kinetic_limit * force_trial / trial_norm
+ end
+
+ return zero(force_trial)
+end
+
+update_rigid_contact_eachstep!(system, v_ode, u_ode, semi, t, history_dt) = false
+
+# Advance persistent contact state once after an accepted step. The Boolean return reports
+# whether the force law changed, allowing the callback to invalidate an FSAL derivative only
+# when necessary.
+function update_rigid_contact_eachstep!(system::RigidBodySystem{<:Any, <:Any, NDIMS},
+ v_ode, u_ode, semi, t, history_dt) where {NDIMS}
+ requires_update_callback(system, semi) || return false
+
+ v_system = wrap_v(v_ode, system, semi)
+ u_system = wrap_u(u_ode, system, semi)
+ active_contact_keys = Set{RigidContactKey}()
+ history_changed = false
+
+ foreach_system(semi) do neighbor_system
+ neighbor_system === system && return
+ has_system_interaction(system, neighbor_system, semi) || return
+ history_changed |= update_contact_history_pair!(system, neighbor_system,
+ v_system, u_system,
+ v_ode, u_ode, semi, history_dt,
+ active_contact_keys)
+ end
+
+ contact_map = system.cache.contact_tangential_displacement
+ # A key not rediscovered at the accepted endpoint no longer represents an active contact.
+ # Removing it prevents stale static-friction memory from reappearing after separation.
+ for key in collect(keys(contact_map))
+ key in active_contact_keys && continue
+ delete!(contact_map, key)
+ history_changed = true
+ end
+
+ descriptor_map = system.cache.wall_contact_descriptors
+ for key in collect(keys(descriptor_map))
+ key in active_contact_keys && continue
+ delete!(descriptor_map, key)
+ end
+
+ return history_changed
+end
+
+function update_contact_history_pair!(system, neighbor_system, v_system, u_system, v_ode,
+ u_ode,
+ semi, dt, active_contact_keys)
+ return false
+end
+
+function update_contact_history_pair!(system::RigidBodySystem{<:Any, <:Any, NDIMS},
+ neighbor_system::WallBoundarySystem,
+ v_system, u_system,
+ v_ode, u_ode,
+ semi, dt,
+ active_contact_keys) where {NDIMS}
+ contact_model = system.contact_model
+ isnothing(contact_model) && return false
+
+ history_changed = false
+
+ # Rebuild exactly the same transient manifolds used by the RHS, now at the accepted
+ # endpoint. Only this callback pass is allowed to update persistent descriptors.
+ set_zero!(system.cache.contact_manifold_count)
+ set_zero!(system.cache.contact_manifold_weight_sum)
+ set_zero!(system.cache.contact_manifold_penetration_sum)
+ set_zero!(system.cache.contact_manifold_normal_sum)
+ set_zero!(system.cache.contact_manifold_wall_velocity_sum)
+ set_zero!(system.cache.contact_manifold_wall_position_sum)
+ set_zero!(system.cache.contact_manifold_history_id)
+
+ v_neighbor = wrap_v(v_ode, neighbor_system, semi)
+ u_neighbor = wrap_u(u_ode, neighbor_system, semi)
+ system_coords = current_coordinates(u_system, system)
+ neighbor_coords = current_coordinates(u_neighbor, neighbor_system)
+
+ foreach_point_neighbor(system, neighbor_system, system_coords, neighbor_coords, semi;
+ points=each_integrated_particle(system),
+ parallelization_backend=SerialBackend()) do particle, neighbor,
+ pos_diff, distance
+ accumulate_wall_contact_pair!(system, v_neighbor, u_neighbor, neighbor_system,
+ particle, neighbor, pos_diff, distance,
+ contact_model)
+ end
+
+ neighbor_system_index = system_indices(neighbor_system, semi)
+ match_wall_contact_manifolds!(system, neighbor_system_index, contact_model;
+ update_descriptors=true)
+ ELTYPE = eltype(system)
+ zero_tangential = zero(SVector{NDIMS, ELTYPE})
+
+ for particle in each_integrated_particle(system)
+ n_manifolds = system.cache.contact_manifold_count[particle]
+ n_manifolds == 0 && continue
+
+ particle_velocity = current_velocity(v_system, system, particle)
+
+ for manifold_index in 1:n_manifolds
+ weight_sum = system.cache.contact_manifold_weight_sum[manifold_index, particle]
+ weight_sum <= eps(ELTYPE) && continue
+
+ normal = extract_svector(system.cache.contact_manifold_normal_sum, Val(NDIMS),
+ manifold_index, particle) / weight_sum
+ normal_norm = norm(normal)
+ normal_norm <= eps(ELTYPE) && continue
+ normal /= normal_norm
+
+ wall_velocity = extract_svector(system.cache.contact_manifold_wall_velocity_sum,
+ Val(NDIMS), manifold_index, particle) /
+ weight_sum
+ penetration_effective = system.cache.contact_manifold_penetration_sum[manifold_index,
+ particle] /
+ weight_sum
+ relative_velocity = particle_velocity - wall_velocity
+ normal_velocity = dot(relative_velocity, normal)
+ tangential_velocity = relative_velocity - normal_velocity * normal
+
+ contact_id = system.cache.contact_manifold_history_id[manifold_index, particle]
+ contact_id == 0 && continue
+ contact_key = wall_contact_key(neighbor_system_index, particle, contact_id)
+ push!(active_contact_keys, contact_key)
+ history_changed |= update_contact_tangential_history!(system, contact_key,
+ tangential_velocity,
+ normal,
+ penetration_effective,
+ normal_velocity, dt,
+ contact_model,
+ zero_tangential)
+ end
+ end
+
+ return history_changed
+end
+
+function match_wall_contact_manifolds!(system::RigidBodySystem{<:Any, <:Any, NDIMS},
+ neighbor_system_index,
+ contact_model;
+ update_descriptors) where {NDIMS}
+ descriptor_map = system.cache.wall_contact_descriptors
+ isnothing(descriptor_map) && return system
+
+ ELTYPE = eltype(system)
+ normal_match_cos = convert(ELTYPE, 0.5)
+ anchor_match_distance = contact_model.contact_distance
+ history_ids = system.cache.contact_manifold_history_id
+
+ # Manifold slots depend on wall-neighbor traversal order and therefore cannot identify a
+ # physical contact across steps. Match each current manifold one-to-one against accepted
+ # descriptors for the same rigid particle and wall system. A candidate must remain within
+ # one contact distance and within 60 degrees of the accepted normal. The score balances
+ # normal alignment against normalized anchor distance; the ID breaks ties deterministically.
+ for particle in each_integrated_particle(system)
+ n_manifolds = system.cache.contact_manifold_count[particle]
+ for manifold_index in 1:n_manifolds
+ weight_sum = system.cache.contact_manifold_weight_sum[manifold_index, particle]
+ weight_sum <= eps(ELTYPE) && continue
+
+ normal = extract_svector(system.cache.contact_manifold_normal_sum, Val(NDIMS),
+ manifold_index, particle) / weight_sum
+ normal_norm = norm(normal)
+ normal_norm <= eps(ELTYPE) && continue
+ normal /= normal_norm
+ anchor = extract_svector(system.cache.contact_manifold_wall_position_sum,
+ Val(NDIMS), manifold_index, particle) / weight_sum
+
+ best_key = nothing
+ best_score = -typemax(ELTYPE)
+ for (key, descriptor) in descriptor_map
+ key.neighbor_system_index == neighbor_system_index || continue
+ key.local_particle == particle || continue
+
+ already_matched = false
+ for previous_manifold in 1:(manifold_index - 1)
+ if history_ids[previous_manifold, particle] == key.contact_slot
+ already_matched = true
+ break
+ end
+ end
+ already_matched && continue
+
+ normal_alignment = dot(normal, descriptor.normal)
+ normal_alignment >= normal_match_cos || continue
+ anchor_distance = norm(anchor - descriptor.anchor)
+ anchor_distance <= anchor_match_distance || continue
+
+ score = normal_alignment - anchor_distance / anchor_match_distance
+ if score > best_score ||
+ (score == best_score &&
+ (isnothing(best_key) || key.contact_slot < best_key.contact_slot))
+ best_key = key
+ best_score = score
+ end
+ end
+
+ if isnothing(best_key)
+ # RHS evaluations are read-only (`update_descriptors=false`): an intermediate
+ # Runge-Kutta stage must never create accepted-step history. The callback
+ # allocates IDs monotonically so a new contact cannot inherit stale memory.
+ update_descriptors || continue
+ contact_id = system.cache.next_wall_contact_id[]
+ system.cache.next_wall_contact_id[] += 1
+ best_key = wall_contact_key(neighbor_system_index, particle, contact_id)
+ end
+
+ history_ids[manifold_index, particle] = best_key.contact_slot
+ if update_descriptors
+ descriptor_map[best_key] = WallContactDescriptor(anchor, normal)
+ end
+ end
+ end
+
+ return system
+end
+
+function update_contact_history_pair!(system::RigidBodySystem{<:Any, <:Any, NDIMS},
+ neighbor_system::RigidBodySystem,
+ v_system, u_system,
+ v_ode, u_ode,
+ semi, dt,
+ active_contact_keys) where {NDIMS}
+ contact_model = system.contact_model
+ neighbor_contact_model = neighbor_system.contact_model
+ if isnothing(contact_model) || isnothing(neighbor_contact_model)
+ return false
+ end
+
+ pair_parameters = rigid_contact_pair_parameters(contact_model, neighbor_contact_model)
+ # Tangential damping is instantaneous. Only a nonzero pair spring needs displacement
+ # history and therefore work in the accepted-step callback.
+ if !has_tangential_contact(pair_parameters) ||
+ pair_parameters.tangential_stiffness <= 0
+ return false
+ end
+
+ history_changed = false
+
+ v_neighbor = wrap_v(v_ode, neighbor_system, semi)
+ u_neighbor = wrap_u(u_ode, neighbor_system, semi)
+ system_coords = current_coordinates(u_system, system)
+ neighbor_coords = current_coordinates(u_neighbor, neighbor_system)
+
+ neighbor_system_index = system_indices(neighbor_system, semi)
+ ELTYPE = eltype(system)
+ zero_tangential = zero(SVector{NDIMS, ELTYPE})
+
+ foreach_point_neighbor(system, neighbor_system, system_coords, neighbor_coords, semi;
+ points=each_integrated_particle(system),
+ parallelization_backend=SerialBackend()) do particle, neighbor,
+ pos_diff, distance
+ distance <= eps(ELTYPE) && return
+
+ penetration = pair_parameters.contact_distance - distance
+ penetration_effective = penetration - pair_parameters.penetration_slop
+ penetration_effective <= 0 && return
+
+ normal = pos_diff / distance
+ particle_velocity = current_velocity(v_system, system, particle)
+ neighbor_velocity = current_velocity(v_neighbor, neighbor_system, neighbor)
+ relative_velocity = particle_velocity - neighbor_velocity
+ normal_velocity = dot(relative_velocity, normal)
+ tangential_velocity = relative_velocity - normal_velocity * normal
+
+ contact_key = rigid_rigid_contact_key(neighbor_system_index, particle, neighbor)
+ push!(active_contact_keys, contact_key)
+ history_changed |= update_contact_tangential_history!(system, contact_key,
+ tangential_velocity,
+ normal,
+ penetration_effective,
+ normal_velocity, dt,
+ pair_parameters,
+ zero_tangential)
+ end
+
+ return history_changed
+end
+
+function update_contact_tangential_history!(system::RigidBodySystem, contact_key,
+ tangential_velocity, normal,
+ penetration_effective, normal_velocity, dt,
+ contact_model,
+ zero_tangential)
+ contact_map = system.cache.contact_tangential_displacement
+ isnothing(contact_map) && return false
+
+ dt_ = isfinite(dt) && dt > 0 ? convert(eltype(system), dt) : zero(eltype(system))
+ old_tangential_displacement = get(contact_map, contact_key, zero_tangential)
+ tangential_displacement = old_tangential_displacement
+
+ # Integrate only accepted-step slip, then rotate old history into the current contact
+ # plane. Initialization passes `dt == 0`, which registers contact identities without
+ # inventing displacement before the first accepted step.
+ tangential_displacement += dt_ * tangential_velocity
+ tangential_displacement -= dot(tangential_displacement, normal) * normal
+
+ if contact_model.tangential_stiffness > eps(eltype(system))
+ # Cap stored spring extension at the static Coulomb limit. This keeps history
+ # consistent with the force returned by `tangential_contact_force` after sliding.
+ normal_force_reference = normal_friction_reference_force(contact_model,
+ penetration_effective,
+ normal_velocity)
+ max_displacement = contact_model.static_friction_coefficient *
+ normal_force_reference /
+ contact_model.tangential_stiffness
+ displacement_norm = norm(tangential_displacement)
+
+ if displacement_norm > max_displacement &&
+ displacement_norm > eps(eltype(system))
+ tangential_displacement *= max_displacement / displacement_norm
+ end
+ else
+ tangential_displacement = zero_tangential
+ end
+
+ contact_map[contact_key] = tangential_displacement
+
+ return tangential_displacement != old_tangential_displacement
+end
diff --git a/src/schemes/structure/rigid_body/contact_models.jl b/src/schemes/structure/rigid_body/contact_models.jl
index 0a91d50f90..d9579cc292 100644
--- a/src/schemes/structure/rigid_body/contact_models.jl
+++ b/src/schemes/structure/rigid_body/contact_models.jl
@@ -1,14 +1,91 @@
abstract type AbstractRigidContactModel end
+@enum RigidContactKind::UInt8 begin
+ WallContact = 1
+ RigidRigidContact = 2
+end
+
+"""
+ RigidContactKey(neighbor_system_index, local_particle, contact_slot, contact_kind)
+
+Shared tangential-history key for rigid contact.
+
+`contact_slot` stores a persistent wall-contact ID for rigid-wall contact and the neighbor
+particle index for rigid-rigid contact.
+"""
+struct RigidContactKey
+ neighbor_system_index::Int
+ local_particle::Int
+ contact_slot::Int
+ contact_kind::RigidContactKind
+end
+
+# Accepted-step geometry used to reconnect a transient wall manifold to its history key.
+# The anchor is the weighted wall position of the manifold, not the rigid-particle position.
+struct WallContactDescriptor{NDIMS, ELTYPE}
+ anchor::SVector{NDIMS, ELTYPE}
+ normal::SVector{NDIMS, ELTYPE}
+end
+
+@inline wall_contact_key(neighbor_system_index, local_particle,
+ contact_id) = RigidContactKey(neighbor_system_index,
+ local_particle, contact_id,
+ WallContact)
+
+@inline rigid_rigid_contact_key(neighbor_system_index, local_particle,
+ neighbor_particle) = RigidContactKey(neighbor_system_index,
+ local_particle,
+ neighbor_particle,
+ RigidRigidContact)
+
+@inline function Base.:(==)(lhs::RigidContactKey, rhs::RigidContactKey)
+ return lhs.neighbor_system_index == rhs.neighbor_system_index &&
+ lhs.local_particle == rhs.local_particle &&
+ lhs.contact_slot == rhs.contact_slot &&
+ lhs.contact_kind == rhs.contact_kind
+end
+
+@inline Base.isequal(lhs::RigidContactKey, rhs::RigidContactKey) = lhs == rhs
+
+@inline function Base.hash(key::RigidContactKey, h::UInt)
+ h = hash(key.neighbor_system_index, h)
+ h = hash(key.local_particle, h)
+ h = hash(key.contact_slot, h)
+ h = hash(key.contact_kind, h)
+ return h
+end
+
"""
RigidContactModel(; normal_stiffness,
normal_damping=0.0,
- contact_distance=0.0)
+ static_friction_coefficient=nothing,
+ kinetic_friction_coefficient=nothing,
+ tangential_stiffness=nothing,
+ tangential_damping=nothing,
+ contact_distance=0.0,
+ stick_velocity_tolerance=nothing,
+ penetration_slop=nothing)
Shared rigid-contact model used by the active rigid-wall and rigid-rigid contact paths.
-The current contact force consists of a linear normal spring-dashpot contribution only.
+Both contact paths combine the linear normal spring-dashpot law with tangential friction.
+Tangential spring history is updated through `UpdateCallback`.
+Positive friction coefficients require positive tangential stiffness or damping.
+
+# Keywords
+- `normal_stiffness`: Stiffness of the linear normal spring.
+- `normal_damping`: Damping coefficient in the normal relative-velocity direction.
+- `static_friction_coefficient`: Coulomb limit for the trial tangential force.
+- `kinetic_friction_coefficient`: Coulomb limit after the static limit is exceeded.
+- `tangential_stiffness`: Stiffness of the history-dependent tangential spring.
+- `tangential_damping`: Damping coefficient in the tangential relative-velocity direction.
+- `contact_distance`: Maximum particle separation at which contact is active.
+- `stick_velocity_tolerance`: Velocity scale used to regularize kinetic friction near zero
+ slip speed. Set it to zero to disable regularization.
+- `penetration_slop`: Penetration ignored before the contact law is applied.
+
If `contact_distance == 0`, the particle spacing of the `RigidBodySystem` will be used
-as contact distance.
+as contact distance when the model is adapted via
+`copy_contact_model(model, particle_spacing, ELTYPE)`.
!!! warning "Experimental implementation"
This is an experimental feature and may change in future releases.
@@ -16,28 +93,135 @@ as contact distance.
struct RigidContactModel{ELTYPE <: Real} <: AbstractRigidContactModel
normal_stiffness::ELTYPE
normal_damping::ELTYPE
+ static_friction_coefficient::ELTYPE
+ kinetic_friction_coefficient::ELTYPE
+ tangential_stiffness::ELTYPE
+ tangential_damping::ELTYPE
contact_distance::ELTYPE
+ stick_velocity_tolerance::ELTYPE
+ penetration_slop::ELTYPE
end
function RigidContactModel(; normal_stiffness,
normal_damping=0.0,
- contact_distance=0.0)
+ static_friction_coefficient=nothing,
+ kinetic_friction_coefficient=nothing,
+ tangential_stiffness=nothing,
+ tangential_damping=nothing,
+ contact_distance=0.0,
+ stick_velocity_tolerance=nothing,
+ penetration_slop=nothing)
+ tangential_mode = !isnothing(static_friction_coefficient) ||
+ !isnothing(kinetic_friction_coefficient) ||
+ !isnothing(tangential_stiffness) ||
+ !isnothing(tangential_damping)
+
+ static_friction_coefficient = something(static_friction_coefficient,
+ tangential_mode ? 0.5 : 0.0)
+ kinetic_friction_coefficient = something(kinetic_friction_coefficient,
+ tangential_mode ? 0.4 : 0.0)
+ tangential_stiffness = something(tangential_stiffness, 0.0)
+ tangential_damping = something(tangential_damping, 0.0)
+ stick_velocity_tolerance = something(stick_velocity_tolerance, 1.0e-6)
+ penetration_slop = something(penetration_slop, 0.0)
ELTYPE = promote_type(typeof(normal_stiffness),
typeof(normal_damping),
- typeof(contact_distance))
+ typeof(static_friction_coefficient),
+ typeof(kinetic_friction_coefficient),
+ typeof(tangential_stiffness),
+ typeof(tangential_damping),
+ typeof(contact_distance),
+ typeof(stick_velocity_tolerance),
+ typeof(penetration_slop))
normal_stiffness_ = convert(ELTYPE, normal_stiffness)
normal_damping_ = convert(ELTYPE, normal_damping)
+ static_friction_coefficient_ = convert(ELTYPE, static_friction_coefficient)
+ kinetic_friction_coefficient_ = convert(ELTYPE, kinetic_friction_coefficient)
+ tangential_stiffness_ = convert(ELTYPE, tangential_stiffness)
+ tangential_damping_ = convert(ELTYPE, tangential_damping)
contact_distance_ = convert(ELTYPE, contact_distance)
+ stick_velocity_tolerance_ = convert(ELTYPE, stick_velocity_tolerance)
+ penetration_slop_ = convert(ELTYPE, penetration_slop)
normal_stiffness_ > 0 ||
throw(ArgumentError("`normal_stiffness` must be positive"))
normal_damping_ >= 0 ||
throw(ArgumentError("`normal_damping` must be non-negative"))
+ static_friction_coefficient_ >= 0 ||
+ throw(ArgumentError("`static_friction_coefficient` must be non-negative"))
+ kinetic_friction_coefficient_ >= 0 ||
+ throw(ArgumentError("`kinetic_friction_coefficient` must be non-negative"))
+ kinetic_friction_coefficient_ <= static_friction_coefficient_ ||
+ throw(ArgumentError("`kinetic_friction_coefficient` must be <= `static_friction_coefficient`"))
+ tangential_stiffness_ >= 0 ||
+ throw(ArgumentError("`tangential_stiffness` must be non-negative"))
+ tangential_damping_ >= 0 ||
+ throw(ArgumentError("`tangential_damping` must be non-negative"))
contact_distance_ >= 0 ||
throw(ArgumentError("`contact_distance` must be non-negative"))
+ stick_velocity_tolerance_ >= 0 ||
+ throw(ArgumentError("`stick_velocity_tolerance` must be non-negative"))
+ penetration_slop_ >= 0 ||
+ throw(ArgumentError("`penetration_slop` must be non-negative"))
+
+ tangential_response = tangential_stiffness_ > 0 || tangential_damping_ > 0
+ friction_enabled = static_friction_coefficient_ > 0
+ if tangential_mode && friction_enabled && !tangential_response
+ throw(ArgumentError("positive friction coefficients require positive " *
+ "`tangential_stiffness` or `tangential_damping`"))
+ end
+ if tangential_response && !friction_enabled
+ throw(ArgumentError("positive tangential stiffness or damping requires a positive " *
+ "`static_friction_coefficient`"))
+ end
+
+ return RigidContactModel(normal_stiffness_, normal_damping_,
+ static_friction_coefficient_,
+ kinetic_friction_coefficient_,
+ tangential_stiffness_,
+ tangential_damping_,
+ contact_distance_,
+ stick_velocity_tolerance_,
+ penetration_slop_)
+end
+
+@inline function has_tangential_contact(contact_model::RigidContactModel)
+ return contact_model.static_friction_coefficient > 0 &&
+ (contact_model.tangential_stiffness > 0 ||
+ contact_model.tangential_damping > 0)
+end
+
+@inline function rigid_contact_pair_parameters(contact_model::RigidContactModel,
+ neighbor_contact_model::RigidContactModel)
+ # Both ordered rigid-rigid interaction passes must evaluate exactly the same law for
+ # action-reaction symmetry. Conservative limits are used for unilateral parameters;
+ # stiffness and damping are arithmetic means because neither body owns the pair law.
+ return (;
+ normal_stiffness=(contact_model.normal_stiffness +
+ neighbor_contact_model.normal_stiffness) / 2,
+ normal_damping=(contact_model.normal_damping +
+ neighbor_contact_model.normal_damping) / 2,
+ static_friction_coefficient=min(contact_model.static_friction_coefficient,
+ neighbor_contact_model.static_friction_coefficient),
+ kinetic_friction_coefficient=min(contact_model.kinetic_friction_coefficient,
+ neighbor_contact_model.kinetic_friction_coefficient),
+ tangential_stiffness=(contact_model.tangential_stiffness +
+ neighbor_contact_model.tangential_stiffness) / 2,
+ tangential_damping=(contact_model.tangential_damping +
+ neighbor_contact_model.tangential_damping) / 2,
+ contact_distance=max(contact_model.contact_distance,
+ neighbor_contact_model.contact_distance),
+ stick_velocity_tolerance=max(contact_model.stick_velocity_tolerance,
+ neighbor_contact_model.stick_velocity_tolerance),
+ penetration_slop=max(contact_model.penetration_slop,
+ neighbor_contact_model.penetration_slop))
+end
- return RigidContactModel(normal_stiffness_, normal_damping_, contact_distance_)
+@inline function has_tangential_contact(contact_parameters::NamedTuple)
+ return contact_parameters.static_friction_coefficient > 0 &&
+ (contact_parameters.tangential_stiffness > 0 ||
+ contact_parameters.tangential_damping > 0)
end
function copy_contact_model(model::RigidContactModel, particle_spacing,
@@ -52,7 +236,18 @@ function copy_contact_model(model::RigidContactModel, particle_spacing,
return RigidContactModel(; normal_stiffness=convert(ELTYPE, model.normal_stiffness),
normal_damping=convert(ELTYPE, model.normal_damping),
- contact_distance)
+ static_friction_coefficient=convert(ELTYPE,
+ model.static_friction_coefficient),
+ kinetic_friction_coefficient=convert(ELTYPE,
+ model.kinetic_friction_coefficient),
+ tangential_stiffness=convert(ELTYPE,
+ model.tangential_stiffness),
+ tangential_damping=convert(ELTYPE,
+ model.tangential_damping),
+ contact_distance,
+ stick_velocity_tolerance=convert(ELTYPE,
+ model.stick_velocity_tolerance),
+ penetration_slop=convert(ELTYPE, model.penetration_slop))
end
# Single-body rigid-contact scale.
@@ -73,7 +268,22 @@ end
system::RigidBodySystem)
# A wall is treated as an infinite-mass contact partner, so the reduced mass collapses
# to the mass of the rigid body particle itself.
- return sqrt(minimum(system.mass) / contact_model.normal_stiffness)
+ return contact_time_step(contact_model, minimum(system.mass))
+end
+
+@inline function contact_time_step(contact_parameters, effective_mass::Real)
+ # Spring modes scale as sqrt(m/k), while dashpot modes scale as m/c. Returning the
+ # smallest active scale lets the caller apply the usual global CFL factor once.
+ normal_elastic = sqrt(effective_mass / contact_parameters.normal_stiffness)
+ normal_damping = contact_parameters.normal_damping > 0 ?
+ effective_mass / contact_parameters.normal_damping : Inf
+ tangential_elastic = contact_parameters.tangential_stiffness > 0 ?
+ sqrt(effective_mass /
+ contact_parameters.tangential_stiffness) : Inf
+ tangential_damping = contact_parameters.tangential_damping > 0 ?
+ effective_mass / contact_parameters.tangential_damping : Inf
+
+ return min(normal_elastic, normal_damping, tangential_elastic, tangential_damping)
end
@inline function contact_time_step(system::RigidBodySystem,
@@ -84,18 +294,16 @@ end
contact_model = system.contact_model::RigidContactModel
neighbor_contact_model = neighbor.contact_model::RigidContactModel
- # For rigid-rigid contact, use one symmetric pair stiffness and the reduced mass of the
- # lightest contact-carrying particles of both bodies. This makes the estimate invariant
- # under swapping `system` and `neighbor`.
- pair_normal_stiffness = (contact_model.normal_stiffness +
- neighbor_contact_model.normal_stiffness) / 2
+ # Use symmetric pair parameters and the reduced mass of the lightest contact-carrying
+ # particles of both bodies. This makes the estimate invariant under swapping the systems.
+ pair_parameters = rigid_contact_pair_parameters(contact_model, neighbor_contact_model)
system_min_mass = minimum(system.mass)
neighbor_min_mass = minimum(neighbor.mass)
reduced_mass = system_min_mass * neighbor_min_mass /
(system_min_mass + neighbor_min_mass)
- return sqrt(reduced_mass / pair_normal_stiffness)
+ return contact_time_step(pair_parameters, reduced_mass)
end
@inline function contact_time_step(system::RigidBodySystem,
@@ -111,6 +319,12 @@ function Base.show(io::IO, model::RigidContactModel)
print(io, "RigidContactModel(")
print(io, "normal_stiffness=", model.normal_stiffness)
print(io, ", normal_damping=", model.normal_damping)
+ print(io, ", static_friction_coefficient=", model.static_friction_coefficient)
+ print(io, ", kinetic_friction_coefficient=", model.kinetic_friction_coefficient)
+ print(io, ", tangential_stiffness=", model.tangential_stiffness)
+ print(io, ", tangential_damping=", model.tangential_damping)
print(io, ", contact_distance=", model.contact_distance)
+ print(io, ", stick_velocity_tolerance=", model.stick_velocity_tolerance)
+ print(io, ", penetration_slop=", model.penetration_slop)
print(io, ")")
end
diff --git a/src/schemes/structure/rigid_body/rhs.jl b/src/schemes/structure/rigid_body/rhs.jl
index 48fc8d26c8..6c4541198d 100644
--- a/src/schemes/structure/rigid_body/rhs.jl
+++ b/src/schemes/structure/rigid_body/rhs.jl
@@ -105,9 +105,14 @@ function interact!(dv, v_particle_system, u_particle_system,
set_zero!(particle_system.cache.contact_manifold_penetration_sum)
set_zero!(particle_system.cache.contact_manifold_normal_sum)
set_zero!(particle_system.cache.contact_manifold_wall_velocity_sum)
+ set_zero!(particle_system.cache.contact_manifold_wall_position_sum)
+ set_zero!(particle_system.cache.contact_manifold_history_id)
NDIMS = ndims(particle_system)
ELTYPE = eltype(particle_system)
+ zero_tangential = zero(SVector{NDIMS, ELTYPE})
+ contact_map = particle_system.cache.contact_tangential_displacement
+ neighbor_system_index = system_indices(neighbor_system, semi)
set_zero!(particle_system.cache.contact_count_per_particle)
set_zero!(particle_system.cache.max_contact_penetration_per_particle)
contact_count_per_particle = particle_system.cache.contact_count_per_particle
@@ -125,10 +130,16 @@ function interact!(dv, v_particle_system, u_particle_system,
# Building manifolds mutates shared cache entries for the current rigid particle and can
# merge a new wall sample into an existing manifold. Keep this pass serial so manifold
# assignment stays deterministic and free of synchronization overhead.
- accumulate_wall_contact_pair!(particle_system, v_neighbor_system, neighbor_system,
+ accumulate_wall_contact_pair!(particle_system, v_neighbor_system,
+ u_neighbor_system, neighbor_system,
particle, neighbor, pos_diff, distance, contact_model)
end
+ # Resolve transient slots against accepted-step IDs without updating descriptors. RHS
+ # evaluations include rejected and intermediate stages and must not mutate history.
+ match_wall_contact_manifolds!(particle_system, neighbor_system_index, contact_model;
+ update_descriptors=false)
+
# Apply one force contribution per manifold using the averaged normal, penetration, and
# wall velocity stored in the cache.
@threaded semi for particle in each_integrated_particle(particle_system)
@@ -165,15 +176,28 @@ function interact!(dv, v_particle_system, u_particle_system,
relative_velocity = v_particle - v_boundary
normal_velocity = dot(relative_velocity, normal)
-
- elastic_force = contact_model.normal_stiffness *
- penetration_effective
- damping_force = -contact_model.normal_damping * normal_velocity
- normal_force_magnitude = max(elastic_force + damping_force,
- zero(ELTYPE))
+ tangential_velocity = relative_velocity - normal_velocity * normal
+ normal_force_magnitude = normal_friction_reference_force(contact_model,
+ penetration_effective,
+ normal_velocity)
if normal_force_magnitude > 0
- interaction_force = normal_force_magnitude * normal
+ contact_id = particle_system.cache.contact_manifold_history_id[manifold_index,
+ particle]
+ tangential_displacement = isnothing(contact_map) ||
+ contact_id == 0 ?
+ zero_tangential :
+ get(contact_map,
+ wall_contact_key(neighbor_system_index,
+ particle,
+ contact_id),
+ zero_tangential)
+ tangential_force = tangential_contact_force(contact_model,
+ tangential_displacement,
+ tangential_velocity,
+ normal_force_magnitude)
+ interaction_force = normal_force_magnitude * normal +
+ tangential_force
for dim in eachindex(interaction_force)
particle_system.force_per_particle[dim,
@@ -209,6 +233,7 @@ end
# `contact_distance`.
@inline function accumulate_wall_contact_pair!(particle_system::RigidBodySystem,
v_neighbor_system,
+ u_neighbor_system,
neighbor_system::WallBoundarySystem,
particle, neighbor, pos_diff, distance,
contact_model::RigidContactModel)
@@ -216,10 +241,12 @@ end
distance <= eps(ELTYPE) && return particle_system
penetration = contact_model.contact_distance - distance
- penetration <= 0 && return particle_system
+ penetration_effective = penetration - contact_model.penetration_slop
+ penetration_effective <= 0 && return particle_system
normal = pos_diff / distance
wall_velocity = current_velocity(v_neighbor_system, neighbor_system, neighbor)
+ wall_position = current_coords(u_neighbor_system, neighbor_system, neighbor)
density = convert(ELTYPE, neighbor_system.initial_condition.density[neighbor])
density <= eps(ELTYPE) && return particle_system
@@ -240,7 +267,8 @@ end
normal,
normal_merge_cos)
accumulate_contact_manifold_sums!(particle_system.cache, particle, manifold_index,
- contact_weight, normal, wall_velocity, penetration)
+ contact_weight, normal, wall_velocity, wall_position,
+ penetration_effective)
return particle_system
end
@@ -308,7 +336,8 @@ end
# later divides by `weight_sum` once to recover the effective manifold normal, wall velocity,
# and penetration for that rigid particle / manifold pair.
function accumulate_contact_manifold_sums!(cache, particle, manifold_index, contact_weight,
- normal, wall_velocity, penetration_effective)
+ normal, wall_velocity, wall_position,
+ penetration_effective)
# Store weighted sums so the final interaction step can recover one averaged contact
# state per manifold instead of reacting to every wall particle individually. The summed
# data describes one effective contact patch: averaged normal, wall velocity, and
@@ -324,6 +353,9 @@ function accumulate_contact_manifold_sums!(cache, particle, manifold_index, cont
cache.contact_manifold_wall_velocity_sum[dim, manifold_index,
particle] += contact_weight *
wall_velocity[dim]
+ cache.contact_manifold_wall_position_sum[dim, manifold_index,
+ particle] += contact_weight *
+ wall_position[dim]
end
return cache
@@ -351,10 +383,10 @@ function interact!(dv, v_particle_system, u_particle_system,
set_zero!(particle_system.cache.max_contact_penetration_per_particle)
contact_count_per_particle = particle_system.cache.contact_count_per_particle
max_contact_penetration_per_particle = particle_system.cache.max_contact_penetration_per_particle
- pair_normal_stiffness = (contact_model.normal_stiffness +
- neighbor_contact_model.normal_stiffness) / 2
- pair_normal_damping = (contact_model.normal_damping +
- neighbor_contact_model.normal_damping) / 2
+ pair_parameters = rigid_contact_pair_parameters(contact_model, neighbor_contact_model)
+ zero_tangential = zero(SVector{ndims(particle_system), ELTYPE})
+ contact_map = particle_system.cache.contact_tangential_displacement
+ neighbor_system_index = system_indices(neighbor_system, semi)
foreach_point_neighbor(particle_system, neighbor_system, system_coords, neighbor_coords,
semi;
@@ -368,9 +400,9 @@ function interact!(dv, v_particle_system, u_particle_system,
# the regular parallel backend.
distance <= eps(ELTYPE) && return dv
- penetration = max(contact_model.contact_distance,
- neighbor_contact_model.contact_distance) - distance
- penetration <= 0 && return dv
+ penetration = pair_parameters.contact_distance - distance
+ penetration_effective = penetration - pair_parameters.penetration_slop
+ penetration_effective <= 0 && return dv
normal = pos_diff / distance
particle_velocity = current_velocity(v_particle_system, particle_system, particle)
@@ -378,12 +410,22 @@ function interact!(dv, v_particle_system, u_particle_system,
relative_velocity = particle_velocity - neighbor_velocity
normal_velocity = dot(relative_velocity, normal)
- elastic_force = pair_normal_stiffness * penetration
- damping_force = -pair_normal_damping * normal_velocity
+ elastic_force = pair_parameters.normal_stiffness * penetration_effective
+ damping_force = -pair_parameters.normal_damping * normal_velocity
normal_force_magnitude = max(elastic_force + damping_force, zero(ELTYPE))
normal_force_magnitude <= 0 && return dv
- interaction_force = normal_force_magnitude * normal
+ tangential_velocity = relative_velocity - normal_velocity * normal
+ contact_key = rigid_rigid_contact_key(neighbor_system_index, particle, neighbor)
+ # History belongs to this ordered particle pair. The reverse interaction pass stores
+ # the negated displacement under its own key and therefore produces the reaction force.
+ tangential_displacement = isnothing(contact_map) ? zero_tangential :
+ get(contact_map, contact_key, zero_tangential)
+ tangential_force = tangential_contact_force(pair_parameters,
+ tangential_displacement,
+ tangential_velocity,
+ normal_force_magnitude)
+ interaction_force = normal_force_magnitude * normal + tangential_force
for dim in 1:ndims(particle_system)
particle_system.force_per_particle[dim, particle] += interaction_force[dim]
@@ -393,7 +435,7 @@ function interact!(dv, v_particle_system, u_particle_system,
# This makes these per-particle reductions race-free under the regular backends.
contact_count_per_particle[particle] += 1
max_contact_penetration_per_particle[particle] = max(max_contact_penetration_per_particle[particle],
- penetration)
+ penetration_effective)
end
particle_system.cache.contact_count[] += sum(contact_count_per_particle)
diff --git a/src/schemes/structure/rigid_body/system.jl b/src/schemes/structure/rigid_body/system.jl
index e4b49cea88..ad53935fc9 100644
--- a/src/schemes/structure/rigid_body/system.jl
+++ b/src/schemes/structure/rigid_body/system.jl
@@ -20,11 +20,14 @@ torque and applied consistently to all rigid particles.
# Keywords
- `boundary_model`: Boundary model for fluid-structure interaction
(see [Boundary Models](@ref boundary_models)).
-- `contact_model`: Optional rigid contact model.
- If specified, rigid-wall and rigid-rigid collisions are enabled.
+- `contact_model`: Optional [`RigidContactModel`](@ref). If specified, rigid-wall and
+ rigid-rigid collisions are enabled. Tangential spring history requires
+ `UpdateCallback(interval=1)` and is currently CPU-only.
- `acceleration`: Global acceleration vector applied to all rigid particles.
- `particle_spacing`: Reference particle spacing used for time-step estimation.
-- `max_manifolds`: Maximum number of wall-contact manifolds cached per rigid particle.
+- `max_manifolds`: Maximum number of transient wall-contact manifolds assembled per rigid
+ particle. Persistent friction history is matched by geometry rather than
+ by these manifold slots.
- `source_terms`: Optional source terms of the form
`(coords, velocity, density, pressure, t) -> source`.
- `adhesion_coefficient`: Wall-adhesion strength used by Akinci-type surface tension
@@ -81,6 +84,12 @@ function RigidBodySystem(initial_condition; boundary_model=nothing,
throw(ArgumentError("`RigidBodySystem` currently supports only 2D and 3D, got $(NDIMS)D"))
end
+ if boundary_model isa BoundaryModelDummyParticles &&
+ !isnothing(boundary_model.correction)
+ throw(ArgumentError("corrections in `BoundaryModelDummyParticles` are not " *
+ "supported for `RigidBodySystem`"))
+ end
+
ELTYPE = eltype(initial_condition)
acceleration_ = SVector(acceleration...)
if length(acceleration_) != NDIMS
@@ -119,7 +128,8 @@ function RigidBodySystem(initial_condition; boundary_model=nothing,
inverse_inertia = Ref(zero(SMatrix{3, 3, ELTYPE, 9}))
end
- cache = (; contact_count=Ref(0),
+ cache = (; create_cache_contact_history(contact_model_, Val(NDIMS), ELTYPE)...,
+ contact_count=Ref(0),
max_contact_penetration=Ref(zero(ELTYPE)),
create_cache_contact_manifold(contact_model_, Val(NDIMS), ELTYPE,
nparticles(initial_condition),
@@ -150,12 +160,21 @@ function create_cache_contact_manifold(::Nothing, ::Val{NDIMS}, ELTYPE,
return (;)
end
+function create_cache_contact_history(contact_model, ::Val{NDIMS},
+ ::Type{ELTYPE}) where {NDIMS, ELTYPE}
+ return (; contact_tangential_displacement=nothing,
+ wall_contact_descriptors=nothing,
+ next_wall_contact_id=nothing)
+end
+
# Allocate per-particle scratch arrays for rigid contact.
#
# The manifold cache shape is `[dimension, manifold, particle]` for vector-valued sums and
# `[manifold, particle]` for scalar sums. It is rebuilt for each rigid-wall system pair in
# the RHS and therefore acts purely as transient manifold assembly storage. The per-particle
# diagnostic scratch is reused for both rigid-wall and rigid-rigid contact reductions.
+# `contact_manifold_history_id` temporarily links each assembled slot to accepted-step state;
+# the slot index itself is never used as a persistent identity.
function create_cache_contact_manifold(contact_model, ::Val{NDIMS}, ELTYPE,
n_particles, max_manifolds) where {NDIMS}
return (; contact_count_per_particle=zeros(Int, n_particles),
@@ -165,7 +184,10 @@ function create_cache_contact_manifold(contact_model, ::Val{NDIMS}, ELTYPE,
contact_manifold_penetration_sum=zeros(ELTYPE, max_manifolds, n_particles),
contact_manifold_normal_sum=zeros(ELTYPE, NDIMS, max_manifolds, n_particles),
contact_manifold_wall_velocity_sum=zeros(ELTYPE, NDIMS, max_manifolds,
- n_particles))
+ n_particles),
+ contact_manifold_wall_position_sum=zeros(ELTYPE, NDIMS, max_manifolds,
+ n_particles),
+ contact_manifold_history_id=zeros(Int, max_manifolds, n_particles))
end
function rigid_center_of_mass_kinematics(system::RigidBodySystem, coordinates, velocity)
@@ -266,11 +288,8 @@ end
return system.boundary_model.smoothing_kernel
end
-@inline function system_correction(system::RigidBodySystem{<:BoundaryModelDummyParticles})
- return system.boundary_model.correction
-end
-
function initialize!(system::RigidBodySystem, semi)
+ reset_contact_history!(system)
initialize_colorfield!(system, system.boundary_model, semi)
return system
end
@@ -348,6 +367,14 @@ function write_v0!(v0, ::BoundaryModelDummyParticles{ContinuityDensity},
end
function restart_with!(system::RigidBodySystem, v, u)
+ contact_map = system.cache.contact_tangential_displacement
+ if !isnothing(contact_map) && !isempty(contact_map)
+ # Restart files contain coordinates and velocities, but not the path-dependent
+ # tangential spring state. Make the resulting loss of static-friction memory visible.
+ @warn "tangential rigid-contact history is cleared when restarting"
+ end
+ reset_contact_history!(system)
+
indices_u = CartesianIndices(system.initial_condition.coordinates)
copyto!(system.initial_condition.coordinates, indices_u, u, indices_u)
@@ -385,6 +412,21 @@ function reset_interaction_caches!(system::RigidBodySystem)
return system
end
+function reset_contact_history!(system::RigidBodySystem)
+ # Clear all accepted-step state together. Resetting the ID counter is safe only here,
+ # after no descriptor or displacement key can still refer to an old contact.
+ contact_map = system.cache.contact_tangential_displacement
+ isnothing(contact_map) || empty!(contact_map)
+
+ descriptor_map = system.cache.wall_contact_descriptors
+ isnothing(descriptor_map) || empty!(descriptor_map)
+
+ next_contact_id = system.cache.next_wall_contact_id
+ isnothing(next_contact_id) || (next_contact_id[] = 1)
+
+ return system
+end
+
function update_final!(system::RigidBodySystem, v, u, v_ode, u_ode, semi, t)
system_coords = current_coordinates(u, system)
system_velocity = current_velocity(v, system)
diff --git a/src/schemes/structure/total_lagrangian_sph/system.jl b/src/schemes/structure/total_lagrangian_sph/system.jl
index 108b232c0b..2713bf2eb3 100644
--- a/src/schemes/structure/total_lagrangian_sph/system.jl
+++ b/src/schemes/structure/total_lagrangian_sph/system.jl
@@ -165,7 +165,8 @@ function TotalLagrangianSPHSystem(initial_condition; smoothing_kernel, smoothing
n_clamped_particles)
cache = (; create_cache_tlsph(clamped_particles_motion, initial_condition_sorted)...,
- create_cache_tlsph(velocity_averaging, initial_condition_sorted)...)
+ create_cache_tlsph(velocity_averaging, initial_condition_sorted)...,
+ create_cache_tlsph_boundary(boundary_model, initial_condition_sorted)...)
return TotalLagrangianSPHSystem(initial_condition_sorted, initial_coordinates,
current_coordinates, mass, correction_matrix,
@@ -261,6 +262,19 @@ function create_cache_tlsph(::VelocityAveraging, initial_condition)
return (; averaged_velocity, t_last_averaging)
end
+create_cache_tlsph_boundary(boundary_model, initial_condition) = (;)
+
+function create_cache_tlsph_boundary(boundary_model::BoundaryModelDummyParticles{SummationDensity},
+ initial_condition)
+ if correction_density(boundary_model.correction) isa ShepardKernelCorrection
+ # Keep the numerator separate until the global coefficient phase is complete so
+ # other systems never observe a partially updated TLSPH density.
+ return (; boundary_density_numerator=similar(boundary_model.cache.density))
+ end
+
+ return (;)
+end
+
@inline function requires_update_callback(system::TotalLagrangianSPHSystem, semi)
# Velocity averaging used and `integrate_tlsph == false` means that split integration
# is used and the averaged velocity is updated there.
@@ -370,10 +384,29 @@ end
return system.boundary_model.hydrodynamic_mass[particle]
end
-@propagate_inbounds function correction_matrix(system, particle)
+@propagate_inbounds function tlsph_correction_matrix(system, particle)
extract_smatrix(system.correction_matrix, system, particle)
end
+@inline function hydrodynamic_correction(system::TotalLagrangianSPHSystem{<:BoundaryModelDummyParticles})
+ return correction_gradient(system.boundary_model.correction)
+end
+
+@inline function kernel_correction_coefficient(system::TotalLagrangianSPHSystem{<:BoundaryModelDummyParticles},
+ particle)
+ return system.boundary_model.cache.kernel_correction_coefficient[particle]
+end
+
+@inline function dw_gamma(system::TotalLagrangianSPHSystem{<:BoundaryModelDummyParticles},
+ particle)
+ return extract_svector(system.boundary_model.cache.dw_gamma, system, particle)
+end
+
+@inline function correction_matrix(system::TotalLagrangianSPHSystem{<:BoundaryModelDummyParticles},
+ particle)
+ return extract_smatrix(system.boundary_model.cache.correction_matrix, system, particle)
+end
+
@propagate_inbounds function deformation_gradient(system, particle)
extract_smatrix(system.deformation_grad, system, particle)
end
@@ -480,6 +513,63 @@ function update_quantities!(system::TotalLagrangianSPHSystem, v, u, v_ode, u_ode
return system
end
+function update_density_correction_values!(system::TotalLagrangianSPHSystem{<:BoundaryModelDummyParticles},
+ v, u, v_ode, u_ode, semi, t)
+ density_correction = correction_density(system.boundary_model.correction)
+ compute_tlsph_density_correction_values!(system.boundary_model, system,
+ density_correction, u, v_ode, u_ode, semi)
+
+ return system
+end
+
+@inline function compute_tlsph_density_correction_values!(boundary_model, system,
+ density_correction, u,
+ v_ode, u_ode, semi)
+ return compute_boundary_correction_values!(boundary_model, system, density_correction,
+ u, v_ode, u_ode, semi)
+end
+
+function compute_tlsph_density_correction_values!(boundary_model::BoundaryModelDummyParticles{SummationDensity},
+ system,
+ ::ShepardKernelCorrection, u,
+ v_ode, u_ode, semi)
+ # Assemble the missing summation numerator together with the Shepard coefficient to avoid
+ # a second neighbor traversal.
+ return compute_shepard_coeff!(system, current_coordinates(u, system), v_ode, u_ode,
+ semi,
+ boundary_model.cache.kernel_correction_coefficient,
+ system.cache.boundary_density_numerator)
+end
+
+function update_density_correction!(system::TotalLagrangianSPHSystem{<:BoundaryModelDummyParticles},
+ v, u, v_ode, u_ode, semi, t)
+ density_correction = correction_density(system.boundary_model.correction)
+ apply_tlsph_density_correction!(system.boundary_model, system, density_correction,
+ v, u, v_ode, u_ode, semi)
+
+ return system
+end
+
+@inline function apply_tlsph_density_correction!(boundary_model, system,
+ density_correction,
+ v, u, v_ode, u_ode, semi)
+ return update_density_correction!(boundary_model, system, v, u, v_ode, u_ode, semi)
+end
+
+function apply_tlsph_density_correction!(boundary_model::BoundaryModelDummyParticles{SummationDensity},
+ system, ::ShepardKernelCorrection,
+ v, u, v_ode, u_ode, semi)
+ (; density, kernel_correction_coefficient) = boundary_model.cache
+ density_numerator = system.cache.boundary_density_numerator
+
+ @threaded semi for particle in eachparticle(system)
+ @inbounds density[particle] = density_numerator[particle] /
+ kernel_correction_coefficient[particle]
+ end
+
+ return boundary_model
+end
+
function update_boundary_interpolation!(system::TotalLagrangianSPHSystem, v, u,
v_ode, u_ode, semi, t)
(; boundary_model) = system
@@ -488,6 +578,13 @@ function update_boundary_interpolation!(system::TotalLagrangianSPHSystem, v, u,
update_pressure!(boundary_model, system, v, u, v_ode, u_ode, semi)
end
+function update_gradient_correction!(system::TotalLagrangianSPHSystem{<:BoundaryModelDummyParticles},
+ v, u, v_ode, u_ode, semi, t)
+ update_gradient_correction!(system.boundary_model, system, v, u, v_ode, u_ode, semi)
+
+ return system
+end
+
@inline function compute_pk1_corrected!(system, semi)
(; deformation_grad, pk1_rho2, material_density) = system
@@ -496,7 +593,7 @@ end
@threaded semi for particle in eachparticle(system)
pk1_particle = @inbounds pk1_stress_tensor(system, particle)
pk1_particle_corrected = pk1_particle *
- @inbounds correction_matrix(system, particle)
+ @inbounds tlsph_correction_matrix(system, particle)
rho2_inv = 1 / @inbounds material_density[particle]^2
for j in 1:ndims(system), i in 1:ndims(system)
@@ -528,7 +625,7 @@ end
# We are looping over the particles of `system`, so it is guaranteed
# that `particle` is in bounds of `system`.
current_coords_a = @inbounds current_coords(system, particle)
- L_a = @inbounds correction_matrix(system, particle)
+ L_a = @inbounds tlsph_correction_matrix(system, particle)
# Accumulate the contributions over all neighbors before writing
# to `deformation_grad` to reduce the number of memory writes.
diff --git a/src/setups/complex_shape.jl b/src/setups/complex_shape.jl
index 6a78b412e2..d0da406f7d 100644
--- a/src/setups/complex_shape.jl
+++ b/src/setups/complex_shape.jl
@@ -52,6 +52,8 @@ function ComplexShape(geometry; particle_spacing, density,
throw(ArgumentError("`WindingNumberHormann` only supports 2D geometries"))
end
+ require_closed_geometry(geometry, "ComplexShape")
+
if grid_offset < 0.0
throw(ArgumentError("only a positive `grid_offset` is supported"))
end
@@ -91,8 +93,10 @@ of the geometry.
- `boundary_density`: Density of each boundary particle.
- `place_on_shell`: When `place_on_shell=true`, boundary particles will be placed
one particle spacing from the surface of the geometry.
- Otherwise when `place_on_shell=true` (simulating fluid particles),
+ Otherwise when `place_on_shell=false` (simulating fluid particles),
boundary particles will be placed half particle spacing away from the surface.
+ Thus, `boundary_thickness` must be at least one particle spacing
+ for `place_on_shell=true` and half a particle spacing otherwise.
# Examples
@@ -111,7 +115,7 @@ boundary_sampled = sample_boundary(signed_distance_field; boundary_density=1.0,
│ InitialCondition │
│ ════════════════ │
│ #dimensions: ……………………………………………… 2 │
-│ #particles: ………………………………………………… 889 │
+│ #particles: ………………………………………………… 677 │
│ particle spacing: ………………………………… 0.03 │
│ eltype: …………………………………………………………… Float64 │
│ coordinate eltype: ……………………………… Float64 │
@@ -133,10 +137,22 @@ function sample_boundary(signed_distance_field;
end
# Only keep the required part of the signed distance field
- distance_to_boundary = zero(particle_spacing)
- keep_indices = (distance_to_boundary .< distances .<= max_signed_distance)
+ distance_to_boundary = place_on_shell ? particle_spacing : particle_spacing / 2
+ if boundary_thickness < distance_to_boundary
+ throw(ArgumentError("`boundary_thickness` must be at least " *
+ "`particle_spacing` for `place_on_shell=true` and " *
+ "half `particle_spacing` for `place_on_shell=false`."))
+ end
+
+ keep_indices = (distance_to_boundary .<= distances .<= boundary_thickness)
+ boundary_positions = positions[keep_indices]
+
+ if isempty(boundary_positions)
+ throw(ArgumentError("No boundary particles were sampled. Increase " *
+ "`boundary_thickness` or generate a denser `SignedDistanceField`."))
+ end
- boundary_coordinates = stack(positions[keep_indices])
+ boundary_coordinates = stack(boundary_positions)
return InitialCondition(; coordinates=boundary_coordinates, density=boundary_density,
particle_spacing)
end
diff --git a/src/setups/extrude_geometry.jl b/src/setups/extrude_geometry.jl
index 460583c38e..43455678bb 100644
--- a/src/setups/extrude_geometry.jl
+++ b/src/setups/extrude_geometry.jl
@@ -87,6 +87,14 @@ shape = extrude_geometry(shape; direction, particle_spacing=0.1, n_extrude=4, de
function extrude_geometry(geometry; particle_spacing=-1, direction, n_extrude::Integer,
velocity=zeros(length(direction)), place_on_shell=false,
mass=nothing, density=nothing, pressure=0.0)
+ if all(iszero, direction)
+ throw(ArgumentError("`direction` needs to be non-zero"))
+ end
+
+ if n_extrude < 1
+ throw(ArgumentError("`n_extrude` needs to be positive"))
+ end
+
direction_ = normalize(direction)
NDIMS = length(direction_)
@@ -105,6 +113,11 @@ function extrude_geometry(geometry; particle_spacing=-1, direction, n_extrude::I
face_coords = sample_plane(geometry, particle_spacing; place_on_shell)
+ if size(face_coords, 1) != NDIMS
+ throw(ArgumentError("`direction` must be of length $(size(face_coords, 1)) " *
+ "for the sampled geometry"))
+ end
+
coords = (face_coords .+ i * particle_spacing * direction_ for i in 0:(n_extrude - 1))
# In this context, `stack` is faster than `hcat(coords...)`
@@ -235,6 +248,10 @@ end
function shift_plane_corners(plane_points::NTuple{2}, direction, particle_spacing,
place_on_shell)
+ if length(direction) != 2
+ throw(ArgumentError("`direction` must be 2D when extruding 2D points"))
+ end
+
# With `place_on_shell`, particles need to be AT the min coordinates and not half a particle
# spacing away from it.
(place_on_shell) && (return plane_points)
@@ -254,6 +271,10 @@ end
function shift_plane_corners(plane_points::NTuple{3}, direction, particle_spacing,
place_on_shell)
+ if length(direction) != 3
+ throw(ArgumentError("`direction` must be 3D when extruding 3D points"))
+ end
+
# With `place_on_shell`, particles need to be AT the min coordinates and not half a particle
# spacing away from it.
(place_on_shell) && (return plane_points)
diff --git a/src/setups/rectangular_shape.jl b/src/setups/rectangular_shape.jl
index c9043a35e6..a1daff091d 100644
--- a/src/setups/rectangular_shape.jl
+++ b/src/setups/rectangular_shape.jl
@@ -92,7 +92,7 @@ function RectangularShape(particle_spacing, n_particles_per_dimension, min_coord
throw(ArgumentError("`min_coordinates` must be of length $NDIMS for a $(NDIMS)D problem"))
end
- if density !== nothing && any(density .< eps())
+ if density !== nothing && !(density isa Function) && any(density .< eps())
throw(ArgumentError("`density` needs to be positive and larger than $(eps())"))
end
@@ -105,34 +105,50 @@ function RectangularShape(particle_spacing, n_particles_per_dimension, min_coord
place_on_shell, loop_order)
if !isnothing(coordinates_perturbation)
- seed!(1)
amplitude = coordinates_perturbation * particle_spacing
- coordinates .+= rand((-amplitude):(particle_spacing * 1e-3):(amplitude),
+ coordinates .+= rand(MersenneTwister(1),
+ (-amplitude):(particle_spacing * 1e-3):(amplitude),
NDIMS, n_particles)
end
# Allow zero acceleration with state equation, but interpret `nothing` acceleration
# with state equation as a likely mistake.
if acceleration isa AbstractVector || acceleration isa Tuple
+ if length(acceleration) != NDIMS
+ throw(ArgumentError("`acceleration` must be of length $NDIMS for a $(NDIMS)D problem"))
+ end
+
if pressure != 0.0
throw(ArgumentError("`pressure` cannot be used together with `acceleration` " *
"and `state_equation` (hydrostatic pressure gradient)"))
end
if state_equation === nothing
- density_fun = pressure -> density
+ if density === nothing
+ throw(ArgumentError("`density` must be specified when using " *
+ "`acceleration` without `state_equation`"))
+ end
else
if density !== nothing
throw(ArgumentError("`density` cannot be used together with `acceleration` " *
"and `state_equation` (hydrostatic pressure gradient)"))
end
- density_fun = pressure -> inverse_state_equation(state_equation, pressure)
end
# Initialize hydrostatic pressure
pressure = Vector{ELTYPE}(undef, n_particles)
- initialize_pressure!(pressure, particle_spacing, acceleration,
- density_fun, n_particles_per_dimension, loop_order)
+ if state_equation === nothing && density isa Function
+ initialize_pressure_with_coordinate_density!(pressure, particle_spacing,
+ acceleration, density,
+ coordinates,
+ n_particles_per_dimension,
+ loop_order)
+ else
+ density_fun = state_equation === nothing ? (pressure -> density) :
+ (pressure -> inverse_state_equation(state_equation, pressure))
+ initialize_pressure!(pressure, particle_spacing, acceleration,
+ density_fun, n_particles_per_dimension, loop_order)
+ end
if state_equation !== nothing
# Weakly compressible case: get density from inverse state equation
@@ -223,15 +239,23 @@ function rectangular_shape_coords(particle_spacing, n_particles_per_dimension,
return coordinates
end
-function initialize_pressure!(pressure, particle_spacing, acceleration, density_fun,
- n_particles_per_dimension, loop_order)
+function acceleration_dimension(acceleration)
if count(a -> abs(a) > eps(), acceleration) > 1
throw(ArgumentError("hydrostatic pressure calculation is not supported with " *
"diagonal acceleration"))
end
+ return findfirst(a -> abs(a) > eps(), acceleration)
+end
+
+function initialize_pressure!(pressure, particle_spacing, acceleration, density_fun,
+ n_particles_per_dimension, loop_order)
# Dimension in which the acceleration is acting
- accel_dim = findfirst(a -> abs(a) > eps(), acceleration)
+ accel_dim = acceleration_dimension(acceleration)
+ if accel_dim === nothing
+ fill!(pressure, zero(eltype(pressure)))
+ return pressure
+ end
# Compute 1D pressure gradient with explicit Euler method
factor = particle_spacing * abs(acceleration[accel_dim])
@@ -265,3 +289,72 @@ function initialize_pressure!(pressure, particle_spacing, acceleration, density_
pressure[particle] = pressure_1d[index_in_accel_dim]
end
end
+
+function particle_indices_by_cartesian_index(n_particles_per_dimension, loop_order)
+ NDIMS = length(n_particles_per_dimension)
+ particle_indices = Array{Int}(undef, n_particles_per_dimension)
+ cartesian_indices = CartesianIndices(n_particles_per_dimension)
+ permutation = loop_permutation(loop_order, Val(NDIMS))
+ permuted_indices = permutedims(cartesian_indices, permutation)
+
+ for particle in eachindex(permuted_indices)
+ particle_indices[permuted_indices[particle]] = particle
+ end
+
+ return particle_indices
+end
+
+# This is needed for `density = coords -> ...`. The pressure-dependent path above can reuse
+# one 1D pressure profile for every column. Coordinate-dependent density may vary between
+# columns, so each gravity-aligned column needs its own explicit Euler integration.
+function initialize_pressure_with_coordinate_density!(pressure, particle_spacing,
+ acceleration, density_fun,
+ coordinates,
+ n_particles_per_dimension,
+ loop_order)
+ # Dimension in which the acceleration is acting
+ accel_dim = acceleration_dimension(acceleration)
+ if accel_dim === nothing
+ fill!(pressure, zero(eltype(pressure)))
+ return pressure
+ end
+
+ NDIMS = length(n_particles_per_dimension)
+ factor = particle_spacing * abs(acceleration[accel_dim])
+ particle_indices = particle_indices_by_cartesian_index(n_particles_per_dimension,
+ loop_order)
+
+ accel_indices = if sign(acceleration[accel_dim]) < 0
+ n_particles_per_dimension[accel_dim]:-1:1
+ else
+ 1:n_particles_per_dimension[accel_dim]
+ end
+ surface_index = first(accel_indices)
+ column_starts = ntuple(dim -> dim == accel_dim ? (surface_index:surface_index) :
+ axes(particle_indices, dim), Val(NDIMS))
+
+ for column_start in CartesianIndices(column_starts)
+ pressure_prev = zero(eltype(pressure))
+ density_prev = zero(eltype(pressure))
+ for (i, accel_index) in enumerate(accel_indices)
+ index = ntuple(dim -> dim == accel_dim ? accel_index : column_start[dim],
+ Val(NDIMS))
+ particle = particle_indices[index...]
+ coords = SVector{NDIMS, eltype(coordinates)}(ntuple(dim -> coordinates[dim,
+ particle],
+ Val(NDIMS)))
+ density = density_fun(coords)
+
+ if i == 1
+ pressure[particle] = 0.5factor * density
+ else
+ pressure[particle] = pressure_prev + factor * density_prev
+ end
+
+ pressure_prev = pressure[particle]
+ density_prev = density
+ end
+ end
+
+ return pressure
+end
diff --git a/src/setups/rectangular_tank.jl b/src/setups/rectangular_tank.jl
index 40abd3a9ca..6a9252a8a5 100644
--- a/src/setups/rectangular_tank.jl
+++ b/src/setups/rectangular_tank.jl
@@ -117,10 +117,27 @@ struct RectangularTank{NDIMS, NDIMSt2, ELTYPE <: Real, F, B}
throw(ArgumentError("`fluid_density` needs to be positive and larger than $(eps())."))
end
+ if any(<(0), fluid_size_)
+ throw(ArgumentError("`fluid_size` dimensions need to be non-negative"))
+ end
+
+ if !(n_layers isa Integer) || n_layers < 1
+ throw(ArgumentError("`n_layers` needs to be a positive integer"))
+ end
+ n_layers = Int(n_layers)
+
+ if spacing_ratio < eps()
+ throw(ArgumentError("`spacing_ratio` needs to be positive and larger than $(eps())."))
+ end
+
if length(tank_size) != NDIMS
throw(ArgumentError("`tank_size` must be of length $NDIMS for a $(NDIMS)D problem"))
end
+ if any(<(0), tank_size_)
+ throw(ArgumentError("`tank_size` dimensions need to be non-negative"))
+ end
+
# Fluid particle data
n_particles_per_dim,
fluid_size_ = fluid_particles_per_dimension(fluid_size_, particle_spacing)
@@ -168,7 +185,7 @@ struct RectangularTank{NDIMS, NDIMSt2, ELTYPE <: Real, F, B}
# Move the tank corner in the negative coordinate directions to the desired position
boundary.coordinates .+= min_coordinates
- if norm(fluid_size) > eps()
+ if all(>(0), n_particles_per_dim)
if state_equation !== nothing
# Use hydrostatic pressure gradient and calculate density from inverse state
# equation, so don't pass fluid density.
@@ -205,8 +222,8 @@ function calculate_normals!(normals, boundary_coordinates, boundary_spacing,
corner_indices, = boundary_indices
offset = boundary_spacing / 2
- # Check if a face exists and if there are
- # any particles associated with it.
+ # Check if a face exists and if there are
+ # any particles associated with it.
function face_has_particles(i)
return faces[i] && !isempty(face_indices[i])
end
@@ -305,8 +322,8 @@ function calculate_normals!(normals, boundary_coordinates, boundary_spacing,
end
end
- # Check if a face exists and if there
- # are any particles associated with it.
+ # Check if a face exists and if there
+ # are any particles associated with it.
function face_has_particles(idxs, i)
return faces[i] && !isempty(idxs[i])
end
@@ -480,14 +497,20 @@ function check_tank_overlap(fluid_size::NTuple{2}, tank_size, particle_spacing,
fluid_size_x, fluid_size_y = fluid_size
if tank_size[1] < fluid_size[1] - 1e-5 * particle_spacing
- n_particles_x -= 1
+ n_particles_x = max(0,
+ floor(Int,
+ (tank_size[1] + 1e-5 * particle_spacing) /
+ particle_spacing))
fluid_size_x = n_particles_x * particle_spacing
@info "The fluid was overlapping.\n New fluid length in x-direction is set to $fluid_size_x."
end
if tank_size[2] < fluid_size[2] - 1e-5 * particle_spacing
- n_particles_y -= 1
+ n_particles_y = max(0,
+ floor(Int,
+ (tank_size[2] + 1e-5 * particle_spacing) /
+ particle_spacing))
fluid_size_y = n_particles_y * particle_spacing
@info "The fluid was overlapping.\n New fluid length in y-direction is set to $fluid_size_y."
@@ -502,21 +525,30 @@ function check_tank_overlap(fluid_size::NTuple{3}, tank_size, particle_spacing,
fluid_size_x, fluid_size_y, fluid_size_z = fluid_size
if tank_size[1] < fluid_size[1] - 1e-5 * particle_spacing
- n_particles_x -= 1
+ n_particles_x = max(0,
+ floor(Int,
+ (tank_size[1] + 1e-5 * particle_spacing) /
+ particle_spacing))
fluid_size_x = n_particles_x * particle_spacing
@info "The fluid was overlapping.\n New fluid length in x-direction is set to $fluid_size_x."
end
if tank_size[2] < fluid_size[2] - 1e-5 * particle_spacing
- n_particles_y -= 1
+ n_particles_y = max(0,
+ floor(Int,
+ (tank_size[2] + 1e-5 * particle_spacing) /
+ particle_spacing))
fluid_size_y = n_particles_y * particle_spacing
@info "The fluid was overlapping.\n New fluid length in y-direction is set to $fluid_size_y."
end
if tank_size[3] < fluid_size[3] - 1e-5 * particle_spacing
- n_particles_z -= 1
+ n_particles_z = max(0,
+ floor(Int,
+ (tank_size[3] + 1e-5 * particle_spacing) /
+ particle_spacing))
fluid_size_z = n_particles_z * particle_spacing
@info "The fluid was overlapping.\n New fluid length in z-direction is set to $fluid_size_z."
@@ -851,7 +883,7 @@ function initialize_boundaries(particle_spacing, tank_size::NTuple{3},
end
end
- # z aligned edge (left-top)
+ # z aligned edge (left-top)
if faces[left] && faces[top]
edge_1_4 = rectangular_shape_coords(particle_spacing,
(n_layers, n_layers, n_particles_z),
@@ -1031,7 +1063,7 @@ function initialize_boundaries(particle_spacing, tank_size::NTuple{3},
end
end
- # left top front
+ # left top front
if faces[left] && faces[top] && faces[front]
corner_1_4_5 = rectangular_shape_coords(particle_spacing,
(n_layers, n_layers, n_layers),
diff --git a/src/setups/sphere_shape.jl b/src/setups/sphere_shape.jl
index fea261e127..b547897888 100644
--- a/src/setups/sphere_shape.jl
+++ b/src/setups/sphere_shape.jl
@@ -36,7 +36,8 @@ coordinate directions as `cutout_min` and `cutout_max`.
- `cutout_min`: Corner in negative coordinate directions of a cuboid that is to be
cut out of the sphere.
- `cutout_max`: Corner in positive coordinate directions of a cuboid that is to be
- cut out of the sphere.
+ cut out of the sphere. If the cutout has zero volume, no particles
+ are removed.
- `place_on_shell = false`: If `place_on_shell=true`, particles will be placed on the shell
of the shape. For example, the [`TotalLagrangianSPHSystem`](@ref)
requires particles to be placed on the shell of the shape and
@@ -114,9 +115,22 @@ function SphereShape(particle_spacing, radius, center_position, density;
cutout_min_ = collect(cutout_min)
cutout_max_ = collect(cutout_max)
+ # A zero-volume cutout means no cutout. This keeps the 2D zero default valid for
+ # 3D shapes while still validating dimensionality once a real cutout is requested.
+ has_cutout = length(cutout_min_) != length(cutout_max_) ||
+ norm(cutout_max_ - cutout_min_) > eps()
+
+ if has_cutout && (length(cutout_min_) != NDIMS || length(cutout_max_) != NDIMS)
+ throw(ArgumentError("`cutout_min` and `cutout_max` must be of length $NDIMS " *
+ "for a $(NDIMS)D problem"))
+ end
+
+ if has_cutout && any(cutout_min_ .> cutout_max_)
+ throw(ArgumentError("`cutout_min` must be smaller than or equal to `cutout_max`"))
+ end
+
# Remove particles in cutout
# TODO This should consider the particle radius as well
- has_cutout = norm(cutout_max_ - cutout_min_) > eps()
function in_cutout(particle)
return has_cutout &&
all(cutout_min_ .<= view(coordinates, :, particle) .<= cutout_max_)
diff --git a/src/visualization/makie.jl b/src/visualization/makie.jl
new file mode 100644
index 0000000000..14cc9fcd89
--- /dev/null
+++ b/src/visualization/makie.jl
@@ -0,0 +1,20 @@
+"""
+ trixi2makie(solution; frame=Makie.automatic, kwargs...)
+ trixi2makie(v_ode, u_ode, semi; kwargs...)
+ trixi2makie!(axis, args...; kwargs...)
+
+Plot a TrixiParticles solution with Makie using physically sized particle markers. The first
+method plots one frame of an ODE solution, while the second accepts the position and state
+arrays explicitly. Use `trixi2makie!` to add the plot to an existing Makie axis.
+
+Makie's generic `plot` and `plot!` functions support the same arguments, so `plot(solution)`
+and `plot!(axis, solution)` use this recipe as well. This particle-level view is intended for
+diagnostics; fluid surface reconstruction requires additional post-processing.
+
+This function is available after loading Makie or one of its backends, such as CairoMakie,
+GLMakie, or RayMakie. See the visualization documentation for the supported keyword
+arguments.
+"""
+function trixi2makie end
+
+function trixi2makie! end
diff --git a/test/callbacks/density_reinit.jl b/test/callbacks/density_reinit.jl
index bb65c1d133..eabb020e11 100644
--- a/test/callbacks/density_reinit.jl
+++ b/test/callbacks/density_reinit.jl
@@ -12,6 +12,7 @@
p::Any
u::Any
t::Float64
+ opts::Any
end
density_reinit_calls = Symbol[]
@@ -69,7 +70,7 @@
system = MockDensityReinitSystem(nothing, :fluid)
vu_ode = (; x=(:v_ode, :u_ode))
semi = (; systems=(system,))
- integrator = MockDensityReinitIntegrator((; semi), vu_ode, 0.0)
+ integrator = MockDensityReinitIntegrator((; semi), vu_ode, 0.0, nothing)
TrixiParticles.get_neighborhood_search(system::MockDensityReinitSystem,
neighbor::MockDensityReinitSystem,
@@ -216,4 +217,139 @@
(;
systems=(MockNoDensityReinitSystem(:boundary),)))
end
+
+ @testset "nonuniform multi-system reinitialization with buffer" begin
+ spacing = 0.1
+ kernel = WendlandC6Kernel{2}()
+ smoothing_length = 2spacing
+ state_equation = StateEquationCole(; sound_speed=10.0,
+ reference_density=1000.0, exponent=1)
+ initial1 = RectangularShape(spacing, (3, 3), (0.0, 0.0); density=1000.0)
+ initial2 = RectangularShape(spacing, (3, 3), (0.35, 0.0); density=1000.0)
+ system1 = WeaklyCompressibleSPHSystem(initial1; smoothing_kernel=kernel,
+ smoothing_length,
+ density_calculator=ContinuityDensity(),
+ state_equation, buffer_size=2)
+ system2 = WeaklyCompressibleSPHSystem(initial2; smoothing_kernel=kernel,
+ smoothing_length,
+ density_calculator=ContinuityDensity(),
+ state_equation)
+ semi = Semidiscretization(system1, system2; neighborhood_search=nothing,
+ parallelization_backend=SerialBackend())
+ callback = DensityReinitializationCallback(system1, semi; interval=1,
+ reinit_initial_solution=false)
+ ode = semidiscretize(semi, (0.0, 1.0); reset_threads=false)
+ semi = ode.p.semi
+ system1, system2 = semi.systems
+ vu_ode = deepcopy(ode.u0)
+ v_ode, u_ode = vu_ode.x
+ v1 = TrixiParticles.wrap_v(v_ode, system1, semi)
+ v2 = TrixiParticles.wrap_v(v_ode, system2, semi)
+ u1 = TrixiParticles.wrap_u(u_ode, system1, semi)
+ u2 = TrixiParticles.wrap_u(u_ode, system2, semi)
+ active1 = collect(TrixiParticles.eachparticle(system1))
+ active2 = collect(TrixiParticles.eachparticle(system2))
+ v1[end, active1] .= range(800.0, 1200.0; length=length(active1))
+ v2[end, active2] .= range(900.0, 1100.0; length=length(active2))
+ density2_before = copy(v2[end, :])
+
+ TrixiParticles.update_nhs!(semi, u_ode)
+ coefficient = zeros(size(v1, 2))
+ TrixiParticles.compute_shepard_coeff!(system1,
+ TrixiParticles.current_coordinates(u1,
+ system1),
+ v_ode, u_ode, semi, coefficient)
+ summation = zeros(size(v1, 2))
+ TrixiParticles.summation_density!(system1, semi, u1, u_ode, summation)
+ expected = summation[active1] ./ coefficient[active1]
+
+ cross_contribution = zeros(size(v1, 2))
+ coords1 = TrixiParticles.current_coordinates(u1, system1)
+ coords2 = TrixiParticles.current_coordinates(u2, system2)
+ TrixiParticles.foreach_point_neighbor(system1, system2, coords1, coords2,
+ semi) do particle, neighbor, pos_diff,
+ distance
+ volume = TrixiParticles.hydrodynamic_mass(system2, neighbor) /
+ TrixiParticles.current_density(v2, system2, neighbor)
+ cross_contribution[particle] += volume *
+ TrixiParticles.smoothing_kernel(system1,
+ distance,
+ particle)
+ end
+
+ integrator = MockDensityReinitIntegrator((; semi), vu_ode, 0.05, nothing)
+ callback.affect!(integrator)
+ inactive1 = setdiff(axes(v1, 2), active1)
+
+ @test v1[end, active1]≈expected rtol=2e-14 atol=2e-14
+ @test all(iszero, v1[end, inactive1])
+ @test v2[end, :] == density2_before
+ @test any(>(0), cross_contribution[active1])
+ end
+
+ @testset "simultaneous interacting reinitialization" begin
+ spacing = 0.1
+ kernel = WendlandC6Kernel{2}()
+ state_equation = StateEquationCole(; sound_speed=10.0,
+ reference_density=1000.0, exponent=1)
+ initial1 = RectangularShape(spacing, (2, 2), (0.0, 0.0); density=1000.0)
+ initial2 = RectangularShape(spacing, (2, 2), (0.1, 0.0); density=1000.0)
+ system1 = WeaklyCompressibleSPHSystem(initial1; smoothing_kernel=kernel,
+ smoothing_length=2spacing,
+ density_calculator=ContinuityDensity(),
+ state_equation)
+ system2 = WeaklyCompressibleSPHSystem(initial2; smoothing_kernel=kernel,
+ smoothing_length=2spacing,
+ density_calculator=ContinuityDensity(),
+ state_equation)
+ semi = Semidiscretization(system1, system2; neighborhood_search=nothing,
+ parallelization_backend=SerialBackend())
+ ode = semidiscretize(semi, (0.0, 1.0); reset_threads=false)
+ semi = ode.p.semi
+ system1, system2 = semi.systems
+ vu_ode = deepcopy(ode.u0)
+ v_ode, u_ode = vu_ode.x
+ v1 = TrixiParticles.wrap_v(v_ode, system1, semi)
+ v2 = TrixiParticles.wrap_v(v_ode, system2, semi)
+ u1 = TrixiParticles.wrap_u(u_ode, system1, semi)
+ u2 = TrixiParticles.wrap_u(u_ode, system2, semi)
+ v1[end, :] .= range(800.0, 1200.0; length=size(v1, 2))
+ v2[end, :] .= range(1600.0, 2400.0; length=size(v2, 2))
+ TrixiParticles.update_nhs!(semi, u_ode)
+
+ coefficient1 = zeros(size(v1, 2))
+ coefficient2 = zeros(size(v2, 2))
+ TrixiParticles.compute_shepard_coeff!(system1,
+ TrixiParticles.current_coordinates(u1,
+ system1),
+ v_ode, u_ode, semi, coefficient1)
+ TrixiParticles.compute_shepard_coeff!(system2,
+ TrixiParticles.current_coordinates(u2,
+ system2),
+ v_ode, u_ode, semi, coefficient2)
+ expected1 = zeros(size(v1, 2))
+ expected2 = zeros(size(v2, 2))
+ TrixiParticles.summation_density!(system1, semi, u1, u_ode, expected1)
+ TrixiParticles.summation_density!(system2, semi, u2, u_ode, expected2)
+ expected1 ./= coefficient1
+ expected2 ./= coefficient2
+
+ density_callback1 = DensityReinitializationCallback(system1, semi; dt=1.0,
+ reinit_initial_solution=false)
+ density_callback2 = DensityReinitializationCallback(system2, semi; dt=1.0,
+ reinit_initial_solution=false)
+ callback1 = density_callback1.affect!
+ callback2 = density_callback2.affect!
+ callbacks = CallbackSet(density_callback1, density_callback2)
+ integrator = MockDensityReinitIntegrator((; semi), vu_ode, 0.05,
+ (; callback=callbacks))
+ callback1(integrator)
+
+ @test v1[end, :]≈expected1 rtol=2e-14 atol=2e-14
+ @test v2[end, :]≈expected2 rtol=2e-14 atol=2e-14
+ @test callback2.last_t == integrator.t
+ density_after = copy(v2[end, :])
+ callback2(integrator)
+ @test v2[end, :] == density_after
+ end
end
diff --git a/test/examples/examples.jl b/test/examples/examples.jl
index da1a48bf8d..dfea4479ec 100644
--- a/test/examples/examples.jl
+++ b/test/examples/examples.jl
@@ -12,13 +12,7 @@
r"\[ Info: To create the self-interaction neighborhood search.*\n"
]
@test sol.retcode == ReturnCode.Success
- if VERSION < v"1.12"
- # Older Julia versions produce allocations because `get_neighborhood_search`
- # is not type-stable with TLSPH.
- @test count_rhs_allocations(sol) < 200
- else
- @test count_rhs_allocations(sol) == 0
- end
+ @test count_rhs_allocations(sol) == 0
end
@trixi_testset "structure/oscillating_beam_2d.jl with penalty force" begin
@@ -37,13 +31,7 @@
sol = solve(ode, CarpenterKennedy2N54(williamson_condition=false), dt=1.0,
save_everystep=false, callback=callbacks)
@test sol.retcode == ReturnCode.Success
- if VERSION < v"1.12"
- # Older Julia versions produce allocations because `get_neighborhood_search`
- # is not type-stable with TLSPH.
- @test count_rhs_allocations(sol) < 200
- else
- @test count_rhs_allocations(sol) == 0
- end
+ @test count_rhs_allocations(sol) == 0
end
@trixi_testset "structure/oscillating_beam_2d.jl with penalty force and viscosity" begin
@@ -56,13 +44,7 @@
r"\[ Info: To create the self-interaction neighborhood search.*\n"
]
@test sol.retcode == ReturnCode.Success
- if VERSION < v"1.12"
- # Older Julia versions produce allocations because `get_neighborhood_search`
- # is not type-stable with TLSPH.
- @test count_rhs_allocations(sol) < 200
- else
- @test count_rhs_allocations(sol) == 0
- end
+ @test count_rhs_allocations(sol) == 0
end
@trixi_testset "structure/oscillating_beam_2d.jl with rotating clamp" begin
@@ -82,13 +64,7 @@
r"\[ Info: To create the self-interaction neighborhood search.*\n"
]
@test sol.retcode == ReturnCode.Success
- if VERSION < v"1.12"
- # Older Julia versions produce allocations because `get_neighborhood_search`
- # is not type-stable with TLSPH.
- @test count_rhs_allocations(sol) < 200
- else
- @test count_rhs_allocations(sol) == 0
- end
+ @test count_rhs_allocations(sol) == 0
end
@trixi_testset "structure/oscillating_beam_2d.jl with MechanicalWorkCalculator" begin
@@ -182,6 +158,85 @@
@test sol.retcode == ReturnCode.Success
@test count_rhs_allocations(sol) == 0
end
+
+ @trixi_testset "structure/sliding_rigid_squares_friction_2d.jl" begin
+ @trixi_test_nowarn trixi_include(@__MODULE__,
+ joinpath(examples_dir(), "structure",
+ "sliding_rigid_squares_friction_2d.jl"),
+ tspan=(0.0, 0.3))
+ @test sol.retcode == ReturnCode.Success
+
+ # Compare center-of-mass motion rather than individual contact particles, whose
+ # velocities also contain the square's rigid rotation.
+ v_ode_initial, u_ode_initial = sol.u[begin].x
+ v_ode_final, u_ode_final = sol.u[end].x
+ v_frictional_initial = TrixiParticles.wrap_v(v_ode_initial,
+ structure_system_frictional,
+ semi)
+ u_frictional_initial = TrixiParticles.wrap_u(u_ode_initial,
+ structure_system_frictional,
+ semi)
+ v_frictionless = TrixiParticles.wrap_v(v_ode_final,
+ structure_system_frictionless, semi)
+ u_frictionless = TrixiParticles.wrap_u(u_ode_final,
+ structure_system_frictionless, semi)
+ v_frictional = TrixiParticles.wrap_v(v_ode_final,
+ structure_system_frictional, semi)
+ u_frictional = TrixiParticles.wrap_u(u_ode_final,
+ structure_system_frictional, semi)
+ frictionless_coords = TrixiParticles.current_coordinates(u_frictionless,
+ structure_system_frictionless)
+ frictionless_velocity = TrixiParticles.current_velocity(v_frictionless,
+ structure_system_frictionless)
+ frictional_initial_coords = TrixiParticles.current_coordinates(u_frictional_initial,
+ structure_system_frictional)
+ frictional_initial_velocity = TrixiParticles.current_velocity(v_frictional_initial,
+ structure_system_frictional)
+ frictional_coords = TrixiParticles.current_coordinates(u_frictional,
+ structure_system_frictional)
+ frictional_velocity = TrixiParticles.current_velocity(v_frictional,
+ structure_system_frictional)
+ frictionless_com,
+ frictionless_com_velocity = TrixiParticles.rigid_center_of_mass_kinematics(structure_system_frictionless,
+ frictionless_coords,
+ frictionless_velocity)
+ frictional_initial_com,
+ frictional_initial_com_velocity = TrixiParticles.rigid_center_of_mass_kinematics(structure_system_frictional,
+ frictional_initial_coords,
+ frictional_initial_velocity)
+ frictional_com,
+ frictional_com_velocity = TrixiParticles.rigid_center_of_mass_kinematics(structure_system_frictional,
+ frictional_coords,
+ frictional_velocity)
+ frictionless_rotation = TrixiParticles.rigid_rotational_kinematics(structure_system_frictionless,
+ frictionless_coords,
+ frictionless_velocity,
+ frictionless_com,
+ frictionless_com_velocity)
+ frictional_rotation = TrixiParticles.rigid_rotational_kinematics(structure_system_frictional,
+ frictional_coords,
+ frictional_velocity,
+ frictional_com,
+ frictional_com_velocity)
+
+ # A body sliding under kinetic Coulomb friction has deceleration `mu_k * gravity`.
+ expected_stopping_distance = frictional_initial_com_velocity[1]^2 /
+ (2 *
+ contact_model_frictional.kinetic_friction_coefficient *
+ gravity)
+
+ # Normal-only contact should preserve horizontal sliding without spinning up.
+ @test isapprox(frictionless_com_velocity[1],
+ frictional_initial_com_velocity[1]; rtol=0.05)
+ @test abs(frictionless_rotation.angular_velocity) < 0.05
+
+ # Wall friction should stop the other square near the analytical stopping
+ # distance and exert a torque because it acts below the center of mass.
+ @test abs(frictional_com_velocity[1]) < 0.05
+ @test isapprox(frictional_com[1] - frictional_initial_com[1],
+ expected_stopping_distance; rtol=0.05)
+ @test abs(frictional_rotation.angular_velocity) > 0.1
+ end
end
@testset verbose=true "FSI" begin
@@ -271,13 +326,7 @@
r"\[ Info: To create the self-interaction neighborhood search.*\n"
]
@test sol.retcode == ReturnCode.Success
- if VERSION < v"1.12"
- # Older Julia versions produce allocations because `get_neighborhood_search`
- # is not type-stable with TLSPH.
- @test count_rhs_allocations(sol) < 200
- else
- @test count_rhs_allocations(sol) == 0
- end
+ @test count_rhs_allocations(sol) == 0
end
@trixi_testset "fsi/dam_break_plate_2d.jl" begin
@@ -290,13 +339,7 @@
r"\[ Info: To create the self-interaction neighborhood search.*\n"
]
@test sol.retcode == ReturnCode.Success
- if VERSION < v"1.12"
- # Older Julia versions produce allocations because `get_neighborhood_search`
- # is not type-stable with TLSPH.
- @test count_rhs_allocations(sol) < 200
- else
- @test count_rhs_allocations(sol) == 0
- end
+ @test count_rhs_allocations(sol) == 0
end
@trixi_testset "fsi/dam_break_plate_2d.jl velocity averaging" begin
@@ -365,13 +408,7 @@
r"\[ Info: To create the self-interaction neighborhood search.*\n"
]
@test sol.retcode == ReturnCode.Success
- if VERSION < v"1.12"
- # Older Julia versions produce allocations because `get_neighborhood_search`
- # is not type-stable with TLSPH.
- @test count_rhs_allocations(sol) < 200
- else
- @test count_rhs_allocations(sol) == 0
- end
+ @test count_rhs_allocations(sol) == 0
end
@trixi_testset "fsi/dam_break_plate_2d.jl split integration" begin
@@ -393,13 +430,7 @@
r"┌ Warning: Verbosity toggle: max_iters \n(?s:.*?)└ @ (?:SciMLBase|DiffEqBase).*\n"
]
@test sol.retcode == ReturnCode.MaxIters
- if VERSION < v"1.12"
- # Older Julia versions produce allocations because `get_neighborhood_search`
- # is not type-stable with TLSPH.
- @test count_rhs_allocations(sol) < 200
- else
- @test count_rhs_allocations(sol) == 0
- end
+ @test count_rhs_allocations(sol) == 0
# Use split integration and verify that we need fewer than 400 iterations
split_integration = SplitIntegrationCallback(CarpenterKennedy2N54(williamson_condition=false),
@@ -411,13 +442,7 @@
save_everystep=false, callback=callbacks)
@test sol2.retcode == ReturnCode.Success
- if VERSION < v"1.12"
- # Older Julia versions produce allocations because `get_neighborhood_search`
- # is not type-stable with TLSPH.
- @test count_rhs_allocations(sol2; split_integration) < 200
- else
- @test count_rhs_allocations(sol2; split_integration) == 0
- end
+ @test count_rhs_allocations(sol2; split_integration) == 0
# Use stage-level coupling and verify that it is not compatible with
# the fluid time integration scheme `RDPK3SpFSAL49`.
@@ -441,13 +466,7 @@
save_everystep=false, callback=callbacks)
@test sol2.retcode == ReturnCode.Success
- if VERSION < v"1.12"
- # Older Julia versions produce allocations because `get_neighborhood_search`
- # is not type-stable with TLSPH.
- @test count_rhs_allocations(sol2; split_integration) < 200
- else
- @test count_rhs_allocations(sol2; split_integration) == 0
- end
+ @test count_rhs_allocations(sol2; split_integration) == 0
# Use split integration and verify that it is actually used for TLSPH
# by using a time step that is too large and verifying that it is crashing.
@@ -471,13 +490,7 @@
r"\[ Info: To create the self-interaction neighborhood search.*\n"
]
@test sol.retcode == ReturnCode.Success
- if VERSION < v"1.12"
- # Older Julia versions produce allocations because `get_neighborhood_search`
- # is not type-stable with TLSPH.
- @test count_rhs_allocations(sol) < 200
- else
- @test count_rhs_allocations(sol) == 0
- end
+ @test count_rhs_allocations(sol) == 0
end
@trixi_testset "fsi/falling_spheres_2d.jl" begin
@@ -488,15 +501,7 @@
r"\[ Info: To create the self-interaction neighborhood search.*\n"
]
@test sol.retcode == ReturnCode.Success
- if VERSION > v"1.11"
- # Newer Version than 1.11 produce more allocations
- # todo: unclear where this is from
- @test count_rhs_allocations(sol) < 1000
- else
- # Older Julia versions than 1.12 produce allocations because `get_neighborhood_search`
- # is not type-stable with TLSPH.
- @test count_rhs_allocations(sol) < 200
- end
+ @test count_rhs_allocations(sol) == 0
end
@trixi_testset "fsi/falling_rigid_spheres_2d.jl" begin
@@ -505,13 +510,7 @@
"falling_rigid_spheres_2d.jl"),
tspan=(0.0, 0.5))
@test sol.retcode == ReturnCode.Success
- if VERSION < v"1.12"
- # Older Julia versions produce allocations because `get_neighborhood_search`
- # is not type-stable with TLSPH.
- @test count_rhs_allocations(sol) < 500
- else
- @test count_rhs_allocations(sol) == 0
- end
+ @test count_rhs_allocations(sol) == 0
end
@trixi_testset "fsi/hydrostatic_water_column_2d.jl" begin
@@ -522,13 +521,7 @@
r"\[ Info: To create the self-interaction neighborhood search.*\n",
]
@test sol.retcode == ReturnCode.Success
- if VERSION < v"1.12"
- # Older Julia versions produce allocations because `get_neighborhood_search`
- # is not type-stable with TLSPH.
- @test count_rhs_allocations(sol) < 500
- else
- @test count_rhs_allocations(sol) == 0
- end
+ @test count_rhs_allocations(sol) == 0
end
@trixi_testset "fsi/falling_rotating_rigid_squares_2d.jl" begin
@@ -537,13 +530,7 @@
"falling_rotating_rigid_squares_2d.jl"),
tspan=(0.0, 0.5))
@test sol.retcode == ReturnCode.Success
- if VERSION < v"1.12"
- # Older Julia versions produce allocations because `get_neighborhood_search`
- # is not type-stable with TLSPH.
- @test count_rhs_allocations(sol) < 500
- else
- @test count_rhs_allocations(sol) == 0
- end
+ @test count_rhs_allocations(sol) == 0
end
@trixi_testset "fsi/falling_rotating_rigid_squares_w_buoys_2d.jl" begin
@@ -554,10 +541,10 @@
r"WARNING: Method definition structure_boundary_model.*\n"
]
@test sol.retcode == ReturnCode.Success
- if VERSION < v"1.12"
- # Older Julia versions produce allocations because `get_neighborhood_search`
- # is not type-stable with TLSPH or rigid.
- @test count_rhs_allocations(sol) < 2000
+ if v"1.11" <= VERSION < v"1.12"
+ # Julia 1.11 retains thread-count-dependent allocations in the
+ # heterogeneous rigid-body interaction dispatch. CI measures 3328 bytes.
+ @test count_rhs_allocations(sol) < 3400
else
@test count_rhs_allocations(sol) == 0
end
@@ -572,13 +559,7 @@
r"\[ Info: To create the self-interaction neighborhood search.*\n",
]
@test sol.retcode == ReturnCode.Success
- if VERSION < v"1.12"
- # Older Julia versions produce allocations because `get_neighborhood_search`
- # is not type-stable with TLSPH.
- @test count_rhs_allocations(sol) < 500
- else
- @test count_rhs_allocations(sol) == 0
- end
+ @test count_rhs_allocations(sol) == 0
end
end
diff --git a/test/examples/examples_fluid.jl b/test/examples/examples_fluid.jl
index fe302529bf..a0a39106f3 100644
--- a/test/examples/examples_fluid.jl
+++ b/test/examples/examples_fluid.jl
@@ -227,12 +227,7 @@
r"└ New tank length in y-direction.*\n"
]
@test sol.retcode == ReturnCode.Success
- if VERSION < v"1.11"
- # For some reason, 1.10 produces allocations here
- @test count_rhs_allocations(sol) <= 32
- else
- @test count_rhs_allocations(sol) == 0
- end
+ @test count_rhs_allocations(sol) == 0
end
@trixi_testset "fluid/dam_break_2d_iisph.jl with PressureMirroring" begin
@@ -245,12 +240,7 @@
r"└ New tank length in y-direction.*\n"
]
@test sol.retcode == ReturnCode.Success
- if VERSION < v"1.11"
- # For some reason, 1.10 produces allocations here
- @test count_rhs_allocations(sol) <= 32
- else
- @test count_rhs_allocations(sol) == 0
- end
+ @test count_rhs_allocations(sol) == 0
end
@trixi_testset "fluid/dam_break_2d_iisph.jl with AdamiPressureExtrapolation" begin
@@ -263,12 +253,7 @@
r"└ New tank length in y-direction.*\n"
]
@test sol.retcode == ReturnCode.Success
- if VERSION < v"1.11"
- # For some reason, 1.10 produces allocations here
- @test count_rhs_allocations(sol) <= 32
- else
- @test count_rhs_allocations(sol) == 0
- end
+ @test count_rhs_allocations(sol) == 0
end
@trixi_testset "fluid/dam_break_2d_iisph.jl with BernoulliPressureExtrapolation" begin
@@ -281,12 +266,7 @@
r"└ New tank length in y-direction.*\n"
]
@test sol.retcode == ReturnCode.Success
- if VERSION < v"1.11"
- # For some reason, 1.10 produces allocations here
- @test count_rhs_allocations(sol) <= 32
- else
- @test count_rhs_allocations(sol) == 0
- end
+ @test count_rhs_allocations(sol) == 0
end
@trixi_testset "fluid/dam_break_2d_iisph.jl with PressureBoundaries" begin
@@ -298,12 +278,7 @@
r"└ New tank length in y-direction.*\n"
]
@test sol.retcode == ReturnCode.Success
- if VERSION < v"1.11"
- # For some reason, 1.10 produces allocations here
- @test count_rhs_allocations(sol) <= 32
- else
- @test count_rhs_allocations(sol) == 0
- end
+ @test count_rhs_allocations(sol) == 0
end
@trixi_testset "fluid/dam_break_2d_gpu.jl" begin
diff --git a/test/examples/gpu.jl b/test/examples/gpu.jl
index f63bca3b45..37a992ca3c 100644
--- a/test/examples/gpu.jl
+++ b/test/examples/gpu.jl
@@ -77,6 +77,233 @@ end
end
end
+@testset verbose=true "Correction lifecycle $TRIXIPARTICLES_TEST_" begin
+ # Build a fluid system of the given kind (`:wcsph` or `:edac`) with a correction.
+ function correction_fluid(kind, initial_condition, smoothing_kernel,
+ smoothing_length, density_calculator, correction)
+ if kind == :wcsph
+ state_equation = StateEquationCole(; sound_speed=10.0f0,
+ reference_density=1000.0f0, exponent=1)
+ return WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel,
+ smoothing_length, density_calculator,
+ state_equation, correction)
+ end
+
+ return EntropicallyDampedSPHSystem(initial_condition; smoothing_kernel,
+ smoothing_length, sound_speed=10.0f0,
+ pressure_acceleration=nothing,
+ density_calculator, correction)
+ end
+
+ # All correction caches present in the system must stay on the backend, keep
+ # `Float32` values, and remain finite.
+ function correction_cache_is_valid(system, backend)
+ return all((:kernel_correction_coefficient, :dw_gamma,
+ :correction_matrix)) do name
+ hasproperty(system.cache, name) || return true
+ values = getproperty(system.cache, name)
+ return eltype(values) == Float32 && all(isfinite, Array(values)) &&
+ TrixiParticles.KernelAbstractions.get_backend(values) == backend
+ end
+ end
+
+ function correction_rhs_is_valid(kind, correction, density_calculator, backend)
+ spacing = 0.1f0
+ initial_condition = RectangularShape(spacing, (4, 4), (0.0f0, 0.0f0);
+ density=1000.0f0,
+ velocity=pos -> SVector(pos[1], -pos[2]),
+ coordinates_eltype=Float32)
+ system = correction_fluid(kind, initial_condition, WendlandC6Kernel{2}(),
+ 2spacing, density_calculator, correction)
+ semi = Semidiscretization(system; neighborhood_search=nothing,
+ parallelization_backend=backend)
+ ode = semidiscretize(semi, (0.0f0, 0.1f0); reset_threads=false)
+ v_ode, u_ode = ode.u0.x
+ dv_ode = similar(v_ode)
+ fill!(dv_ode, 0.0f0)
+ # Evaluate the RHS, which triggers the staged correction updates.
+ TrixiParticles.kick!(dv_ode, v_ode, u_ode, ode.p, 0.0f0)
+
+ return eltype(dv_ode) == Float32 && all(isfinite, Array(dv_ode)) &&
+ correction_cache_is_valid(first(ode.p.semi.systems), backend)
+ end
+
+ backend = Main.parallelization_backend
+ # Check the RHS evaluation with the Shepard correction for both system types.
+ for kind in (:wcsph, :edac)
+ @test correction_rhs_is_valid(kind, ShepardKernelCorrection(), SummationDensity(),
+ backend)
+ @test correction_rhs_is_valid(kind, KernelCorrection(), ContinuityDensity(),
+ backend)
+ @test correction_rhs_is_valid(kind, GradientCorrection(), ContinuityDensity(),
+ backend)
+ @test correction_rhs_is_valid(kind, BlendedGradientCorrection(0.4f0),
+ ContinuityDensity(), backend)
+ end
+
+ function mixed_boundary_rhs_is_valid(backend)
+ spacing = 0.1f0
+ smoothing_kernel = WendlandC6Kernel{2}()
+ fluid_initial = RectangularShape(spacing, (4, 4), (0.0f0, 0.0f0);
+ density=1000.0f0,
+ coordinates_eltype=Float32)
+ correction = MixedKernelGradientCorrection()
+ fluid = correction_fluid(:wcsph, fluid_initial, smoothing_kernel, 2spacing,
+ ContinuityDensity(), correction)
+
+ boundary_initial = RectangularShape(spacing, (4, 1), (0.0f0, -spacing);
+ density=1000.0f0,
+ coordinates_eltype=Float32)
+ state_equation = StateEquationCole(; sound_speed=10.0f0,
+ reference_density=1000.0f0, exponent=1)
+ boundary_model = BoundaryModelDummyParticles(boundary_initial.density,
+ boundary_initial.mass,
+ SummationDensity(), smoothing_kernel,
+ 2spacing; state_equation, correction)
+ boundary = WallBoundarySystem(boundary_initial, boundary_model)
+ semi = Semidiscretization(fluid, boundary; neighborhood_search=nothing,
+ parallelization_backend=backend)
+ ode = semidiscretize(semi, (0.0f0, 0.1f0); reset_threads=false)
+ dv_ode = similar(ode.u0.x[1])
+ fill!(dv_ode, 0.0f0)
+ TrixiParticles.kick!(dv_ode, ode.u0.x[1], ode.u0.x[2], ode.p, 0.0f0)
+
+ boundary_cache = last(ode.p.semi.systems).boundary_model.cache
+ cache_is_valid = all((:kernel_correction_coefficient, :dw_gamma,
+ :correction_matrix)) do name
+ values = getproperty(boundary_cache, name)
+ return eltype(values) == Float32 && all(isfinite, Array(values)) &&
+ TrixiParticles.KernelAbstractions.get_backend(values) == backend
+ end
+ return all(isfinite, Array(dv_ode)) && cache_is_valid
+ end
+
+ @test mixed_boundary_rhs_is_valid(backend)
+
+ spacing = 0.1f0
+ initial_condition = RectangularShape(spacing, (2, 2), (0.0f0, 0.0f0);
+ density=1000.0f0,
+ coordinates_eltype=Float32)
+ system = correction_fluid(:wcsph, initial_condition, WendlandC6Kernel{2}(),
+ 2spacing, SummationDensity(), ShepardKernelCorrection())
+ semi = Semidiscretization(system; neighborhood_search=nothing,
+ parallelization_backend=backend)
+ ode = semidiscretize(semi, (0.0f0, 0.1f0); reset_threads=false)
+ # The sanitizer must replace invalid coefficients by one on the GPU while
+ # preserving valid Float32 normalizers below `sqrt(eps(Float32))`.
+ coefficient = Adapt.adapt(backend, Float32[0.0, NaN, -1.0, 1.0f-4, 2.0])
+ TrixiParticles.sanitize_kernel_correction_coefficient!(coefficient,
+ first(ode.p.semi.systems),
+ ode.p.semi)
+ @test Array(coefficient) == Float32[1.0, 1.0, 1.0, 1.0f-4, 2.0]
+
+ # Kernel correction must fall back to coefficient one and zero offset for degenerate
+ # coefficients (zero, non-finite, or below `sqrt(eps(Float32))`) and remain on the GPU.
+ function kernel_correction_fallback_is_valid(kind, correction, density_calculator,
+ backend; mass_value=0.0f0,
+ expect_dv_finite=true)
+ spacing_ = 0.1f0
+ ic = RectangularShape(spacing_, (4, 4), (0.0f0, 0.0f0);
+ density=1000.0f0, coordinates_eltype=Float32)
+ sys = correction_fluid(kind, ic, WendlandC6Kernel{2}(), 2spacing_,
+ density_calculator, correction)
+ sys.mass .= mass_value
+ semi = Semidiscretization(sys; neighborhood_search=nothing,
+ parallelization_backend=backend)
+ ode = semidiscretize(semi, (0.0f0, 0.1f0); reset_threads=false)
+ v_ode, u_ode = ode.u0.x
+ sys_gpu = first(ode.p.semi.systems)
+ dv_ode = similar(v_ode)
+ fill!(dv_ode, 0.0f0)
+ TrixiParticles.kick!(dv_ode, v_ode, u_ode, ode.p, 0.0f0)
+ coeff = Array(sys_gpu.cache.kernel_correction_coefficient)
+ dw = Array(sys_gpu.cache.dw_gamma)
+ backend_ok = TrixiParticles.KernelAbstractions.get_backend(sys_gpu.cache.kernel_correction_coefficient) ==
+ backend &&
+ TrixiParticles.KernelAbstractions.get_backend(sys_gpu.cache.dw_gamma) ==
+ backend
+ caches_ok = all(==(1.0f0), coeff) && all(iszero, dw) &&
+ all(isfinite, coeff) && all(isfinite, dw) &&
+ eltype(coeff) == Float32 && eltype(dw) == Float32 && backend_ok
+ dv_ok = !expect_dv_finite || all(isfinite, Array(dv_ode))
+ return caches_ok && dv_ok
+ end
+
+ for kind in (:wcsph, :edac),
+ correction in (KernelCorrection(), MixedKernelGradientCorrection()),
+ density_calculator in (ContinuityDensity(), SummationDensity())
+
+ # Zero mass => degenerate coefficient => fallback. Only WCSPH with
+ # ContinuityDensity keeps a finite density and finite RHS.
+ expect_finite = (kind === :wcsph && density_calculator isa ContinuityDensity)
+ @test kernel_correction_fallback_is_valid(kind, correction, density_calculator,
+ backend; mass_value=0.0f0,
+ expect_dv_finite=expect_finite)
+ # Tiny mass below `sqrt(eps(Float32))` threshold => fallback
+ # (only well-defined for ContinuityDensity, where density is independent of mass).
+ if density_calculator isa ContinuityDensity
+ @test kernel_correction_fallback_is_valid(kind, correction,
+ density_calculator, backend;
+ mass_value=1.0f-8,
+ expect_dv_finite=expect_finite)
+ end
+ # Non-finite mass => fallback caches remain finite
+ @test kernel_correction_fallback_is_valid(kind, correction, density_calculator,
+ backend; mass_value=Float32(NaN),
+ expect_dv_finite=false)
+ @test kernel_correction_fallback_is_valid(kind, correction, density_calculator,
+ backend; mass_value=Float32(Inf),
+ expect_dv_finite=false)
+ end
+
+ coordinates = Float32[0.0 0.1 0.2; 0.0 0.0 0.0]
+ collinear = InitialCondition(; coordinates, velocity=zeros(Float32, 2, 3),
+ density=fill(1000.0f0, 3), particle_spacing=spacing)
+ system = correction_fluid(:wcsph, collinear, WendlandC6Kernel{2}(), 2spacing,
+ ContinuityDensity(), GradientCorrection())
+ semi = Semidiscretization(system; neighborhood_search=nothing,
+ parallelization_backend=backend)
+ ode = semidiscretize(semi, (0.0f0, 0.1f0); reset_threads=false)
+ dv_ode = similar(ode.u0.x[1])
+ fill!(dv_ode, 0.0f0)
+ TrixiParticles.kick!(dv_ode, ode.u0.x[1], ode.u0.x[2], ode.p, 0.0f0)
+ matrix = Array(first(ode.p.semi.systems).cache.correction_matrix)
+ identity = Matrix{Float32}(I, 2, 2)
+ @test all(particle -> matrix[:, :, particle] == identity, axes(matrix, 3))
+
+ initial_condition = RectangularShape(spacing, (4, 4), (0.0f0, 0.0f0);
+ density=1000.0f0,
+ coordinates_eltype=Float32)
+ state_equation = StateEquationCole(; sound_speed=10.0f0,
+ reference_density=1000.0f0, exponent=1)
+ system = WeaklyCompressibleSPHSystem(initial_condition;
+ smoothing_kernel=WendlandC6Kernel{2}(),
+ smoothing_length=2spacing,
+ density_calculator=ContinuityDensity(),
+ state_equation)
+ semi = Semidiscretization(system; neighborhood_search=nothing,
+ parallelization_backend=backend)
+ ode = semidiscretize(semi, (0.0f0, 0.1f0); reset_threads=false)
+ v_ode, u_ode = ode.u0.x
+ system = first(ode.p.semi.systems)
+ v = TrixiParticles.wrap_v(v_ode, system, ode.p.semi)
+ u = TrixiParticles.wrap_u(u_ode, system, ode.p.semi)
+ density = collect(range(800.0f0, 1200.0f0; length=size(v, 2)))
+ # Start from a non-constant continuity density, so that the reinitialization
+ # actually has to update values on the GPU.
+ v[end, :] .= Adapt.adapt(backend, density)
+ TrixiParticles.update_nhs!(ode.p.semi, u_ode)
+ TrixiParticles.reinit_density!(system, v, u, v_ode, u_ode, ode.p.semi)
+ @test all(isfinite, Array(v[end, :]))
+
+ # A constant field is the zeroth-order consistency invariant of the Shepard
+ # operator. This checks density and pressure values, not only finiteness.
+ v[end, :] .= 1000.0f0
+ TrixiParticles.reinit_density!(system, v, u, v_ode, u_ode, ode.p.semi)
+ @test Array(v[end, :])≈fill(1000.0f0, size(v, 2)) rtol=2e-5 atol=2e-5
+ @test maximum(abs, Array(system.pressure)) < 1.0f-2
+end
+
@testset verbose=true "Examples $TRIXIPARTICLES_TEST_" begin
@testset verbose=true "Fluid" begin
@trixi_testset "fluid/dam_break_2d_gpu.jl Float64" begin
diff --git a/test/general/corrections.jl b/test/general/corrections.jl
new file mode 100644
index 0000000000..97883fbf80
--- /dev/null
+++ b/test/general/corrections.jl
@@ -0,0 +1,7 @@
+@trixi_testset "Correction Consistency" begin
+ include("corrections/common.jl")
+ include("corrections/lifecycle.jl")
+ include("corrections/shepard.jl")
+ include("corrections/kernel.jl")
+ include("corrections/gradient.jl")
+end
diff --git a/test/general/corrections/common.jl b/test/general/corrections/common.jl
new file mode 100644
index 0000000000..62a097ad2c
--- /dev/null
+++ b/test/general/corrections/common.jl
@@ -0,0 +1,197 @@
+# Set up a single semidiscretized fluid system with the given correction.
+# Re-extract the system from the ODE, since `semidiscretize` replaces systems by
+# runtime copies.
+function correction_setup(correction=nothing; n=9, perturbation=false,
+ density_calculator=ContinuityDensity(), edac=false,
+ pressure_acceleration=:default,
+ velocity=(pos -> SVector(pos[1], pos[2])), buffer_size=nothing,
+ neighborhood_search=GridNeighborhoodSearch{2}())
+ particle_spacing = 1.0 / n
+ smoothing_length = 2.0 * particle_spacing
+ smoothing_kernel = WendlandC6Kernel{2}()
+ fluid = RectangularShape(particle_spacing, (n, n), (0.0, 0.0);
+ density=1000.0, velocity,
+ coordinates_perturbation=perturbation ? 0.1 : nothing)
+
+ if edac
+ if pressure_acceleration === :default
+ system = EntropicallyDampedSPHSystem(fluid; smoothing_kernel,
+ smoothing_length, sound_speed=10.0,
+ density_calculator, correction,
+ buffer_size)
+ else
+ system = EntropicallyDampedSPHSystem(fluid; smoothing_kernel,
+ smoothing_length, sound_speed=10.0,
+ density_calculator, correction,
+ pressure_acceleration, buffer_size)
+ end
+ else
+ state_equation = StateEquationCole(; sound_speed=10.0,
+ reference_density=1000.0, exponent=1)
+ if pressure_acceleration === :default
+ system = WeaklyCompressibleSPHSystem(fluid; smoothing_kernel,
+ smoothing_length, density_calculator,
+ state_equation, correction, buffer_size)
+ else
+ system = WeaklyCompressibleSPHSystem(fluid; smoothing_kernel,
+ smoothing_length, density_calculator,
+ state_equation, correction,
+ pressure_acceleration, buffer_size)
+ end
+ end
+
+ semi = Semidiscretization(system; neighborhood_search,
+ parallelization_backend=SerialBackend())
+ ode = semidiscretize(semi, (0.0, 1.0); reset_threads=false)
+ v_ode = Array(ode.u0.x[1])
+ u_ode = Array(ode.u0.x[2])
+ semi = ode.p.semi
+ system = first(semi.systems)
+
+ return (; system, semi, v_ode, u_ode, particle_spacing)
+end
+
+# Poison all correction caches with `NaN` to verify that the next update recomputes them.
+function fill_correction_cache!(system, value)
+ for name in (:kernel_correction_coefficient, :dw_gamma, :correction_matrix)
+ hasproperty(system.cache, name) || continue
+ fill!(getproperty(system.cache, name), value)
+ end
+ return system
+end
+
+# Recompute all correction caches by running a full `update_systems_and_nhs` pass.
+function update_correction!(setup)
+ (; system, semi, v_ode, u_ode) = setup
+ fill_correction_cache!(system, NaN)
+ TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, 0.0)
+ return setup
+end
+
+struct CorrectionMatrixTestSystem{NDIMS, ELTYPE} <: TrixiParticles.AbstractSystem{NDIMS}
+ mass::Vector{ELTYPE}
+end
+
+function invert_scaled_correction_matrix(::Type{ELTYPE}, ::Val{NDIMS},
+ scale::ELTYPE) where {ELTYPE,
+ NDIMS}
+ matrix = zeros(ELTYPE, NDIMS, NDIMS)
+ for i in 1:NDIMS
+ matrix[i, i] = scale
+ end
+
+ return invert_correction_matrix(matrix)
+end
+
+function invert_correction_matrix(matrix::AbstractMatrix{ELTYPE}) where {ELTYPE}
+ NDIMS = size(matrix, 1)
+ system = CorrectionMatrixTestSystem{NDIMS, ELTYPE}(ones(ELTYPE, 1))
+ correction_matrix = reshape(copy(matrix), NDIMS, NDIMS, 1)
+
+ TrixiParticles.correction_matrix_inversion_step!(correction_matrix, system,
+ DummySemidiscretization())
+ return correction_matrix[:, :, 1]
+end
+
+# Recompute, in a naive loop, the zeroth and first gradient moments and the direct and
+# difference kernel-gradient interpolations of a scalar field. These reference values are
+# compared against the corrections computed by TrixiParticles.
+function correction_moments(setup; field=(pos -> 1.0))
+ (; system, semi, v_ode, u_ode) = setup
+ v = TrixiParticles.wrap_v(v_ode, system, semi)
+ u = TrixiParticles.wrap_u(u_ode, system, semi)
+ coordinates = Array(TrixiParticles.current_coordinates(u, system))
+ values = [field(SVector{2}(view(coordinates, :, particle)))
+ for particle in TrixiParticles.eachparticle(system)]
+ n_particles = TrixiParticles.nparticles(system)
+
+ zeroth_gradient_moment = zeros(2, n_particles)
+ first_gradient_moment = zeros(2, 2, n_particles)
+ direct_gradient = zeros(2, n_particles)
+ difference_gradient = zeros(2, n_particles)
+
+ GC.@preserve v_ode u_ode begin
+ TrixiParticles.foreach_point_neighbor(system, system, coordinates, coordinates,
+ semi) do particle, neighbor, pos_diff,
+ distance
+ pos_diff_ = SVector(pos_diff)
+ volume = TrixiParticles.hydrodynamic_mass(system, neighbor) /
+ TrixiParticles.current_density(v, system, neighbor)
+ gradient = TrixiParticles.smoothing_kernel_grad(system, pos_diff_, distance,
+ particle)
+ neighbor_offset = -pos_diff_
+
+ for i in 1:2
+ zeroth_gradient_moment[i, particle] += volume * gradient[i]
+ direct_gradient[i, particle] += volume * values[neighbor] * gradient[i]
+ difference_gradient[i,
+ particle] += volume *
+ (values[neighbor] - values[particle]) *
+ gradient[i]
+ for j in 1:2
+ first_gradient_moment[i, j,
+ particle] += volume * gradient[i] *
+ neighbor_offset[j]
+ end
+ end
+ end
+ end
+
+ return (; zeroth_gradient_moment, first_gradient_moment, direct_gradient,
+ difference_gradient)
+end
+
+# Find the particle closest to the lower-left corner, where the correction is
+# least accurate due to missing neighbors.
+function corner_particle(system)
+ coordinates = TrixiParticles.initial_coordinates(system)
+ return argmin(eachindex(axes(coordinates, 2))) do particle
+ coordinates[1, particle] + coordinates[2, particle]
+ end
+end
+
+function correction_restart_result(correction; edac, density_calculator)
+ direct = correction_setup(correction; edac, density_calculator,
+ pressure_acceleration=nothing)
+ (; system, semi, v_ode, u_ode) = direct
+ v = TrixiParticles.wrap_v(v_ode, system, semi)
+ u = TrixiParticles.wrap_u(u_ode, system, semi)
+
+ for particle in TrixiParticles.eachparticle(system)
+ v[1, particle] = 0.01particle
+ v[2, particle] = -0.02particle
+ u[1, particle] += 1.0e-3 * sin(particle)
+ u[2, particle] += 1.0e-3 * cos(particle)
+ end
+ if edac
+ v[3, :] .= range(1.0, 2.0; length=size(v, 2))
+ end
+ if density_calculator isa ContinuityDensity
+ v[end, :] .= range(900.0, 1100.0; length=size(v, 2))
+ end
+
+ dv_direct = zero(v_ode)
+ TrixiParticles.kick!(dv_direct, v_ode, u_ode,
+ (; semi, split_integration_data=nothing), 0.0)
+
+ restarted = correction_setup(correction; edac, density_calculator,
+ pressure_acceleration=nothing)
+ mock_solution = (; u=[(; x=(copy(v_ode), copy(u_ode)))])
+ restart_with!(restarted.semi, mock_solution; reset_threads=false)
+ ode_restart = semidiscretize(restarted.semi, (0.0, 1.0); reset_threads=false)
+ v_restart = Array(ode_restart.u0.x[1])
+ u_restart = Array(ode_restart.u0.x[2])
+ dv_restart = zero(v_restart)
+ TrixiParticles.kick!(dv_restart, v_restart, u_restart,
+ (; semi=ode_restart.p.semi, split_integration_data=nothing), 0.0)
+
+ cache = first(ode_restart.p.semi.systems).cache
+ cache_finite = all((:kernel_correction_coefficient, :dw_gamma,
+ :correction_matrix)) do name
+ return !hasproperty(cache, name) || all(isfinite, getproperty(cache, name))
+ end
+
+ return (; state_equal=v_restart == v_ode && u_restart == u_ode,
+ rhs_equal=isapprox(dv_restart, dv_direct; rtol=2e-13, atol=2e-13),
+ cache_finite)
+end
diff --git a/test/general/corrections/gradient.jl b/test/general/corrections/gradient.jl
new file mode 100644
index 0000000000..276830d36d
--- /dev/null
+++ b/test/general/corrections/gradient.jl
@@ -0,0 +1,170 @@
+@testset "Gradient and blended corrections" begin
+ identity_matrix = Matrix{Float64}(I, 2, 2)
+ linear_field(pos) = 2.0 + 3.0 * pos[1] - 2.0 * pos[2]
+ exact_gradient = [3.0, -2.0]
+
+ @test_throws ArgumentError BlendedGradientCorrection(-0.1)
+ @test_throws ArgumentError BlendedGradientCorrection(1.1)
+
+ for correction in (GradientCorrection(), BlendedGradientCorrection(0.4)),
+ edac in (false, true)
+ setup = correction_setup(correction; edac, pressure_acceleration=nothing)
+ update_correction!(setup)
+ @test all(isfinite, setup.system.cache.correction_matrix)
+ end
+
+ for perturbation in (false, true)
+ raw_setup = update_correction!(correction_setup(nothing; perturbation))
+ raw_moments = correction_moments(raw_setup; field=linear_field)
+
+ gradient_setup = update_correction!(correction_setup(GradientCorrection();
+ perturbation))
+ gradient_moments = correction_moments(gradient_setup; field=linear_field)
+ @test maximum(particle -> norm(gradient_moments.first_gradient_moment[:, :,
+ particle] -
+ identity_matrix),
+ TrixiParticles.eachparticle(gradient_setup.system)) < 2e-12
+ @test maximum(particle -> norm(gradient_moments.difference_gradient[:, particle] -
+ exact_gradient),
+ TrixiParticles.eachparticle(gradient_setup.system)) < 5e-12
+
+ blending_factor = 0.4
+ blended_setup = update_correction!(correction_setup(BlendedGradientCorrection(blending_factor);
+ perturbation))
+ blended_moments = correction_moments(blended_setup; field=linear_field)
+ expected = (1 - blending_factor) * raw_moments.first_gradient_moment
+ for particle in TrixiParticles.eachparticle(blended_setup.system)
+ expected[:, :, particle] .+= blending_factor * identity_matrix
+ end
+ @test maximum(abs, blended_moments.first_gradient_moment - expected) < 2e-12
+
+ corner = corner_particle(raw_setup.system)
+ @test norm(raw_moments.first_gradient_moment[:, :, corner] - identity_matrix) > 1e-2
+ end
+
+ density32 = fill(1000.0f0, 4)
+ mass32 = fill(10.0f0, 4)
+ state_equation32 = StateEquationCole(; sound_speed=10.0f0,
+ reference_density=1000.0f0, exponent=1)
+ for correction in (GradientCorrection(), BlendedGradientCorrection(0.4f0),
+ MixedKernelGradientCorrection())
+ boundary = BoundaryModelDummyParticles(density32, mass32, SummationDensity(),
+ WendlandC6Kernel{2}(), 0.2f0;
+ state_equation=state_equation32, correction)
+ @test eltype(boundary.cache.correction_matrix) == Float32
+ if hasproperty(boundary.cache, :dw_gamma)
+ @test eltype(boundary.cache.dw_gamma) == Float32
+ end
+ end
+
+ @testset "scale-independent inversion" begin
+ cases = ((Float32, 2, 1.0f-30),
+ (Float32, 2, 1.0f20),
+ (Float32, 3, 1.0f-16),
+ (Float32, 3, 1.0f13),
+ (Float64, 2, 1.0e-200),
+ (Float64, 3, 1.0e150))
+ for (ELTYPE, NDIMS, scale) in cases
+ inverse = invert_scaled_correction_matrix(ELTYPE, Val(NDIMS), scale)
+ expected = Matrix{ELTYPE}(I, NDIMS, NDIMS) / scale
+ @test all(isfinite, inverse)
+ @test inverse ≈ expected rtol = 10eps(ELTYPE)
+ end
+
+ # The normalized matrix is valid, but rescaling its inverse overflows.
+ inverse = invert_scaled_correction_matrix(Float32, Val(1), 1.0f-39)
+ @test inverse == ones(Float32, 1, 1)
+
+ # The entry scale squared is finite, but this matrix's determinant overflows.
+ scale = 1.4f19
+ matrix = Float32[scale scale; -scale scale]
+ inverse = invert_correction_matrix(matrix)
+ expected = Float32[0.5 -0.5; 0.5 0.5] / scale
+ @test all(isfinite, inverse)
+ @test inverse ≈ expected rtol = 10eps(Float32)
+ end
+
+ particle_spacing = 0.25
+ particles = RectangularShape(particle_spacing, (4, 4, 4), (0.0, 0.0, 0.0);
+ density=1000.0)
+ state_equation = StateEquationCole(; sound_speed=10.0,
+ reference_density=1000.0, exponent=1)
+ system = WeaklyCompressibleSPHSystem(particles;
+ smoothing_kernel=WendlandC6Kernel{3}(),
+ smoothing_length=2particle_spacing,
+ density_calculator=ContinuityDensity(),
+ state_equation,
+ correction=GradientCorrection())
+ semi = Semidiscretization(system; parallelization_backend=SerialBackend())
+ ode = semidiscretize(semi, (0.0, 1.0); reset_threads=false)
+ v_ode = Array(ode.u0.x[1])
+ u_ode = Array(ode.u0.x[2])
+ system = first(ode.p.semi.systems)
+ TrixiParticles.update_systems_and_nhs(v_ode, u_ode, ode.p.semi, 0.0)
+ v = TrixiParticles.wrap_v(v_ode, system, ode.p.semi)
+ u = TrixiParticles.wrap_u(u_ode, system, ode.p.semi)
+ coordinates = Array(TrixiParticles.current_coordinates(u, system))
+ first_moment = zeros(3, 3)
+ GC.@preserve v_ode u_ode begin
+ TrixiParticles.foreach_point_neighbor(system, system, coordinates, coordinates,
+ ode.p.semi;
+ points=1:1) do particle,
+ neighbor,
+ pos_diff,
+ distance
+ volume = TrixiParticles.hydrodynamic_mass(system, neighbor) /
+ TrixiParticles.current_density(v, system, neighbor)
+ gradient = TrixiParticles.smoothing_kernel_grad(system, SVector(pos_diff),
+ distance, particle)
+ for j in 1:3, i in 1:3
+ first_moment[i, j] -= volume * gradient[i] * pos_diff[j]
+ end
+ end
+ end
+ @test first_moment ≈ Matrix{Float64}(I, 3, 3) atol = 3e-12
+
+ for y_offset in (0.0, 1.0e-12)
+ coordinates = [0.0 0.1 0.2; 0.0 y_offset 0.0]
+ initial = InitialCondition(; coordinates, velocity=zeros(2, 3),
+ density=fill(1000.0, 3), particle_spacing=0.1)
+ system = WeaklyCompressibleSPHSystem(initial;
+ smoothing_kernel=WendlandC6Kernel{2}(),
+ smoothing_length=0.2,
+ density_calculator=ContinuityDensity(),
+ state_equation,
+ correction=GradientCorrection())
+ semi = Semidiscretization(system; parallelization_backend=SerialBackend())
+ ode = semidiscretize(semi, (0.0, 1.0); reset_threads=false)
+ v_ode = Array(ode.u0.x[1])
+ u_ode = Array(ode.u0.x[2])
+ system = first(ode.p.semi.systems)
+ TrixiParticles.update_systems_and_nhs(v_ode, u_ode, ode.p.semi, 0.0)
+ for particle in TrixiParticles.eachparticle(system)
+ @test TrixiParticles.correction_matrix(system, particle) == I
+ end
+ end
+
+ analytic_density_rate = -2000.0
+ errors = Dict{Any, Float64}()
+ for correction in (nothing, GradientCorrection(), BlendedGradientCorrection(0.4))
+ setup = correction_setup(correction)
+ dv_ode = zero(setup.v_ode)
+ TrixiParticles.kick!(dv_ode, setup.v_ode, setup.u_ode,
+ (; semi=setup.semi, split_integration_data=nothing), 0.0)
+ dv = TrixiParticles.wrap_v(dv_ode, setup.system, setup.semi)
+ error = dv[end, :] .- analytic_density_rate
+ errors[correction] = sqrt(sum(abs2, error) / length(error))
+ end
+ @test errors[GradientCorrection()] < 2e-10
+ @test errors[BlendedGradientCorrection(0.4)] < errors[nothing]
+ @test errors[nothing] > 1.0
+
+ for correction in (GradientCorrection(), BlendedGradientCorrection(0.4)),
+ edac in (false, true),
+ density_calculator in (SummationDensity(), ContinuityDensity())
+ result = correction_restart_result(correction; edac, density_calculator)
+ @test result.state_equal
+ @test result.rhs_equal
+ @test result.cache_finite
+ end
+end
diff --git a/test/general/corrections/kernel.jl b/test/general/corrections/kernel.jl
new file mode 100644
index 0000000000..9728b3cb9b
--- /dev/null
+++ b/test/general/corrections/kernel.jl
@@ -0,0 +1,126 @@
+@testset "Kernel correction" begin
+ for edac in (false, true)
+ setup = correction_setup(KernelCorrection(); edac,
+ pressure_acceleration=nothing)
+ update_correction!(setup)
+
+ @test all(isfinite, setup.system.cache.kernel_correction_coefficient)
+ @test all(isfinite, setup.system.cache.dw_gamma)
+ end
+
+ for perturbation in (false, true)
+ setup = update_correction!(correction_setup(KernelCorrection(); perturbation))
+ moments = correction_moments(setup)
+ @test maximum(abs, moments.zeroth_gradient_moment) < 2e-12
+ end
+
+ density32 = fill(1000.0f0, 4)
+ mass32 = fill(10.0f0, 4)
+ state_equation = StateEquationCole(; sound_speed=10.0f0,
+ reference_density=1000.0f0, exponent=1)
+ boundary = BoundaryModelDummyParticles(density32, mass32, SummationDensity(),
+ WendlandC6Kernel{2}(), 0.2f0;
+ state_equation,
+ correction=KernelCorrection())
+ @test eltype(boundary.cache.dw_gamma) == Float32
+
+ for edac in (false, true),
+ density_calculator in (SummationDensity(),
+ ContinuityDensity())
+ result = correction_restart_result(KernelCorrection(); edac, density_calculator)
+ @test result.state_equal
+ @test result.rhs_equal
+ @test result.cache_finite
+ end
+
+ @testset "fallback to uncorrected gradient for degenerate coefficients" begin
+ for correction in (KernelCorrection(), MixedKernelGradientCorrection()),
+ edac in (false, true),
+ density_calculator in (SummationDensity(), ContinuityDensity())
+
+ # Use a pressure formulation compatible with asymmetric kernel corrections
+ # for EDAC systems.
+ setup = correction_setup(correction; edac, density_calculator, n=4,
+ pressure_acceleration=nothing)
+ setup.system.mass .= 0
+ update_correction!(setup)
+ @test all(==(1), setup.system.cache.kernel_correction_coefficient)
+ @test all(iszero, setup.system.cache.dw_gamma)
+ @test all(isfinite, setup.system.cache.kernel_correction_coefficient)
+ @test all(isfinite, setup.system.cache.dw_gamma)
+ # Only WCSPH with ContinuityDensity keeps a finite density with zero mass;
+ # SummationDensity yields zero density and EDAC couples pressure in `v`.
+ if density_calculator isa ContinuityDensity && !edac
+ dv = zero(setup.v_ode)
+ TrixiParticles.kick!(dv, setup.v_ode, setup.u_ode,
+ (; semi=setup.semi, split_integration_data=nothing),
+ 0.0)
+ @test all(isfinite, dv)
+ end
+
+ # Tiny mass below `sqrt(eps(T))` => fallback (only well-defined for
+ # ContinuityDensity, where density is independent of mass).
+ if density_calculator isa ContinuityDensity
+ setup = correction_setup(correction; edac, density_calculator, n=4,
+ pressure_acceleration=nothing)
+ setup.system.mass .= 1.0e-12
+ update_correction!(setup)
+ @test all(==(1), setup.system.cache.kernel_correction_coefficient)
+ @test all(iszero, setup.system.cache.dw_gamma)
+ end
+
+ # Non-finite or negative coefficients => fallback caches remain finite
+ # For SummationDensity, a negative mass yields a positive volume
+ # (density is also negative), so it does not reliably produce a
+ # degenerate coefficient.
+ bad_masses = density_calculator isa ContinuityDensity ?
+ (NaN, Inf, -1.0) : (NaN, Inf)
+ for bad_mass in bad_masses
+ setup = correction_setup(correction; edac, density_calculator, n=4,
+ pressure_acceleration=nothing)
+ setup.system.mass .= bad_mass
+ update_correction!(setup)
+ @test all(==(1), setup.system.cache.kernel_correction_coefficient)
+ @test all(iszero, setup.system.cache.dw_gamma)
+ @test all(isfinite, setup.system.cache.kernel_correction_coefficient)
+ @test all(isfinite, setup.system.cache.dw_gamma)
+ end
+ # ContinuityDensity with non-finite density also yields fallback
+ if density_calculator isa ContinuityDensity
+ setup = correction_setup(correction; edac, density_calculator, n=4,
+ pressure_acceleration=nothing)
+ v = TrixiParticles.wrap_v(setup.v_ode, setup.system, setup.semi)
+ v[end, :] .= NaN
+ update_correction!(setup)
+ @test all(==(1), setup.system.cache.kernel_correction_coefficient)
+ @test all(iszero, setup.system.cache.dw_gamma)
+ @test all(isfinite, setup.system.cache.kernel_correction_coefficient)
+ end
+ end
+
+ # The fallback disables the correction: dw_gamma=0 and coefficient=1
+ # imply the corrected kernel gradient reduces to the uncorrected one.
+ for correction in (KernelCorrection(), MixedKernelGradientCorrection())
+ setup = correction_setup(correction; n=4, pressure_acceleration=nothing)
+ setup.system.mass .= 0
+ # ContinuityDensity ensures finite density for a well-defined RHS
+ # check, but the fallback for zero mass is independent of density.
+ update_correction!(setup)
+ system = setup.system
+ pos_diff = SVector(0.1, 0.2)
+ distance = sqrt(sum(abs2, pos_diff))
+ h = TrixiParticles.initial_smoothing_length(system)
+ kernel = TrixiParticles.system_smoothing_kernel(system)
+ for particle in TrixiParticles.eachparticle(system)
+ # Only test particles that actually fell back (all in this degenerate setup)
+ @test setup.system.cache.kernel_correction_coefficient[particle] == 1
+ corr_grad = TrixiParticles.corrected_kernel_grad_unsafe(kernel, pos_diff,
+ distance, h,
+ correction, system,
+ particle)
+ uncorr = TrixiParticles.kernel_grad(kernel, pos_diff, distance, h)
+ @test corr_grad ≈ uncorr
+ end
+ end
+ end
+end
diff --git a/test/general/corrections/lifecycle.jl b/test/general/corrections/lifecycle.jl
new file mode 100644
index 0000000000..2b6dc4896b
--- /dev/null
+++ b/test/general/corrections/lifecycle.jl
@@ -0,0 +1,353 @@
+@testset "Cross-system update ordering" begin
+ # Two overlapping fluids with different corrections, where one system's update
+ # depends on the final density of the other. This verifies that the globally staged
+ # update in `update_systems_and_nhs` produces the same results independent of the
+ # order in which the systems are passed to the `Semidiscretization`.
+ function ordered_correction_result(reverse_order; edac)
+ spacing = 0.1
+ smoothing_length = 2 * spacing
+ smoothing_kernel = WendlandC6Kernel{2}()
+ density = 1000.0
+ velocity(pos) = SVector(0.1 + pos[1], -0.2 - pos[2])
+ pressure(pos) = 1.0 + 2pos[1] - pos[2]
+
+ # Offset the second block so that both systems interact through their
+ # neighborhood search.
+ gradient_initial = RectangularShape(spacing, (3, 3), (0.0, 0.0);
+ density, velocity, pressure)
+ shepard_initial = RectangularShape(spacing, (3, 3), (0.05, 0.025);
+ density, velocity, pressure)
+
+ if edac
+ gradient_system = EntropicallyDampedSPHSystem(gradient_initial;
+ smoothing_kernel,
+ smoothing_length,
+ sound_speed=10.0,
+ pressure_acceleration=nothing,
+ density_calculator=ContinuityDensity(),
+ correction=GradientCorrection())
+ shepard_system = EntropicallyDampedSPHSystem(shepard_initial;
+ smoothing_kernel,
+ smoothing_length,
+ sound_speed=10.0,
+ density_calculator=SummationDensity(),
+ correction=ShepardKernelCorrection())
+ else
+ state_equation = StateEquationCole(; sound_speed=10.0,
+ reference_density=density, exponent=1)
+ gradient_system = WeaklyCompressibleSPHSystem(gradient_initial;
+ smoothing_kernel,
+ smoothing_length,
+ state_equation,
+ density_calculator=ContinuityDensity(),
+ correction=GradientCorrection())
+ shepard_system = WeaklyCompressibleSPHSystem(shepard_initial;
+ smoothing_kernel,
+ smoothing_length,
+ state_equation,
+ density_calculator=SummationDensity(),
+ correction=ShepardKernelCorrection())
+ end
+
+ # Vary the system order to check that correction staging is order-independent.
+ systems = reverse_order ? (shepard_system, gradient_system) :
+ (gradient_system, shepard_system)
+ semi = Semidiscretization(systems...; neighborhood_search=nothing,
+ parallelization_backend=SerialBackend())
+ ode = semidiscretize(semi, (0.0, 1.0); reset_threads=false)
+ v_ode = Array(ode.u0.x[1])
+ u_ode = Array(ode.u0.x[2])
+ dv_ode = zero(v_ode)
+ # Evaluate the RHS, which triggers the staged correction updates.
+ TrixiParticles.kick!(dv_ode, v_ode, u_ode,
+ (; semi=ode.p.semi, split_integration_data=nothing), 0.0)
+
+ # Recover the systems from the semidiscretization, since the order is swapped.
+ gradient_system = only(system
+ for system in ode.p.semi.systems
+ if system.correction isa GradientCorrection)
+ shepard_system = only(system
+ for system in ode.p.semi.systems
+ if system.correction isa ShepardKernelCorrection)
+ v_gradient = TrixiParticles.wrap_v(v_ode, gradient_system, ode.p.semi)
+ v_shepard = TrixiParticles.wrap_v(v_ode, shepard_system, ode.p.semi)
+ dv_gradient = TrixiParticles.wrap_v(dv_ode, gradient_system, ode.p.semi)
+ dv_shepard = TrixiParticles.wrap_v(dv_ode, shepard_system, ode.p.semi)
+
+ return (;
+ gradient_density=copy(TrixiParticles.current_density(v_gradient,
+ gradient_system)),
+ shepard_density=copy(TrixiParticles.current_density(v_shepard,
+ shepard_system)),
+ gradient_pressure=copy(TrixiParticles.current_pressure(v_gradient,
+ gradient_system)),
+ shepard_pressure=copy(TrixiParticles.current_pressure(v_shepard,
+ shepard_system)),
+ correction_matrix=copy(gradient_system.cache.correction_matrix),
+ shepard_coefficient=copy(shepard_system.cache.kernel_correction_coefficient),
+ gradient_rhs=copy(dv_gradient), shepard_rhs=copy(dv_shepard))
+ end
+
+ # Check all correction-coupled quantities for both WCSPH and EDAC systems.
+ for edac in (false, true)
+ forward = ordered_correction_result(false; edac)
+ reverse = ordered_correction_result(true; edac)
+
+ @test forward.gradient_density≈reverse.gradient_density rtol=5e-13 atol=5e-13
+ @test forward.shepard_density≈reverse.shepard_density rtol=5e-13 atol=5e-13
+ @test forward.gradient_pressure≈reverse.gradient_pressure rtol=5e-13 atol=5e-13
+ @test forward.shepard_pressure≈reverse.shepard_pressure rtol=5e-13 atol=5e-13
+ @test forward.correction_matrix≈reverse.correction_matrix rtol=5e-13 atol=5e-13
+ @test forward.shepard_coefficient≈reverse.shepard_coefficient rtol=5e-13 atol=5e-13
+ @test forward.gradient_rhs≈reverse.gradient_rhs rtol=1e-11 atol=1e-10
+ @test forward.shepard_rhs≈reverse.shepard_rhs rtol=1e-11 atol=1e-10
+ end
+
+ # The mixed gradient/Shepard case above has only one system that mutates density and
+ # therefore cannot expose sequential density correction. Use two overlapping Shepard
+ # systems so reversing their declaration order would reveal coefficients assembled from
+ # an already-corrected neighbor density.
+ function ordered_shepard_result(reverse_order; edac)
+ spacing = 0.1
+ smoothing_length = 2 * spacing
+ smoothing_kernel = WendlandC6Kernel{2}()
+ density = 1000.0
+ initial_a = RectangularShape(spacing, (3, 3), (0.0, 0.0); density)
+ initial_b = RectangularShape(spacing, (3, 3), (0.05, 0.025); density)
+
+ function make_system(initial_condition)
+ if edac
+ return EntropicallyDampedSPHSystem(initial_condition;
+ smoothing_kernel,
+ smoothing_length,
+ sound_speed=10.0,
+ density_calculator=SummationDensity(),
+ correction=ShepardKernelCorrection())
+ end
+
+ state_equation = StateEquationCole(; sound_speed=10.0,
+ reference_density=density, exponent=1)
+ return WeaklyCompressibleSPHSystem(initial_condition;
+ smoothing_kernel,
+ smoothing_length,
+ state_equation,
+ density_calculator=SummationDensity(),
+ correction=ShepardKernelCorrection())
+ end
+
+ systems = reverse_order ? (make_system(initial_b), make_system(initial_a)) :
+ (make_system(initial_a), make_system(initial_b))
+ semi = Semidiscretization(systems...; neighborhood_search=nothing,
+ parallelization_backend=SerialBackend())
+ ode = semidiscretize(semi, (0.0, 1.0); reset_threads=false)
+ v_ode = Array(ode.u0.x[1])
+ u_ode = Array(ode.u0.x[2])
+ dv_ode = zero(v_ode)
+ TrixiParticles.kick!(dv_ode, v_ode, u_ode,
+ (; semi=ode.p.semi, split_integration_data=nothing), 0.0)
+
+ # Compare the same physical particle clouds after reversing their tuple positions.
+ index_a, index_b = reverse_order ? (2, 1) : (1, 2)
+ system_a = ode.p.semi.systems[index_a]
+ system_b = ode.p.semi.systems[index_b]
+ v_a = TrixiParticles.wrap_v(v_ode, system_a, ode.p.semi)
+ v_b = TrixiParticles.wrap_v(v_ode, system_b, ode.p.semi)
+ dv_a = TrixiParticles.wrap_v(dv_ode, system_a, ode.p.semi)
+ dv_b = TrixiParticles.wrap_v(dv_ode, system_b, ode.p.semi)
+
+ return (;
+ density_a=copy(TrixiParticles.current_density(v_a, system_a)),
+ density_b=copy(TrixiParticles.current_density(v_b, system_b)),
+ pressure_a=copy(TrixiParticles.current_pressure(v_a, system_a)),
+ pressure_b=copy(TrixiParticles.current_pressure(v_b, system_b)),
+ coefficient_a=copy(system_a.cache.kernel_correction_coefficient),
+ coefficient_b=copy(system_b.cache.kernel_correction_coefficient),
+ rhs_a=copy(dv_a), rhs_b=copy(dv_b))
+ end
+
+ # Coefficients and every quantity derived from corrected density must be independent of
+ # system declaration order for both explicit pressure models.
+ for edac in (false, true)
+ forward = ordered_shepard_result(false; edac)
+ reverse = ordered_shepard_result(true; edac)
+
+ @test forward.density_a≈reverse.density_a rtol=5e-13 atol=5e-13
+ @test forward.density_b≈reverse.density_b rtol=5e-13 atol=5e-13
+ @test forward.pressure_a≈reverse.pressure_a rtol=5e-13 atol=5e-13
+ @test forward.pressure_b≈reverse.pressure_b rtol=5e-13 atol=5e-13
+ @test forward.coefficient_a≈reverse.coefficient_a rtol=5e-13 atol=5e-13
+ @test forward.coefficient_b≈reverse.coefficient_b rtol=5e-13 atol=5e-13
+ @test forward.rhs_a≈reverse.rhs_a rtol=1e-11 atol=1e-10
+ @test forward.rhs_b≈reverse.rhs_b rtol=1e-11 atol=1e-10
+ end
+end
+
+@testset "Boundary density before pressure" begin
+ # Boundary pressure must be computed from the Shepard-corrected density, so the
+ # density update has to run before pressure evaluation.
+ n = 5
+ particle_spacing = 1.0 / n
+ smoothing_kernel = WendlandC6Kernel{2}()
+ particles = RectangularShape(particle_spacing, (n, n), (0.0, 0.0); density=1000.0)
+ state_equation = StateEquationCole(; sound_speed=10.0,
+ reference_density=1000.0, exponent=1)
+ boundary_model = BoundaryModelDummyParticles(particles.density, particles.mass,
+ SummationDensity(), smoothing_kernel,
+ 2particle_spacing; state_equation,
+ correction=ShepardKernelCorrection())
+ boundary = WallBoundarySystem(particles, boundary_model)
+ semi = Semidiscretization(boundary; parallelization_backend=SerialBackend())
+ ode = semidiscretize(semi, (0.0, 1.0); reset_threads=false)
+ v_ode = Array(ode.u0.x[1])
+ u_ode = Array(ode.u0.x[2])
+ boundary = first(ode.p.semi.systems)
+ TrixiParticles.update_systems_and_nhs(v_ode, u_ode, ode.p.semi, 0.0)
+
+ # The boundary pressure must match the state equation evaluated at the
+ # (Shepard-corrected) boundary density.
+ @test boundary.boundary_model.pressure ≈
+ state_equation.(boundary.boundary_model.cache.density)
+end
+
+@testset "Structure correction lifecycle" begin
+ particle_spacing = 0.1
+ smoothing_length = 2particle_spacing
+ smoothing_kernel = WendlandC6Kernel{2}()
+ density = 1000.0
+ particles = RectangularShape(particle_spacing, (3, 3), (0.0, 0.0); density)
+ state_equation = StateEquationCole(; sound_speed=10.0,
+ reference_density=density, exponent=1)
+
+ function structure_setup(correction)
+ boundary_model = BoundaryModelDummyParticles(particles.density, particles.mass,
+ SummationDensity(), smoothing_kernel,
+ smoothing_length;
+ state_equation, correction)
+ system = TotalLagrangianSPHSystem(particles; smoothing_kernel, smoothing_length,
+ young_modulus=1.0e6, poisson_ratio=0.3,
+ boundary_model)
+ semi = Semidiscretization(system; neighborhood_search=nothing,
+ parallelization_backend=SerialBackend())
+ ode = semidiscretize(semi, (0.0, 1.0); reset_threads=false)
+ v_ode = Array(ode.u0.x[1])
+ u_ode = Array(ode.u0.x[2])
+ system = first(ode.p.semi.systems)
+ TrixiParticles.update_systems_and_nhs(v_ode, u_ode, ode.p.semi, 0.0)
+
+ return (; system, semi=ode.p.semi, v_ode, u_ode)
+ end
+
+ # Reconstruct the density numerator from the lifecycle result, rho_corrected * c, and
+ # compare it with an independent summation. The second assertion makes the test sensitive
+ # to the old behavior, where the unchanged initial density was normalized instead.
+ shepard = structure_setup(ShepardKernelCorrection())
+ v_shepard = TrixiParticles.wrap_v(shepard.v_ode, shepard.system, shepard.semi)
+ u_shepard = TrixiParticles.wrap_u(shepard.u_ode, shepard.system, shepard.semi)
+ raw_density = zeros(TrixiParticles.nparticles(shepard.system))
+ TrixiParticles.summation_density!(shepard.system, shepard.semi, u_shepard,
+ shepard.u_ode, raw_density)
+ corrected_density = TrixiParticles.current_density(v_shepard, shepard.system)
+ coefficient = shepard.system.boundary_model.cache.kernel_correction_coefficient
+ @test corrected_density .* coefficient ≈ raw_density atol = 5e-13
+ @test maximum(abs, raw_density .- density) > 1.0
+
+ # The optimized TLSPH path assembles its density numerator alongside the Shepard
+ # coefficient, but keeps the numerator in scratch storage until every system has
+ # assembled its coefficient. Reverse a coupled fluid/TLSPH pair to ensure that this
+ # fusion does not reintroduce declaration-order dependence.
+ function ordered_fluid_structure_result(reverse_order)
+ fluid_particles = RectangularShape(particle_spacing, (3, 3), (0.0, 0.0); density)
+ fluid = WeaklyCompressibleSPHSystem(fluid_particles;
+ smoothing_kernel, smoothing_length,
+ state_equation,
+ density_calculator=SummationDensity(),
+ correction=ShepardKernelCorrection())
+
+ structure_particles = RectangularShape(particle_spacing, (3, 2), (0.0, -0.15);
+ density=1200.0)
+ hydrodynamic_density = fill(density,
+ TrixiParticles.nparticles(structure_particles))
+ hydrodynamic_mass = fill(density * particle_spacing^2,
+ TrixiParticles.nparticles(structure_particles))
+ boundary_model = BoundaryModelDummyParticles(hydrodynamic_density,
+ hydrodynamic_mass,
+ SummationDensity(), smoothing_kernel,
+ smoothing_length;
+ state_equation,
+ correction=ShepardKernelCorrection())
+ structure = TotalLagrangianSPHSystem(structure_particles;
+ smoothing_kernel, smoothing_length,
+ young_modulus=1.0e6, poisson_ratio=0.3,
+ boundary_model)
+ systems = reverse_order ? (structure, fluid) : (fluid, structure)
+ semi = Semidiscretization(systems...; neighborhood_search=nothing,
+ parallelization_backend=SerialBackend())
+ ode = semidiscretize(semi, (0.0, 1.0); reset_threads=false)
+ v_ode = Array(ode.u0.x[1])
+ u_ode = Array(ode.u0.x[2])
+ dv_ode = zero(v_ode)
+ TrixiParticles.kick!(dv_ode, v_ode, u_ode,
+ (; semi=ode.p.semi, split_integration_data=nothing), 0.0)
+
+ fluid = only(system
+ for system in ode.p.semi.systems
+ if system isa WeaklyCompressibleSPHSystem)
+ structure = only(system
+ for system in ode.p.semi.systems
+ if system isa TotalLagrangianSPHSystem)
+ v_fluid = TrixiParticles.wrap_v(v_ode, fluid, ode.p.semi)
+ v_structure = TrixiParticles.wrap_v(v_ode, structure, ode.p.semi)
+ dv_fluid = TrixiParticles.wrap_v(dv_ode, fluid, ode.p.semi)
+ dv_structure = TrixiParticles.wrap_v(dv_ode, structure, ode.p.semi)
+
+ return (;
+ fluid_density=copy(TrixiParticles.current_density(v_fluid, fluid)),
+ structure_density=copy(TrixiParticles.current_density(v_structure,
+ structure)),
+ fluid_coefficient=copy(fluid.cache.kernel_correction_coefficient),
+ structure_coefficient=copy(structure.boundary_model.cache.kernel_correction_coefficient),
+ fluid_rhs=copy(dv_fluid), structure_rhs=copy(dv_structure))
+ end
+
+ forward = ordered_fluid_structure_result(false)
+ reverse = ordered_fluid_structure_result(true)
+ @test forward.fluid_density≈reverse.fluid_density rtol=5e-13 atol=5e-13
+ @test forward.structure_density≈reverse.structure_density rtol=5e-13 atol=5e-13
+ @test forward.fluid_coefficient≈reverse.fluid_coefficient rtol=5e-13 atol=5e-13
+ @test forward.structure_coefficient≈reverse.structure_coefficient rtol=5e-13 atol=5e-13
+ @test forward.fluid_rhs≈reverse.fluid_rhs rtol=1e-11 atol=1e-10
+ @test forward.structure_rhs≈reverse.structure_rhs rtol=1e-11 atol=1e-10
+
+ # TLSPH has a material gradient correction and a separate hydrodynamic boundary
+ # correction. Verify that the ordinary structural gradient remains untouched while the
+ # FSI-specific path consumes the nonidentity boundary correction matrix.
+ gradient = structure_setup(GradientCorrection())
+ particle = first(TrixiParticles.eachparticle(gradient.system))
+ pos_diff = SVector(0.05, 0.025)
+ distance = norm(pos_diff)
+ raw_gradient = TrixiParticles.kernel_grad(smoothing_kernel, pos_diff, distance,
+ smoothing_length)
+ correction_matrix = TrixiParticles.correction_matrix(gradient.system, particle)
+ hydrodynamic_gradient = TrixiParticles.hydrodynamic_smoothing_kernel_grad(gradient.system,
+ pos_diff,
+ distance,
+ particle)
+
+ @test norm(correction_matrix - I) > 1e-2
+ @test TrixiParticles.smoothing_kernel_grad(gradient.system, pos_diff, distance,
+ particle) ≈ raw_gradient
+ @test hydrodynamic_gradient ≈ correction_matrix * raw_gradient
+
+ # Rigid-body correction caches are not implemented. Reject both density and gradient
+ # corrections at construction instead of allowing a later crash or stale normalization.
+ error_message = "corrections in `BoundaryModelDummyParticles` are not supported " *
+ "for `RigidBodySystem`"
+ for correction in (ShepardKernelCorrection(), GradientCorrection())
+ boundary_model = BoundaryModelDummyParticles(particles.density, particles.mass,
+ SummationDensity(), smoothing_kernel,
+ smoothing_length;
+ state_equation, correction)
+ @test_throws ArgumentError(error_message) RigidBodySystem(particles;
+ boundary_model)
+ end
+end
diff --git a/test/general/corrections/shepard.jl b/test/general/corrections/shepard.jl
new file mode 100644
index 0000000000..bb2653de4f
--- /dev/null
+++ b/test/general/corrections/shepard.jl
@@ -0,0 +1,74 @@
+@testset "Shepard correction" begin
+ # Recompute the correction after a full update pass.
+ setup = update_correction!(correction_setup(ShepardKernelCorrection();
+ density_calculator=SummationDensity()))
+ v = TrixiParticles.wrap_v(setup.v_ode, setup.system, setup.semi)
+ density = TrixiParticles.current_density(v, setup.system)
+
+ # The corrected coefficients must be finite, and the pressure must be computed
+ # from the Shepard-corrected density.
+ @test all(isfinite, setup.system.cache.kernel_correction_coefficient)
+ @test setup.system.pressure ≈ setup.system.state_equation.(density)
+
+ setup_edac = update_correction!(correction_setup(ShepardKernelCorrection();
+ density_calculator=SummationDensity(),
+ edac=true))
+ @test all(isfinite, setup_edac.system.cache.kernel_correction_coefficient)
+ @test all(isfinite, setup_edac.system.cache.density)
+
+ # Only nonfinite and nonpositive coefficients are invalid. In particular,
+ # Float32 values below `sqrt(eps(Float32))` remain valid Shepard normalizers.
+ coefficients = Float32[0, -1, NaN, Inf, 1.0f-4]
+ TrixiParticles.sanitize_kernel_correction_coefficient!(coefficients, setup.system,
+ setup.semi)
+ @test coefficients == Float32[1, 1, 1, 1, 1.0f-4]
+
+ # Correction caches include inactive open-boundary buffer entries. Sanitizing
+ # every coefficient prevents the full-array density division from producing NaNs.
+ buffered = update_correction!(correction_setup(ShepardKernelCorrection();
+ density_calculator=SummationDensity(),
+ buffer_size=2,
+ neighborhood_search=nothing))
+ @test all(isfinite, buffered.system.cache.kernel_correction_coefficient)
+ @test all(isfinite, buffered.system.cache.density)
+end
+
+@testset "Shepard partition of unity" begin
+ # Verify that the Shepard coefficient reproduces a constant density field exactly:
+ # the uncorrected kernel sum (numerator) divided by the Shepard coefficient
+ # (denominator) must be the reference density everywhere.
+ setup = correction_setup(nothing)
+ (; system, semi, v_ode, u_ode) = setup
+ v = TrixiParticles.wrap_v(v_ode, system, semi)
+ u = TrixiParticles.wrap_u(u_ode, system, semi)
+ coefficient = zeros(TrixiParticles.nparticles(system))
+ numerator = zero(coefficient)
+
+ TrixiParticles.compute_shepard_coeff!(system,
+ TrixiParticles.current_coordinates(u, system),
+ v_ode, u_ode, semi, coefficient)
+ # Recompute the uncorrected kernel sum by hand for comparison.
+ coordinates = TrixiParticles.current_coordinates(u, system)
+ TrixiParticles.foreach_point_neighbor(system, system, coordinates, coordinates,
+ semi) do particle, neighbor, pos_diff, distance
+ numerator[particle] += TrixiParticles.hydrodynamic_mass(system, neighbor) *
+ TrixiParticles.smoothing_kernel(system, distance, particle)
+ end
+
+ @test numerator ./ coefficient ≈ fill(1000.0, length(numerator)) atol = 2e-12
+ @test TrixiParticles.current_density(v, system) == fill(1000.0, length(numerator))
+end
+
+@testset "Continuity density reinitialization" begin
+ # Reinitializing a continuity density with the Shepard operator must reproduce
+ # a constant density field exactly.
+ setup = correction_setup()
+ v = TrixiParticles.wrap_v(setup.v_ode, setup.system, setup.semi)
+ u = TrixiParticles.wrap_u(setup.u_ode, setup.system, setup.semi)
+ TrixiParticles.reinit_density!(setup.system, v, u, setup.v_ode, setup.u_ode,
+ setup.semi)
+
+ @test TrixiParticles.current_density(v, setup.system) ≈ fill(1000.0, 81) atol = 2e-12
+ # The pressure must be recomputed from the reinitialized density.
+ @test maximum(abs, setup.system.pressure) < 2e-10
+end
diff --git a/test/general/custom_quantities.jl b/test/general/custom_quantities.jl
index fe7ffbc21c..14ea7def56 100644
--- a/test/general/custom_quantities.jl
+++ b/test/general/custom_quantities.jl
@@ -208,6 +208,6 @@
@test avg_density(fluid_system, dv_ode, du_ode, v_ode, u_ode, semi_active, t) ==
20.0
@test kinetic_energy(fluid_system, dv_ode, du_ode, v_ode, u_ode, semi_active, t) ==
- 52.5
+ 52.5
end
end
diff --git a/test/general/general.jl b/test/general/general.jl
index 28aae03333..2ba4484559 100644
--- a/test/general/general.jl
+++ b/test/general/general.jl
@@ -1,6 +1,7 @@
include("initial_condition.jl")
include("smoothing_kernels.jl")
include("density_calculator.jl")
+include("corrections.jl")
include("semidiscretization.jl")
include("interpolation.jl")
include("buffer.jl")
diff --git a/test/general/interpolation.jl b/test/general/interpolation.jl
index 5469876724..40acd1c306 100644
--- a/test/general/interpolation.jl
+++ b/test/general/interpolation.jl
@@ -1,4 +1,31 @@
@testset verbose=true "SPH Interpolation" begin
+ @testset "Plane point coordinates" begin
+ x_range = range(0.0, 1.0, length=5)
+ y_range = range(2.0, 3.0, length=3)
+ point_coords = TrixiParticles.plane_point_coords(x_range, y_range)
+
+ @test point_coords[:, 1] == [0.0, 2.0]
+ @test point_coords[:, 5] == [1.0, 2.0]
+ @test point_coords[:, end] == [1.0, 3.0]
+ @test extrema(point_coords[1, :]) == (0.0, 1.0)
+ @test extrema(point_coords[2, :]) == (2.0, 3.0)
+ end
+
+ @testset "Filter tensor interpolation results" begin
+ results = (density=[10.0, 20.0, 30.0],
+ velocity=[1.0 2.0 3.0
+ 4.0 5.0 6.0],
+ cauchy_stress=reshape(1:12, 2, 2, 3))
+ filtered = TrixiParticles.filter_interpolation_results(results, [1, 3])
+
+ @test filtered.density == [10.0, 30.0]
+ @test filtered.velocity == [1.0 3.0
+ 4.0 6.0]
+ @test size(filtered.cauchy_stress) == (2, 2, 2)
+ @test filtered.cauchy_stress[:, :, 1] == results.cauchy_stress[:, :, 1]
+ @test filtered.cauchy_stress[:, :, 2] == results.cauchy_stress[:, :, 3]
+ end
+
function compare_interpolation_result(actual, expected; tolerance=5e-4)
@test length(actual.density) == length(expected.density)
for i in 1:length(expected.density)
@@ -616,6 +643,11 @@
v_no_wall_velocity = interpolate_points(points_coords, semi_boundary,
include_wall_velocity=false,
fluid_system, v_ode, u_ode).velocity
+ v_wall_velocity_without_cutoff = interpolate_points(points_coords, semi_boundary,
+ include_wall_velocity=true,
+ cut_off_bnd=false,
+ fluid_system, v_ode,
+ u_ode).velocity
@test isapprox(v_wall_velocity[2, 1], 0.0; atol=eps())
@test isapprox(v_wall_velocity_without_cutoff[2, 1], 0.0; atol=eps())
diff --git a/test/general/semidiscretization.jl b/test/general/semidiscretization.jl
index 2734128b99..43a1a5bbe3 100644
--- a/test/general/semidiscretization.jl
+++ b/test/general/semidiscretization.jl
@@ -224,6 +224,29 @@
@test_throws ArgumentError(error_str) Semidiscretization(fluid_system,
boundary_system)
end
+
+ @testset verbose=true "Fluid Surface Tension Consistency" begin
+ struct FluidSurfaceMock <: TrixiParticles.AbstractFluidSystem{2}
+ surface_tension::Any
+ surface_normal_method::Any
+ end
+
+ system_with_surface = FluidSurfaceMock(SurfaceTensionMorris(),
+ ColorfieldSurfaceNormal())
+ system_with_normal = FluidSurfaceMock(nothing, ColorfieldSurfaceNormal())
+ system_without_surface = FluidSurfaceMock(nothing, nothing)
+
+ error_str = "either none or all fluid systems in a simulation need " *
+ "to use a surface tension model or a surface normal method."
+ @test_throws ArgumentError(error_str) TrixiParticles.check_configuration(system_with_surface,
+ (system_with_surface,
+ system_without_surface),
+ nothing)
+ @test_nowarn TrixiParticles.check_configuration(system_with_surface,
+ (system_with_surface,
+ system_with_normal),
+ nothing)
+ end
end
@testset verbose=true "Interaction Matrix" begin
@@ -342,7 +365,7 @@
u = TrixiParticles.wrap_u(u_ode, system, semi)
TrixiParticles.compute_correction_values!(system,
- TrixiParticles.system_correction(system),
+ TrixiParticles.correction_density(system.correction),
u, v_ode, u_ode, semi)
return copy(system.cache.kernel_correction_coefficient), semi
diff --git a/test/preprocessing/geometries/geometries.jl b/test/preprocessing/geometries/geometries.jl
index 4f428d266c..e01099dbe0 100644
--- a/test/preprocessing/geometries/geometries.jl
+++ b/test/preprocessing/geometries/geometries.jl
@@ -54,12 +54,98 @@
end
end
+ @testset verbose=true "Open Polygon Closure" begin
+ open_square = [1.0 2.0 2.0 1.0;
+ 1.0 1.0 2.0 2.0]
+
+ geometry = TrixiParticles.Polygon(open_square)
+
+ @test TrixiParticles.nfaces(geometry) == 4
+ @test first(geometry.vertices) == last(geometry.vertices)
+ @test TrixiParticles.volume(geometry) ≈ 1.0
+
+ mktempdir() do dir
+ filename = joinpath(dir, "open_square.asc")
+ open(filename, "w") do io
+ println(io, "# ASCII")
+ for vertex in eachcol(open_square)
+ println(io, vertex[1], " ", vertex[2])
+ end
+ end
+
+ geometry_from_file = load_geometry(filename)
+
+ @test TrixiParticles.nfaces(geometry_from_file) == 4
+ @test first(geometry_from_file.vertices) == last(geometry_from_file.vertices)
+ @test TrixiParticles.volume(geometry_from_file) ≈ 1.0
+ end
+ end
+
+ @testset verbose=true "Closed Geometry Detection" begin
+ open_square = [1.0 2.0 2.0 1.0;
+ 1.0 1.0 2.0 2.0]
+
+ closed_polygon = TrixiParticles.Polygon(open_square)
+ open_polygon = TrixiParticles.Polygon(open_square; close_curve=false)
+ partial_polygon = delete_faces(closed_polygon, 2)
+ rebuilt_closed_polygon = delete_faces(closed_polygon, Int[])
+
+ @test TrixiParticles.is_closed_geometry(closed_polygon)
+ @test TrixiParticles.is_closed_geometry(rebuilt_closed_polygon)
+ @test !TrixiParticles.is_closed_geometry(open_polygon)
+ @test !TrixiParticles.is_closed_geometry(partial_polygon)
+
+ shape = RectangularShape(0.5, (2, 2), (1.0, 1.0), density=1.0)
+ @test_throws ArgumentError intersect(shape, open_polygon)
+ @test_throws ArgumentError setdiff(shape, open_polygon)
+
+ file = pkgdir(TrixiParticles, "test", "preprocessing", "data")
+ planar_geometry = load_geometry(joinpath(file, "inflow_geometry.stl"))
+ closed_mesh = extrude_geometry(planar_geometry, 0.8)
+ open_mesh = extrude_geometry(planar_geometry, 0.8; omit_top_face=true)
+
+ @test !TrixiParticles.is_closed_geometry(planar_geometry)
+ @test TrixiParticles.is_closed_geometry(closed_mesh)
+ @test !TrixiParticles.is_closed_geometry(open_mesh)
+ end
+
+ @testset verbose=true "`delete_faces` Rebuilds Derived Data" begin
+ triangle = [0.0 1.0 0.5 0.0;
+ 0.0 0.0 0.7 0.0]
+
+ edge_only = TrixiParticles.delete_faces(TrixiParticles.Polygon(triangle), [1, 2])
+
+ @test TrixiParticles.nfaces(edge_only) == 1
+ @test length(edge_only.vertices) == 2
+ @test length(edge_only.vertex_normals) == 1
+ @test edge_only.min_corner == min.(edge_only.edge_vertices[1]...)
+ @test edge_only.max_corner == max.(edge_only.edge_vertices[1]...)
+ @test edge_only.vertex_normals[1] == (edge_only.edge_normals[1],
+ edge_only.edge_normals[1])
+
+ A = SVector(0.0, 0.0, 0.0)
+ B = SVector(1.0, 0.0, 0.0)
+ C = SVector(0.0, 1.0, 0.0)
+ D = SVector(1.0, 1.0, 0.0)
+ face_vertices = [(A, B, C), (B, D, C)]
+ face_normals = [SVector(0.0, 0.0, 1.0), SVector(0.0, 0.0, 1.0)]
+ mesh = TrixiParticles.TriangleMesh(face_vertices, face_normals, [A, B, C, D])
+
+ mesh = TrixiParticles.delete_faces(mesh, 1)
+
+ @test TrixiParticles.nfaces(mesh) == 1
+ @test length(mesh.vertices) == 3
+ @test length(mesh.edge_normals) == 3
+ @test mesh.face_vertices == [face_vertices[2]]
+ end
+
@testset verbose=true "Real World Data" begin
data_dir = pkgdir(TrixiParticles, "examples", "preprocessing", "data")
validation_dir = pkgdir(TrixiParticles, "test", "preprocessing", "data")
@testset verbose=true "2D" begin
files = ["hexagon", "circle", "inverted_open_curve"]
+ close_curves = [true, true, false]
n_edges = [6, 63, 240]
volumes = [2.5980750000000006, 3.1363805763454, 2.6153740535469048]
@@ -74,7 +160,8 @@
points = vcat((data.var"Points:0")', (data.var"Points:1")')
- geometry = load_geometry(joinpath(data_dir, files[i] * ".asc"))
+ geometry = load_geometry(joinpath(data_dir, files[i] * ".asc");
+ close_curve=close_curves[i])
@test TrixiParticles.nfaces(geometry) == n_edges[i]
@@ -157,6 +244,19 @@
end
end
+ @testset verbose=true "Degenerate Triangle Normals" begin
+ vertex = SVector(0.0, 0.0, 0.0)
+ normal = SVector(0.0, 0.0, 0.0)
+
+ geometry = TrixiParticles.TriangleMesh([(vertex, vertex, vertex)],
+ [normal], [vertex, vertex, vertex])
+
+ @test all(iszero, geometry.vertex_normals)
+ @test all(iszero, geometry.edge_normals)
+ @test all(all(isfinite, normal) for normal in geometry.vertex_normals)
+ @test all(all(isfinite, normal) for normal in geometry.edge_normals)
+ end
+
@testset verbose=true "Union" begin
# Build a single geometry by uniting multiple STL patches (cuboid.stl contains separate solids).
# The union should produce a closed volume.
@@ -202,47 +302,22 @@
omit_bottom_face=true)
winding_number_factor = 0.2
- @testset verbose=true "Omit Top Face" begin
- expected_min_corner = [-0.036399998962879196; 0.24624998748302457; -0.5233639197487431;;]
- expected_max_corner = [0.38360000103712083; 1.1462499874830245; -0.07336391974874301;;]
-
- ic_1 = ComplexShape(geometry_extruded_1; particle_spacing=0.03, density=1.0,
- point_in_geometry_algorithm=WindingNumberJacobson(;
- geometry=geometry_extruded_1,
- winding_number_factor))
-
- @test nparticles(ic_1) == 2994
- @test isapprox(maximum(ic_1.coordinates, dims=2), expected_max_corner)
- @test isapprox(minimum(ic_1.coordinates, dims=2), expected_min_corner)
- end
- @testset verbose=true "Omit Bottom Face" begin
- expected_min_corner = [-0.0663999989628792; 0.1562499874830246; -0.49336391974874305;;]
- expected_max_corner = [0.38360000103712083; 1.0562499874830245; -0.07336391974874301;;]
-
- ic_2 = ComplexShape(geometry_extruded_2; particle_spacing=0.03, density=1.0,
- point_in_geometry_algorithm=WindingNumberJacobson(;
- geometry=geometry_extruded_2,
- winding_number_factor))
-
- @test nparticles(ic_2) == 2988
- @test isapprox(maximum(ic_2.coordinates, dims=2), expected_max_corner)
- @test isapprox(minimum(ic_2.coordinates, dims=2), expected_min_corner)
- end
-
- @testset verbose=true "Omit Both" begin
- expected_min_corner = [-0.0663999989628792; 0.1562499874830246; -0.5233639197487431;;]
- expected_max_corner = [0.38360000103712083; 1.1462499874830245; -0.07336391974874301;;]
-
- ic_3 = ComplexShape(geometry_extruded_3; particle_spacing=0.03, density=1.0,
- point_in_geometry_algorithm=WindingNumberJacobson(;
- geometry=geometry_extruded_3,
- winding_number_factor))
-
- @test nparticles(ic_3) == 3258
- @test isapprox(maximum(ic_3.coordinates, dims=2), expected_max_corner)
- @test isapprox(minimum(ic_3.coordinates, dims=2), expected_min_corner)
- end
+ @test_throws ArgumentError ComplexShape(geometry_extruded_1;
+ particle_spacing=0.03, density=1.0,
+ point_in_geometry_algorithm=WindingNumberJacobson(;
+ geometry=geometry_extruded_1,
+ winding_number_factor))
+ @test_throws ArgumentError ComplexShape(geometry_extruded_2;
+ particle_spacing=0.03, density=1.0,
+ point_in_geometry_algorithm=WindingNumberJacobson(;
+ geometry=geometry_extruded_2,
+ winding_number_factor))
+ @test_throws ArgumentError ComplexShape(geometry_extruded_3;
+ particle_spacing=0.03, density=1.0,
+ point_in_geometry_algorithm=WindingNumberJacobson(;
+ geometry=geometry_extruded_3,
+ winding_number_factor))
end
end
diff --git a/test/preprocessing/packing/nhs_faces.jl b/test/preprocessing/packing/nhs_faces.jl
index 292522a91b..a9b038f2ed 100644
--- a/test/preprocessing/packing/nhs_faces.jl
+++ b/test/preprocessing/packing/nhs_faces.jl
@@ -4,7 +4,7 @@
0.0 0.0 0.7 0.0]
# Only use the third edge of the triangle, i.e. the edge from [0.1, 0.0] to [0.0, 0.0]
- edge_aligned = deleteat!(TrixiParticles.Polygon(triangle), [1, 2])
+ edge_aligned = TrixiParticles.delete_faces(TrixiParticles.Polygon(triangle), [1, 2])
edge_id = 1 # Only one edge in `Polygon`
cell_sizes = [1.0 + sqrt(eps()), 0.1]
@@ -27,7 +27,8 @@
end
# Only use the first edge of the triangle, i.e. the edge from [0.0, 0.0] to [0.5, 0.7]
- edge_arbitrary = deleteat!(TrixiParticles.Polygon(triangle), [2, 3])
+ edge_arbitrary = TrixiParticles.delete_faces(TrixiParticles.Polygon(triangle),
+ [2, 3])
edge_id = 1 # Only one edge in `Polygon`
expected_ncells_bbox = [(1, 1), (6, 7)]
diff --git a/test/preprocessing/packing/signed_distance.jl b/test/preprocessing/packing/signed_distance.jl
index cb02753b26..d49ae57fcb 100644
--- a/test/preprocessing/packing/signed_distance.jl
+++ b/test/preprocessing/packing/signed_distance.jl
@@ -44,6 +44,16 @@
@test repr("text/plain", signed_distance_field) == show_box
end
+ @testset verbose=true "Open Geometry Validation" begin
+ open_square = [0.0 1.0 1.0 0.0;
+ 0.0 0.0 1.0 1.0]
+ geometry = TrixiParticles.Polygon(open_square; close_curve=false)
+
+ @test SignedDistanceField(geometry, 0.1) isa SignedDistanceField
+ @test_throws ArgumentError SignedDistanceField(geometry, 0.1;
+ use_for_boundary_packing=true)
+ end
+
@testset verbose=true "Real World Data" begin
data_dir = pkgdir(TrixiParticles, "examples", "preprocessing", "data")
validation_dir = pkgdir(TrixiParticles, "test", "preprocessing", "data")
@@ -135,4 +145,15 @@
end
end
end
+
+ @testset verbose=true "Point Matrix Input" begin
+ data_dir = pkgdir(TrixiParticles, "examples", "preprocessing", "data")
+ geometry = load_geometry(joinpath(data_dir, "hexagon.asc"))
+
+ point = first(geometry.vertices)
+ signed_distance_field = SignedDistanceField(geometry, 0.1; points=hcat(point))
+
+ @test signed_distance_field.positions == [point]
+ @test signed_distance_field.distances == [0.0]
+ end
end
diff --git a/test/preprocessing/point_in_poly/winding_number_jacobson.jl b/test/preprocessing/point_in_poly/winding_number_jacobson.jl
index ee2119a62d..3622b48f52 100644
--- a/test/preprocessing/point_in_poly/winding_number_jacobson.jl
+++ b/test/preprocessing/point_in_poly/winding_number_jacobson.jl
@@ -3,6 +3,11 @@
data_dir = pkgdir(TrixiParticles, "examples", "preprocessing", "data")
geometry = load_geometry(joinpath(data_dir, "circle.asc"))
+ winding = WindingNumberJacobson()
+
+ show_compact = "WindingNumberJacobson{NaiveWinding}()"
+ @test repr(winding) == show_compact
+
winding = WindingNumberJacobson(; hierarchical_winding=false)
show_compact = "WindingNumberJacobson{NaiveWinding}()"
@@ -30,4 +35,33 @@
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"""
@test repr("text/plain", winding) == show_box
end
+
+ @testset verbose=true "Point Matrix Input" begin
+ geometry = TrixiParticles.Polygon([0.0 1.0 1.0 0.0 0.0;
+ 0.0 0.0 1.0 1.0 0.0])
+ point_storage = [0.5 0.0 1.5;
+ 0.5 0.0 1.5]
+ points = @view point_storage[:, 1:2:3]
+
+ expected = Bool[true, false]
+
+ inpoly_jacobson, _ = WindingNumberJacobson()(geometry, points)
+ inpoly_hormann, _ = WindingNumberHormann()(geometry, points)
+
+ @test inpoly_jacobson == expected
+ @test inpoly_hormann == expected
+ end
+
+ @testset verbose=true "Open Geometry Validation" begin
+ open_square = [0.0 1.0 1.0 0.0;
+ 0.0 0.0 1.0 1.0]
+ geometry = TrixiParticles.Polygon(open_square; close_curve=false)
+ points = [SVector(0.5, 0.5)]
+
+ jacobson = WindingNumberJacobson(; hierarchical_winding=false)
+ hormann = WindingNumberHormann()
+
+ @test jacobson(geometry, points)[1] isa Vector{Bool}
+ @test hormann(geometry, points)[1] isa Vector{Bool}
+ end
end
diff --git a/test/schemes/boundary/dummy_particles/dummy_particles.jl b/test/schemes/boundary/dummy_particles/dummy_particles.jl
index ef997a1ed6..2bc98ce013 100644
--- a/test/schemes/boundary/dummy_particles/dummy_particles.jl
+++ b/test/schemes/boundary/dummy_particles/dummy_particles.jl
@@ -655,8 +655,8 @@
(width_reference, height_reference),
(width_reference, height_reference),
density; acceleration=[0.0, -9.81],
- state_equation, n_layers=0,
- faces=(true, true, true, false))
+ state_equation,
+ faces=(false, false, false, false))
# Because it is a pain to deal with the linear indices of the pressure arrays,
# we convert the matrices to Cartesian indices based on the coordinates.
diff --git a/test/schemes/boundary/open_boundary/boundary_zone.jl b/test/schemes/boundary/open_boundary/boundary_zone.jl
index 8542fd554d..869f565f87 100644
--- a/test/schemes/boundary/open_boundary/boundary_zone.jl
+++ b/test/schemes/boundary/open_boundary/boundary_zone.jl
@@ -256,6 +256,24 @@
end
end
+ @testset verbose=true "Boundary Zone 3D Float32 Tolerance" begin
+ edge1 = Float32[0.6208666, 0.6295315, 0.46713477]
+ edge2 = Float32[-0.48528308, 0.7766439, -0.4016525]
+ boundary_face = (zeros(Float32, 3), edge1, edge2)
+ face_normal = normalize(cross(edge1, edge2))
+
+ # This is orthogonal to Float32 precision, but not to a Float64-based tolerance.
+ @test abs(dot(edge1, edge2)) > sqrt(eps()) * norm(edge1) * norm(edge2)
+ @test abs(dot(edge1, edge2)) <= sqrt(eps(Float32)) * norm(edge1) * norm(edge2)
+
+ boundary_zone = BoundaryZone(; boundary_face, particle_spacing=0.5f0,
+ face_normal, density=1.0f0,
+ open_boundary_layers=1, boundary_type=InFlow(),
+ sample_points=nothing)
+
+ @test size(boundary_zone.initial_condition.coordinates, 2) > 0
+ end
+
@testset verbose=true "Particle In Boundary Zone 2D" begin
face_vertices = [[-0.2, -0.5], [0.3, 0.6]]
face_size = face_vertices[2] - face_vertices[1]
@@ -367,6 +385,26 @@
open_boundary_layers=2,
boundary_type=OutFlow())
+ non_orthogonal_face = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]]
+ flow_direction = [0.0, 0.0, 1.0]
+
+ error_str = "the vectors `AB` and `AC` must be orthogonal"
+
+ @test_throws ArgumentError(error_str) BoundaryZone(;
+ boundary_face=non_orthogonal_face,
+ particle_spacing=0.1,
+ face_normal=flow_direction,
+ density=1.0,
+ open_boundary_layers=2,
+ boundary_type=InFlow())
+ @test_throws ArgumentError(error_str) BoundaryZone(;
+ boundary_face=non_orthogonal_face,
+ particle_spacing=0.1,
+ face_normal=(-flow_direction),
+ density=1.0,
+ open_boundary_layers=2,
+ boundary_type=OutFlow())
+
rectangular_face = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
flow_direction = [0.0, 1.0, 0.0]
diff --git a/test/schemes/boundary/open_boundary/characteristic_variables.jl b/test/schemes/boundary/open_boundary/characteristic_variables.jl
index 9d2e9fa03e..7e21b0efc4 100644
--- a/test/schemes/boundary/open_boundary/characteristic_variables.jl
+++ b/test/schemes/boundary/open_boundary/characteristic_variables.jl
@@ -18,6 +18,72 @@
# Add small offset to avoid "ArgumentError: density must be positive and larger than `eps()`"
reference_density = (pos, t) -> 1000.0 * (t + sqrt(eps()))
+ @testset "Reject bidirectional flow" begin
+ initial_condition = rectangular_patch(particle_spacing, (2, 2))
+ fluid_system = WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel,
+ smoothing_length,
+ density_calculator=ContinuityDensity(),
+ state_equation=nothing)
+
+ bidirectional = BoundaryZone(; boundary_face=([0.0, 0.0], [0.0, 1.0]),
+ face_normal=[1.0, 0.0], open_boundary_layers,
+ density, particle_spacing)
+ boundary_system = OpenBoundarySystem(bidirectional; fluid_system, buffer_size=0,
+ boundary_model=BoundaryModelCharacteristicsLastiwka())
+
+ error_str = "`BoundaryModelCharacteristicsLastiwka` needs a directed boundary zone. " *
+ "Please specify `InFlow()` or `OutFlow()` instead of `BidirectionalFlow()`."
+ @test_throws ArgumentError(error_str) Semidiscretization(fluid_system,
+ boundary_system)
+ end
+
+ @testset "Fallback is zone-local" begin
+ face_vertices = ([0.0, 0.0], [0.0, 0.5])
+ face_vertices_far = ([10.0, 0.0], [10.0, 0.5])
+ flow_direction = SVector(1.0, 0.0)
+
+ inflow = BoundaryZone(; boundary_face=face_vertices, face_normal=flow_direction,
+ open_boundary_layers, boundary_type=InFlow(),
+ reference_velocity, reference_pressure, reference_density,
+ density, particle_spacing)
+ inflow_far = BoundaryZone(; boundary_face=face_vertices_far,
+ face_normal=flow_direction,
+ open_boundary_layers, boundary_type=InFlow(),
+ reference_velocity, reference_pressure, reference_density,
+ density, particle_spacing)
+ fluid = extrude_geometry(face_vertices; particle_spacing, n_extrude=4,
+ density, pressure, direction=flow_direction)
+ fluid_system = EntropicallyDampedSPHSystem(fluid; smoothing_kernel,
+ smoothing_length,
+ sound_speed,
+ buffer_size=0,
+ density_calculator=ContinuityDensity())
+ boundary_system = OpenBoundarySystem(inflow, inflow_far;
+ fluid_system, buffer_size=0,
+ boundary_model=BoundaryModelCharacteristicsLastiwka())
+ semi = Semidiscretization(fluid_system, boundary_system)
+ ode = semidiscretize(semi, (0.0, 5.0))
+
+ v0_ode, u0_ode = ode.u0.x
+ v = TrixiParticles.wrap_v(v0_ode, boundary_system, semi)
+ u = TrixiParticles.wrap_u(u0_ode, boundary_system, semi)
+
+ TrixiParticles.evaluate_characteristics!(boundary_system,
+ v, u, v0_ode, u0_ode, semi, 2.0)
+ TrixiParticles.evaluate_characteristics!(boundary_system,
+ v, u, v0_ode, u0_ode, semi, 3.0)
+
+ zone_1_particles = findall(==(1), boundary_system.boundary_zone_indices)
+ zone_2_particles = findall(==(2), boundary_system.boundary_zone_indices)
+
+ @test any(!isapprox(characteristic, 0.0)
+ for characteristic in boundary_system.cache.characteristics[:,
+ zone_1_particles])
+ @test all(isapprox(characteristic, 0.0)
+ for characteristic in boundary_system.cache.characteristics[:,
+ zone_2_particles])
+ end
+
# Face vertices of open boundary
face_vertices_1 = [[0.0, 0.0], [0.5, -0.5], [1.0, 0.5]]
face_vertices_2 = [[0.0, 1.0], [0.2, 2.0], [2.3, 0.5]]
@@ -144,7 +210,7 @@
initial_condition = rectangular_patch(particle_spacing, ntuple(_ -> 2, n_dims))
boundary_face = n_dims == 2 ? ([0.0, 0.0], [0.0, 1.0]) :
- ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 1.0, 1.0])
+ ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0])
face_normal = n_dims == 2 ? [1.0, 0.0] : [1.0, 0.0, 0.0]
inflow = BoundaryZone(; boundary_face, boundary_type=InFlow(), face_normal,
open_boundary_layers=10, density=1.0, particle_spacing)
diff --git a/test/schemes/boundary/open_boundary/dynamical_pressure.jl b/test/schemes/boundary/open_boundary/dynamical_pressure.jl
index bd325eb690..dbe80f7406 100644
--- a/test/schemes/boundary/open_boundary/dynamical_pressure.jl
+++ b/test/schemes/boundary/open_boundary/dynamical_pressure.jl
@@ -123,7 +123,7 @@
initial_condition = rectangular_patch(particle_spacing, ntuple(_ -> 2, n_dims))
boundary_face = n_dims == 2 ? ([0.0, 0.0], [0.0, 1.0]) :
- ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 1.0, 1.0])
+ ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0])
face_normal = n_dims == 2 ? [1.0, 0.0] : [1.0, 0.0, 0.0]
inflow = BoundaryZone(; boundary_face, boundary_type=InFlow(), face_normal,
open_boundary_layers=10, density=1.0, particle_spacing)
diff --git a/test/schemes/boundary/open_boundary/mirroring.jl b/test/schemes/boundary/open_boundary/mirroring.jl
index d9e41ee0a2..5bd9a5e5d7 100644
--- a/test/schemes/boundary/open_boundary/mirroring.jl
+++ b/test/schemes/boundary/open_boundary/mirroring.jl
@@ -548,7 +548,7 @@
initial_condition = rectangular_patch(particle_spacing, ntuple(_ -> 2, n_dims))
boundary_face = n_dims == 2 ? ([0.0, 0.0], [0.0, 1.0]) :
- ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 1.0, 1.0])
+ ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0])
face_normal = n_dims == 2 ? [1.0, 0.0] : [1.0, 0.0, 0.0]
inflow = BoundaryZone(; boundary_face, boundary_type=InFlow(), face_normal,
open_boundary_layers=10, density=1.0, particle_spacing)
diff --git a/test/schemes/fluid/surface_tension.jl b/test/schemes/fluid/surface_tension.jl
index 7fe8abbd97..36b4233870 100644
--- a/test/schemes/fluid/surface_tension.jl
+++ b/test/schemes/fluid/surface_tension.jl
@@ -1,5 +1,141 @@
-
@testset verbose=true "Surface Tension" begin
+ @testset "constructors and capabilities" begin
+ constructors = (CohesionForceAkinci, SurfaceTensionAkinci,
+ SurfaceTensionMorris, SurfaceTensionMomentumMorris)
+
+ for constructor in constructors
+ model = constructor(surface_tension_coefficient=0.5f0)
+ @test model.surface_tension_coefficient === 0.5f0
+ @test iszero(constructor(surface_tension_coefficient=0).surface_tension_coefficient)
+
+ for coefficient in (-1.0, NaN, Inf, -Inf, 1.0im, "invalid")
+ @test_throws ArgumentError constructor(surface_tension_coefficient=coefficient)
+ end
+ end
+
+ @test !TrixiParticles.requires_surface_normal(nothing)
+ @test !TrixiParticles.requires_surface_normal(CohesionForceAkinci())
+ @test TrixiParticles.requires_surface_normal(SurfaceTensionAkinci())
+ @test TrixiParticles.requires_surface_normal(SurfaceTensionMorris())
+ @test TrixiParticles.requires_surface_normal(SurfaceTensionMomentumMorris())
+
+ normal_method = ColorfieldSurfaceNormal(boundary_contact_threshold=1,
+ interface_threshold=0.1f0,
+ ideal_density_threshold=0.25)
+ @test normal_method isa ColorfieldSurfaceNormal{Float64}
+ @test ColorfieldSurfaceNormal(boundary_contact_threshold=0.1f0,
+ interface_threshold=0.01f0,
+ ideal_density_threshold=0.0f0) isa
+ ColorfieldSurfaceNormal{Float32}
+
+ invalid_thresholds = ((NaN, 0.01, 0.0),
+ (-Inf, 0.01, 0.0),
+ (0.1, Inf, 0.0),
+ (0.1, 0.01, 1.0im),
+ ("invalid", 0.01, 0.0))
+ for (boundary_threshold, interface_threshold, density_threshold) in
+ invalid_thresholds
+
+ @test_throws ArgumentError ColorfieldSurfaceNormal(;
+ boundary_contact_threshold=boundary_threshold,
+ interface_threshold,
+ ideal_density_threshold=density_threshold)
+ end
+ end
+
+ @testset "cohesion-only systems do not require normals" begin
+ coordinates = [0.0 1.0;
+ 0.0 0.0]
+ initial_condition = InitialCondition(; coordinates, density=ones(2),
+ particle_spacing=1.0)
+ smoothing_kernel = WendlandC2Kernel{2}()
+ smoothing_length = 1.0
+ surface_tension = CohesionForceAkinci(surface_tension_coefficient=0.1)
+
+ wcsph = WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel,
+ smoothing_length,
+ density_calculator=SummationDensity(),
+ state_equation=StateEquationCole(sound_speed=10.0,
+ reference_density=1.0,
+ exponent=1),
+ surface_tension)
+ edac = EntropicallyDampedSPHSystem(initial_condition; smoothing_kernel,
+ smoothing_length, sound_speed=10.0,
+ density_calculator=SummationDensity(),
+ surface_tension)
+
+ for system in (wcsph, edac)
+ @test isnothing(system.surface_normal_method)
+ @test !haskey(system.cache, :surface_normal)
+ @test !haskey(system.cache, :neighbor_count)
+ @test !haskey(system.cache, :reference_particle_spacing)
+
+ semi = Semidiscretization(system)
+ ode = semidiscretize(semi, (0.0, 0.1))
+ v_ode, u_ode = ode.u0.x
+ dv_ode = zero(v_ode)
+ @test_nowarn TrixiParticles.kick!(dv_ode, v_ode, u_ode, ode.p, 0.0)
+ @test all(isfinite, dv_ode)
+ @test any(!iszero, dv_ode)
+ end
+
+ @test_throws ArgumentError WeaklyCompressibleSPHSystem(initial_condition;
+ smoothing_kernel,
+ smoothing_length,
+ density_calculator=SummationDensity(),
+ state_equation=StateEquationCole(sound_speed=10.0,
+ reference_density=1.0,
+ exponent=1),
+ surface_tension=SurfaceTensionAkinci())
+ @test_throws ArgumentError EntropicallyDampedSPHSystem(initial_condition;
+ smoothing_kernel,
+ smoothing_length,
+ sound_speed=10.0,
+ density_calculator=SummationDensity(),
+ surface_tension=SurfaceTensionAkinci())
+
+ full_akinci = WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel,
+ smoothing_length,
+ density_calculator=SummationDensity(),
+ state_equation=StateEquationCole(sound_speed=10.0,
+ reference_density=1.0,
+ exponent=1),
+ surface_tension=SurfaceTensionAkinci(),
+ reference_particle_spacing=1.0)
+ @test full_akinci.surface_normal_method isa ColorfieldSurfaceNormal
+ @test haskey(full_akinci.cache, :surface_normal)
+ end
+
+ @testset "zero Morris coefficient does not restrict the time step" begin
+ function calculate_initial_dt(surface_tension)
+ initial_condition = InitialCondition(; coordinates=[0.0 1.0; 0.0 0.0],
+ density=ones(2), particle_spacing=1.0)
+ reference_particle_spacing = isnothing(surface_tension) ? 0 : 1.0
+ system = WeaklyCompressibleSPHSystem(initial_condition;
+ smoothing_kernel=WendlandC2Kernel{2}(),
+ smoothing_length=1.0,
+ density_calculator=SummationDensity(),
+ state_equation=StateEquationCole(sound_speed=10.0,
+ reference_density=1.0,
+ exponent=1),
+ surface_tension,
+ reference_particle_spacing)
+ semi = Semidiscretization(system)
+ ode = semidiscretize(semi, (0.0, 0.1))
+ v_ode, u_ode = ode.u0.x
+ return TrixiParticles.calculate_dt(v_ode, u_ode, 0.25, semi.systems[1], semi)
+ end
+
+ dt_without_surface_tension = calculate_initial_dt(nothing)
+ dt_with_zero_csf = calculate_initial_dt(SurfaceTensionMorris(;
+ surface_tension_coefficient=0.0))
+ dt_with_zero_css = calculate_initial_dt(SurfaceTensionMomentumMorris(;
+ surface_tension_coefficient=0.0))
+
+ @test dt_with_zero_csf == dt_without_surface_tension
+ @test dt_with_zero_css == dt_without_surface_tension
+ end
+
@testset verbose=true "`cohesion_force_akinci`" begin
surface_tension = SurfaceTensionAkinci(surface_tension_coefficient=1.0)
support_radius = 1.0
@@ -41,6 +177,20 @@
pos_diff, test_distance) * test_distance
@test isapprox(zero[1], 0.0, atol=6e-15)
@test isapprox(zero[2], 0.0, atol=6e-15)
+
+ surface_tension_f32 = CohesionForceAkinci(surface_tension_coefficient=1.0f0)
+ for support_radius in (1.0f12, 1.0f-12)
+ distance = 0.75f0 * support_radius
+ force = TrixiParticles.cohesion_force_akinci(surface_tension_f32,
+ support_radius, 1.0f0,
+ Float32[distance, 0], distance)
+ expected = Float32(-32 / pi * (1 - 0.75)^3 * 0.75^3 /
+ Float64(support_radius)^3)
+ @test eltype(force) == Float32
+ @test all(isfinite, force)
+ @test isapprox(force[1], expected; rtol=4eps(Float32))
+ @test iszero(force[2])
+ end
end
@testset verbose=true "adhesion_force_akinci" begin
@@ -88,6 +238,28 @@
test_distance
@test isapprox(zero[1], 0.0, atol=6e-15)
@test isapprox(zero[2], 0.0, atol=6e-15)
+
+ support_radius_f32 = 15.594092f0
+ distance_f32 = prevfloat(support_radius_f32)
+ near_support = TrixiParticles.adhesion_force_akinci(surface_tension,
+ support_radius_f32, 1.0f0,
+ Float32[1, 0], distance_f32,
+ 1.0f0)
+ @test eltype(near_support) == Float32
+ @test all(isfinite, near_support)
+ @test 0 < norm(near_support) < eps(Float32)
+
+ for support_radius in (1.0f12, 1.0f-13)
+ distance = 0.75f0 * support_radius
+ force = TrixiParticles.adhesion_force_akinci(surface_tension,
+ support_radius, 1.0f0,
+ Float32[distance, 0], distance,
+ 1.0f0)
+ expected = Float32(-0.007 / Float64(support_radius)^3 / sqrt(2))
+ @test all(isfinite, force)
+ @test isapprox(force[1], expected; rtol=4eps(Float32))
+ @test iszero(force[2])
+ end
end
@testset "compute_stress_tensors! (MomentumMorris)" begin
diff --git a/test/setups/complex_shape.jl b/test/setups/complex_shape.jl
index 2917661225..ca6b879cb1 100644
--- a/test/setups/complex_shape.jl
+++ b/test/setups/complex_shape.jl
@@ -2,6 +2,44 @@
data_dir = pkgdir(TrixiParticles, "examples", "preprocessing", "data")
validation_dir = pkgdir(TrixiParticles, "test", "preprocessing", "data")
+ @testset verbose=true "Sample Boundary" begin
+ particle_spacing = 0.1
+ positions = [
+ SVector(0.0, 0.0),
+ SVector(0.1, 0.0),
+ SVector(0.2, 0.0),
+ SVector(0.3, 0.0),
+ SVector(0.4, 0.0)
+ ]
+ distances = [0.01, 0.05, 0.1, 0.2, 0.3]
+
+ signed_distance_field = (; positions, distances, particle_spacing,
+ boundary_packing=true, max_signed_distance=0.3)
+
+ boundary = sample_boundary(signed_distance_field; boundary_density=1.0,
+ boundary_thickness=0.2, place_on_shell=false)
+ @test boundary.coordinates ≈ stack(positions[2:4])
+
+ boundary = sample_boundary(signed_distance_field; boundary_density=1.0,
+ boundary_thickness=0.2, place_on_shell=true)
+ @test boundary.coordinates ≈ stack(positions[3:4])
+
+ @test_throws ArgumentError sample_boundary(signed_distance_field;
+ boundary_density=1.0,
+ boundary_thickness=0.04,
+ place_on_shell=false)
+
+ too_thin_sdf = (; positions, distances, particle_spacing,
+ boundary_packing=true, max_signed_distance=0.1)
+ @test_throws ArgumentError sample_boundary(too_thin_sdf; boundary_density=1.0,
+ boundary_thickness=0.2)
+
+ not_boundary_sdf = (; positions, distances, particle_spacing,
+ boundary_packing=false, max_signed_distance=0.3)
+ @test_throws ArgumentError sample_boundary(not_boundary_sdf; boundary_density=1.0,
+ boundary_thickness=0.2)
+ end
+
@testset verbose=true "2D" begin
@testset verbose=true "Shifted Rectangle" begin
algorithms = [
@@ -41,7 +79,7 @@
end
@testset verbose=true "Real World Data" begin
- files = ["hexagon", "circle", "inverted_open_curve"]
+ files = ["hexagon", "circle"]
algorithms = [
WindingNumberHormann(),
WindingNumberJacobson(; hierarchical_winding=false)
@@ -72,7 +110,8 @@
# See https://docs.julialang.org/en/v1/base/base/#var%22name%22
coords = vcat((data.var"Points:0")', (data.var"Points:1")')
- geometry = load_geometry(joinpath(data_dir, files[j] * ".asc"))
+ geometry = load_geometry(joinpath(data_dir, files[j] * ".asc");
+ close_curve=true)
shape_sampled = ComplexShape(geometry; particle_spacing=0.05,
density=1.0, point_in_geometry_algorithm)
@@ -82,6 +121,15 @@
end
end
+ @testset verbose=true "Open Geometry Validation" begin
+ open_square = [0.0 1.0 1.0 0.0;
+ 0.0 0.0 1.0 1.0]
+ geometry = TrixiParticles.Polygon(open_square; close_curve=false)
+
+ @test_throws ArgumentError ComplexShape(geometry; particle_spacing=0.1,
+ density=1.0)
+ end
+
@testset verbose=true "Intersect of Overlapping Shapes and Geometries" begin
shape = RectangularShape(0.1, (10, 10), (0.0, 0.0), density=1.0)
geometry = load_geometry(joinpath(data_dir, "circle.asc"))
diff --git a/test/setups/extrude_geometry.jl b/test/setups/extrude_geometry.jl
index 4146ab6dd6..b61b14549b 100644
--- a/test/setups/extrude_geometry.jl
+++ b/test/setups/extrude_geometry.jl
@@ -47,6 +47,28 @@
@test shape.coordinates ≈ expected_coords
end
+
+ @testset verbose=true "Errors" begin
+ point1 = [0.0, 0.0]
+ point2 = [0.0, 1.0]
+
+ @test_throws ArgumentError extrude_geometry((point1, point2);
+ direction=[0.0, 0.0],
+ particle_spacing=0.1,
+ n_extrude=1, density=1.0)
+ @test_nowarn extrude_geometry((point1, point2);
+ direction=[1e-20, 0.0],
+ particle_spacing=0.1,
+ n_extrude=1, density=1.0)
+ @test_throws ArgumentError extrude_geometry((point1, point2);
+ direction=[1.0, 0.0],
+ particle_spacing=0.1,
+ n_extrude=0, density=1.0)
+ @test_throws ArgumentError extrude_geometry((point1, point2);
+ direction=[0.0, 0.0, 1.0],
+ particle_spacing=0.1,
+ n_extrude=1, density=1.0)
+ end
end
# 3D
diff --git a/test/setups/rectangular_shape.jl b/test/setups/rectangular_shape.jl
index 4e2419487f..aae2e8a290 100644
--- a/test/setups/rectangular_shape.jl
+++ b/test/setups/rectangular_shape.jl
@@ -48,6 +48,33 @@
@test shape.coordinates == expected_coords[i]
end
end
+
+ @testset "Function Density" begin
+ shape = RectangularShape(0.1, (2, 1), (0.0, 0.0),
+ density=coords -> 1000.0 + coords[1])
+
+ @test shape.density ≈ [1000.05, 1000.15]
+ end
+
+ @testset "Coordinates Perturbation Does Not Reset Random State" begin
+ Random.seed!(42)
+ first_random_number = rand()
+ next_random_number = rand()
+
+ Random.seed!(42)
+ @test rand() == first_random_number
+
+ RectangularShape(0.1, (2, 2), (0.0, 0.0), density=1.0,
+ coordinates_perturbation=0.1)
+
+ @test rand() == next_random_number
+ end
+
+ @testset "Errors" begin
+ @test_throws ArgumentError RectangularShape(0.1, (2, 2), (0.0, 0.0),
+ density=1000.0,
+ acceleration=(0.0, -9.81, 0.0))
+ end
end
# Only show all of these nested testsets in case of errors
@@ -123,6 +150,26 @@
@test shape.pressure ≈ 4.71 * 1000.0 * vec(reverse(pressure'))
end
end
+
+ @testset "Function Density" begin
+ density_function = coords -> 1000.0 + 100coords[1] + 10coords[2]
+ shape = RectangularShape(particle_spacing, (2, 3), (0.0, 0.0),
+ density=density_function,
+ acceleration=(0.0, -1.0))
+
+ @test shape.density ≈ [1005.5, 1015.5, 1006.5, 1016.5, 1007.5, 1017.5]
+ @test shape.pressure ≈ [251.775, 254.275, 151.125, 152.625, 50.375,
+ 50.875]
+ @test shape.mass ≈ particle_spacing^2 * shape.density
+ end
+
+ @testset "Zero Acceleration" begin
+ shape = RectangularShape(particle_spacing, (2, 5), (0.0, 0.0),
+ density=1000.0, acceleration=(0.0, 0.0))
+
+ @test shape.pressure == zeros(10)
+ @test shape.density == 1000 * ones(10)
+ end
end
# Use `@trixi_testset` to isolate the mock functions in a separate namespace
@@ -186,6 +233,16 @@
shape.pressure)
@test shape.mass == particle_spacing^2 * shape.density
end
+
+ @testset "Zero Acceleration" begin
+ shape = RectangularShape(particle_spacing, (2, 5), (0.0, 0.0);
+ acceleration=(0.0, 0.0), state_equation)
+
+ @test shape.pressure == zeros(10)
+ @test shape.density ==
+ TrixiParticles.inverse_state_equation.(Ref(state_equation),
+ shape.pressure)
+ end
end
end
diff --git a/test/setups/rectangular_tank.jl b/test/setups/rectangular_tank.jl
index 1a1fc88508..33775a5873 100644
--- a/test/setups/rectangular_tank.jl
+++ b/test/setups/rectangular_tank.jl
@@ -119,6 +119,43 @@
(water_width, water_height, 0.5),
(tank_width, tank_height),
water_density, spacing_ratio=3)
+
+ error = ArgumentError("`fluid_size` dimensions need to be non-negative")
+ @test_throws error RectangularTank(particle_spacing,
+ (-water_width, water_height),
+ (tank_width, tank_height),
+ water_density)
+
+ error = ArgumentError("`tank_size` dimensions need to be non-negative")
+ @test_throws error RectangularTank(particle_spacing,
+ (water_width, water_height),
+ (-tank_width, tank_height),
+ water_density)
+
+ @test_throws ArgumentError RectangularTank(particle_spacing,
+ (water_width, water_height),
+ (tank_width, tank_height),
+ water_density, spacing_ratio=0)
+
+ @test_throws ArgumentError RectangularTank(particle_spacing,
+ (water_width, water_height),
+ (tank_width, tank_height),
+ water_density, n_layers=0)
+
+ @test_throws ArgumentError RectangularTank(particle_spacing,
+ (water_width, water_height),
+ (tank_width, tank_height),
+ water_density, n_layers=1.5)
+
+ tank = RectangularTank(0.1, (1.0, 1.0), (0.3, 0.3), water_density)
+ @test tank.n_particles_per_dimension == (3, 3)
+ @test all(tank.fluid_size .≈ (0.3, 0.3))
+
+ tank = RectangularTank(0.1, (1.0, 1.0), (0.05, 0.3), water_density;
+ acceleration=(1.0, 0.0))
+ @test isempty(tank.fluid.coordinates)
+ @test tank.n_particles_per_dimension == (0, 3)
+ @test all(tank.fluid_size .≈ (0.0, 0.3))
end
end
diff --git a/test/setups/sphere_shape.jl b/test/setups/sphere_shape.jl
index c8b87d76db..42fc3e7b70 100644
--- a/test/setups/sphere_shape.jl
+++ b/test/setups/sphere_shape.jl
@@ -79,6 +79,18 @@
end
end
+ @testset verbose=true "Errors" begin
+ @test_throws ArgumentError SphereShape(0.1, 0.5, (0.0, 0.0), 1000.0;
+ cutout_min=(0.2, 0.0),
+ cutout_max=(0.1, 0.1))
+ @test_throws ArgumentError SphereShape(0.1, 0.5, (0.0, 0.0, 0.0),
+ 1000.0; cutout_min=(0.0, 0.0),
+ cutout_max=(0.1, 0.1))
+ @test_nowarn SphereShape(0.1, 0.5, (0.0, 0.0, 0.0),
+ 1000.0; cutout_min=(0.0, 0.0),
+ cutout_max=(0.0, 0.0))
+ end
+
@testset verbose=true "SphereShape 3D" begin
shape_names = [
"1-particle VoxelSphere",
diff --git a/test/systems/boundary_system.jl b/test/systems/boundary_system.jl
index 8c55dfa029..67512a19bb 100644
--- a/test/systems/boundary_system.jl
+++ b/test/systems/boundary_system.jl
@@ -28,6 +28,55 @@
end
end
+ @testset verbose=true "High-level Dummy-Particle Builder" begin
+ boundary_coordinates = [1.0 2.0
+ 1.0 2.0]
+ fluid_coordinates = [0.0 0.5
+ 0.0 0.0]
+
+ boundary_ic = InitialCondition(; coordinates=boundary_coordinates, mass, density)
+ fluid_ic = InitialCondition(; coordinates=fluid_coordinates, mass, density)
+
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ smoothing_length = 0.8
+ state_equation = StateEquationCole(; sound_speed=15.0, reference_density=1000.0,
+ exponent=1)
+ viscosity = ViscosityAdami(nu=1e-6)
+
+ fluid_system = WeaklyCompressibleSPHSystem(fluid_ic;
+ density_calculator=ContinuityDensity(),
+ state_equation, smoothing_kernel,
+ smoothing_length,
+ correction=KernelCorrection(),
+ reference_particle_spacing=0.1)
+
+ boundary_model = BoundaryModelDummyParticles(boundary_ic;
+ fluid_system=fluid_system,
+ viscosity=viscosity)
+ system = WallBoundarySystem(boundary_ic, boundary_model,
+ adhesion_coefficient=0.3,
+ color_value=2)
+
+ @test system isa WallBoundarySystem
+ @test system.boundary_model isa BoundaryModelDummyParticles
+ @test system.boundary_model.hydrodynamic_mass == boundary_ic.mass
+ @test system.boundary_model.density_calculator isa AdamiPressureExtrapolation
+ @test system.boundary_model.smoothing_kernel === smoothing_kernel
+ @test system.boundary_model.smoothing_length == smoothing_length
+ @test system.boundary_model.viscosity == viscosity
+ @test system.boundary_model.state_equation == state_equation
+ @test system.boundary_model.correction isa KernelCorrection
+ @test system.boundary_model.cache.reference_particle_spacing == 0.1
+ @test system.adhesion_coefficient == 0.3
+ @test system.cache.color == 2
+
+ edac_system = EntropicallyDampedSPHSystem(fluid_ic; smoothing_kernel,
+ smoothing_length, sound_speed=15.0)
+ edac_boundary_model = BoundaryModelDummyParticles(boundary_ic;
+ fluid_system=edac_system)
+ @test edac_boundary_model.state_equation === nothing
+ end
+
@testset verbose=true "Moving Boundaries" begin
@testset "$(i+1)D" for i in 1:2
NDIMS = i + 1
diff --git a/test/systems/edac_system.jl b/test/systems/edac_system.jl
index dd7dda04fe..86232388b1 100644
--- a/test/systems/edac_system.jl
+++ b/test/systems/edac_system.jl
@@ -213,6 +213,57 @@
@test v0 == vcat(velocity, [0.8, 1.0]')
end
+ @trixi_testset "Correction cache updates" begin
+ coordinates = [0.0 0.1 0.0
+ 0.0 0.0 0.1]
+ velocity = zeros(2, 3)
+ mass = ones(3)
+ density = fill(1000.0, 3)
+ pressure = zeros(3)
+ initial_condition = InitialCondition(; coordinates, velocity, mass, density,
+ pressure)
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ smoothing_length = 0.5
+
+ corrections = (ShepardKernelCorrection(), KernelCorrection(), GradientCorrection(),
+ MixedKernelGradientCorrection())
+
+ @testset "$(typeof(correction))" for correction in corrections
+ system = EntropicallyDampedSPHSystem(initial_condition; smoothing_kernel,
+ smoothing_length, sound_speed=10.0,
+ correction, pressure_acceleration=nothing)
+ semi = Semidiscretization(system)
+
+ TrixiParticles.initialize_neighborhood_searches!(semi)
+
+ u_ode = vec(coordinates)
+ v0 = zeros(TrixiParticles.v_nvariables(system),
+ TrixiParticles.n_integrated_particles(system))
+ TrixiParticles.write_v0!(v0, system)
+ v_ode = vec(v0)
+
+ v = TrixiParticles.wrap_v(v_ode, system, semi)
+ u = TrixiParticles.wrap_u(u_ode, system, semi)
+
+ for cache_key in (:kernel_correction_coefficient, :dw_gamma,
+ :correction_matrix)
+ hasproperty(system.cache, cache_key) || continue
+
+ fill!(getproperty(system.cache, cache_key), NaN)
+ end
+
+ TrixiParticles.update_quantities!(system, v, u, v_ode, u_ode, semi, 0.0)
+ TrixiParticles.update_pressure!(system, v, u, v_ode, u_ode, semi, 0.0)
+
+ for cache_key in (:kernel_correction_coefficient, :dw_gamma,
+ :correction_matrix)
+ hasproperty(system.cache, cache_key) || continue
+
+ @test all(isfinite, getproperty(system.cache, cache_key))
+ end
+ end
+ end
+
@trixi_testset "Average Pressure" begin
particle_spacing = 0.1
smoothing_kernel = SchoenbergCubicSplineKernel{2}()
diff --git a/test/systems/iisph_system.jl b/test/systems/iisph_system.jl
index 2bcd740552..de38f2cd2c 100644
--- a/test/systems/iisph_system.jl
+++ b/test/systems/iisph_system.jl
@@ -68,6 +68,7 @@
@test system.max_iterations == max_iterations
@test system.time_step == time_step
@test length(system.density) == size(coordinates, 2)
+ @test TrixiParticles.system_state_equation(system) === nothing
# A too-short acceleration vector triggers dimension validation
error_str1 = "`acceleration` must be of length $NDIMS for a $(NDIMS)D problem"
@@ -439,12 +440,12 @@
system_pressure.predicted_density .= [990.0, 1010.0]
system_pressure.sum_term .= [5.0, -2.0]
system_pressure.a_ii .= [0.5, 1.0e-10]
- fill!(system_pressure.density_error, 0.0)
+ system_pressure.density_error .= [0.0, 99.0]
semi = DummySemidiscretization()
# First particle uses standard Jacobi update; second hits the safeguarded zero-a_ii path.
# For particle 1: (1-omega)*0 + omega/a_ii * (source - sum_term) with omega=0.4,
- # source=(1000-990)=10, a_ii=0.5, sum_term=5 gives pressure 4 and density_error -3
+ # source=(1000-990)=10, a_ii=0.5, sum_term=5 gives pressure 4 and abs(density_error) 3
relative_error = TrixiParticles.pressure_update(system_pressure,
system_pressure.pressure,
system_pressure.reference_density,
@@ -454,9 +455,50 @@
system_pressure.density_error,
semi)
- @test isapprox(relative_error, -0.003)
+ @test isapprox(relative_error, 0.003)
@test isapprox(system_pressure.pressure, [4.0, 0.0])
- @test isapprox(system_pressure.density_error, [-3.0, 0.0])
+ @test isapprox(system_pressure.density_error, [3.0, 0.0])
+ end
+
+ @testset "Cross-system pressure sums use neighbor coordinates" begin
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ smoothing_length = 0.5
+ time_step = 0.5
+
+ coordinates_a = reshape([0.0, 0.0], 2, 1)
+ ic_a = InitialCondition(; coordinates=coordinates_a,
+ velocity=zeros(2, 1),
+ mass=[1.0],
+ density=[1000.0],
+ pressure=[1.0])
+ system = ImplicitIncompressibleSPHSystem(ic_a;
+ smoothing_kernel,
+ smoothing_length,
+ reference_density=1000.0,
+ time_step)
+
+ coordinates_b = [0.1 0.2
+ 0.0 0.0]
+ ic_b = InitialCondition(; coordinates=coordinates_b,
+ velocity=zeros(2, 2),
+ mass=[1.0, 1.0],
+ density=[1000.0, 1000.0],
+ pressure=[1.0, 2.0])
+ neighbor_system = ImplicitIncompressibleSPHSystem(ic_b;
+ smoothing_kernel,
+ smoothing_length,
+ reference_density=1000.0,
+ time_step)
+
+ semi = Semidiscretization(system, neighbor_system)
+ TrixiParticles.initialize_neighborhood_searches!(semi)
+ u_ode = vcat(vec(coordinates_a), vec(coordinates_b))
+ u = TrixiParticles.wrap_u(u_ode, system, semi)
+
+ @test_nowarn TrixiParticles.calculate_sum_d_ij_pj!(system.sum_d_ij_pj,
+ system, neighbor_system,
+ u, u_ode, semi)
+ @test !iszero(system.sum_d_ij_pj[1, 1])
end
@testset "Source term and iteration limits" begin
@@ -489,4 +531,30 @@
@test TrixiParticles.maximum_iisph_iterations(system_iters) == 7
end
end
+
+ @testset "Reject incompatible fluid systems" begin
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ smoothing_length = 0.5
+ coordinates = [0.0 0.1
+ 0.0 0.2]
+ velocity = zeros(2, 2)
+ mass = [1.0, 1.0]
+ density = [1000.0, 1000.0]
+ pressure = [0.0, 0.0]
+ ic = InitialCondition(; coordinates, velocity, mass, density, pressure)
+
+ iisph_system = ImplicitIncompressibleSPHSystem(ic;
+ smoothing_kernel,
+ smoothing_length,
+ reference_density=1000.0,
+ time_step=0.5)
+ edac_system = EntropicallyDampedSPHSystem(ic; smoothing_kernel,
+ smoothing_length,
+ sound_speed=10.0)
+
+ error_str = "`ImplicitIncompressibleSPHSystem` cannot be used together with " *
+ "`EntropicallyDampedSPHSystem`"
+ @test_throws ArgumentError(error_str) Semidiscretization(iisph_system,
+ edac_system)
+ end
end
diff --git a/test/systems/open_boundary_system.jl b/test/systems/open_boundary_system.jl
index d1b2b3fc1d..eca71a9d6e 100644
--- a/test/systems/open_boundary_system.jl
+++ b/test/systems/open_boundary_system.jl
@@ -2,14 +2,16 @@
@testset "`show`" begin
# Mock fluid system
- struct FluidSystemMock2 <: TrixiParticles.AbstractFluidSystem{2}
+ struct FluidSystemMock2{B} <: TrixiParticles.AbstractFluidSystem{2}
pressure_acceleration_formulation::Nothing
density_diffusion::Nothing
+ buffer::B
end
TrixiParticles.initial_smoothing_length(system::FluidSystemMock2) = 1.0
TrixiParticles.nparticles(system::FluidSystemMock2) = 1
TrixiParticles.system_smoothing_kernel(system::FluidSystemMock2) = nothing
TrixiParticles.density_calculator(system::FluidSystemMock2) = TrixiParticles.ContinuityDensity()
+ TrixiParticles.buffer(system::FluidSystemMock2) = system.buffer
inflow = BoundaryZone(; boundary_face=([0.0, 0.0], [0.0, 1.0]),
particle_spacing=0.05,
@@ -17,7 +19,8 @@
open_boundary_layers=4, boundary_type=InFlow())
system = OpenBoundarySystem(inflow; buffer_size=0,
boundary_model=BoundaryModelCharacteristicsLastiwka(),
- fluid_system=FluidSystemMock2(nothing, nothing))
+ fluid_system=FluidSystemMock2(nothing, nothing,
+ nothing))
show_compact = "OpenBoundarySystem{2}() with 80 particles"
@test repr(system) == show_compact
@@ -40,7 +43,8 @@
boundary_type=OutFlow())
system = OpenBoundarySystem(outflow; buffer_size=0,
boundary_model=BoundaryModelMirroringTafuni(),
- fluid_system=FluidSystemMock2(nothing, nothing))
+ fluid_system=FluidSystemMock2(nothing, nothing,
+ nothing))
show_compact = "OpenBoundarySystem{2}() with 80 particles"
@test repr(system) == show_compact
@@ -59,7 +63,8 @@
system = OpenBoundarySystem(outflow, inflow; buffer_size=0,
boundary_model=BoundaryModelMirroringTafuni(),
- fluid_system=FluidSystemMock2(nothing, nothing))
+ fluid_system=FluidSystemMock2(nothing, nothing,
+ nothing))
show_compact = "OpenBoundarySystem{2}() with 160 particles"
@test repr(system) == show_compact
@@ -78,7 +83,8 @@
system = OpenBoundarySystem(outflow, inflow; buffer_size=0,
boundary_model=BoundaryModelDynamicalPressureZhang(),
- fluid_system=FluidSystemMock2(nothing, nothing))
+ fluid_system=FluidSystemMock2(nothing, nothing,
+ nothing))
show_compact = "OpenBoundarySystem{2}() with 160 particles"
@test repr(system) == show_compact
@@ -96,6 +102,20 @@
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"""
@test repr("text/plain", system) == show_box
+
+ fluid_system_with_buffer = FluidSystemMock2(nothing, nothing,
+ TrixiParticles.SystemBuffer(1, 3))
+ system = OpenBoundarySystem(outflow; fluid_system=fluid_system_with_buffer)
+ @test system.boundary_model isa BoundaryModelMirroringTafuni
+ @test system.buffer.buffer_size == 3
+
+ error_str = "`buffer_size` could not be inferred for `OpenBoundarySystem` " *
+ "because `fluid_system` has no buffer. Pass `buffer_size=...` " *
+ "explicitly or construct `fluid_system` with `buffer_size=...`."
+ @test_throws ArgumentError(error_str) OpenBoundarySystem(outflow;
+ fluid_system=FluidSystemMock2(nothing,
+ nothing,
+ nothing))
end
@testset "boundary zone width" begin
diff --git a/test/systems/packing_system.jl b/test/systems/packing_system.jl
index 20c915fb4c..7b80a723b4 100644
--- a/test/systems/packing_system.jl
+++ b/test/systems/packing_system.jl
@@ -41,6 +41,22 @@
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"""
@test repr("text/plain", system) == show_box
+ signed_distance_field = SignedDistanceField(geometry, 0.1;
+ use_for_boundary_packing=true,
+ max_signed_distance=0.3)
+ boundary_sampled = sample_boundary(signed_distance_field; boundary_density=1.0,
+ boundary_thickness=0.2,
+ place_on_shell=false)
+ system = ParticlePackingSystem(boundary_sampled; signed_distance_field,
+ background_pressure=1.0, is_boundary=true,
+ boundary_thickness=0.2)
+ @test system.shift_length == -0.25
+ @test_throws ArgumentError ParticlePackingSystem(boundary_sampled;
+ signed_distance_field,
+ background_pressure=1.0,
+ is_boundary=true,
+ boundary_thickness=0.4)
+
system = ParticlePackingSystem(initial_condition,
signed_distance_field=nothing,
background_pressure=1.0)
diff --git a/test/systems/rigid_body/contact_history.jl b/test/systems/rigid_body/contact_history.jl
new file mode 100644
index 0000000000..0a4b56e07d
--- /dev/null
+++ b/test/systems/rigid_body/contact_history.jl
@@ -0,0 +1,301 @@
+@trixi_testset "Rigid Contact History" begin
+ using OrdinaryDiffEqLowStorageRK
+
+ # Rigid-wall setup used to exercise callback scheduling and persistent manifold IDs.
+ rigid_coordinates = reshape([0.0, 0.05], 2, 1)
+ rigid_velocity = reshape([1.0, -1.0], 2, 1)
+ rigid_mass = [1.0]
+ rigid_density = [1000.0]
+ rigid_ic = InitialCondition(; coordinates=rigid_coordinates,
+ velocity=rigid_velocity,
+ mass=rigid_mass,
+ density=rigid_density,
+ particle_spacing=0.1)
+
+ boundary_coordinates = reshape([0.0, 0.0], 2, 1)
+ boundary_mass = [1.0]
+ boundary_density = [1000.0]
+ boundary_ic = InitialCondition(; coordinates=boundary_coordinates,
+ mass=boundary_mass,
+ density=boundary_density,
+ particle_spacing=0.1)
+
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ smoothing_length = 0.15
+ boundary_model = BoundaryModelDummyParticles(boundary_density, boundary_mass,
+ SummationDensity(),
+ smoothing_kernel,
+ smoothing_length)
+ boundary_system = WallBoundarySystem(boundary_ic, boundary_model)
+
+ history_model = RigidContactModel(; normal_stiffness=2.0e4,
+ normal_damping=20.0,
+ static_friction_coefficient=0.6,
+ kinetic_friction_coefficient=0.4,
+ tangential_stiffness=1.0e4,
+ tangential_damping=5.0,
+ contact_distance=0.1,
+ stick_velocity_tolerance=1.0e-6)
+ rigid_system = RigidBodySystem(rigid_ic;
+ acceleration=(0.0, 0.0),
+ contact_model=history_model)
+
+ # Tangential displacement is path-dependent, so frictional systems allocate history and
+ # require accepted-step updates.
+ @test TrixiParticles.requires_update_callback(rigid_system)
+ @test rigid_system.cache.contact_tangential_displacement isa Dict
+
+ semi = Semidiscretization(rigid_system, boundary_system)
+ ode = semidiscretize(semi, (0.0, 0.01))
+ v_ode, u_ode = ode.u0.x
+ dv_ode = zero(v_ode)
+ # Evaluating the RHS without the required callback must fail before using stale history.
+ update_error = try
+ TrixiParticles.kick!(dv_ode, v_ode, u_ode, ode.p, 0.0)
+ nothing
+ catch err
+ err
+ end
+ @test update_error isa ArgumentError
+ @test occursin("`UpdateCallback` is required for `RigidBodySystem`",
+ sprint(showerror, update_error))
+
+ # Sparse callbacks would integrate several accepted steps as one history increment.
+ callback_error = try
+ init(ode, RDPK3SpFSAL49(); adaptive=false, dt=1.0e-3,
+ callback=UpdateCallback(interval=2))
+ nothing
+ catch err
+ err
+ end
+ @test callback_error isa ArgumentError
+ @test occursin("requires `UpdateCallback(interval=1)`",
+ sprint(showerror, callback_error))
+
+ # Initialization registers a zero displacement; the first accepted step advances it once.
+ integrator = init(ode, RDPK3SpFSAL49(); adaptive=false, dt=1.0e-3,
+ save_everystep=false, callback=UpdateCallback())
+ initialized_map = rigid_system.cache.contact_tangential_displacement
+ @test length(initialized_map) == 1
+ @test all(iszero, values(initialized_map))
+
+ step!(integrator)
+ @test 0 < norm(first(values(initialized_map))) < 1.5e-3
+
+ TrixiParticles.reset_contact_history!(rigid_system)
+
+ # A direct accepted-step update creates a wall-contact key and integrates the slip over
+ # exactly the supplied step size.
+ TrixiParticles.update_rigid_contact_eachstep!(rigid_system, v_ode, u_ode, semi, 0.0,
+ 1.0e-3)
+
+ contact_map = rigid_system.cache.contact_tangential_displacement
+ @test length(contact_map) == 1
+ contact_key = first(keys(contact_map))
+ tangential_displacement = contact_map[contact_key]
+ @test contact_key.contact_kind == TrixiParticles.WallContact
+ @test contact_key.local_particle == 1
+ @test norm(tangential_displacement) > 0
+
+ # A geometrically different manifold must receive a new ID rather than inheriting the
+ # tangential displacement associated with the transient slot number.
+ old_contact_id = contact_key.contact_slot
+ descriptor = rigid_system.cache.wall_contact_descriptors[contact_key]
+ TrixiParticles.set_zero!(rigid_system.cache.contact_manifold_count)
+ TrixiParticles.set_zero!(rigid_system.cache.contact_manifold_weight_sum)
+ TrixiParticles.set_zero!(rigid_system.cache.contact_manifold_normal_sum)
+ TrixiParticles.set_zero!(rigid_system.cache.contact_manifold_wall_position_sum)
+ TrixiParticles.set_zero!(rigid_system.cache.contact_manifold_history_id)
+ rigid_system.cache.contact_manifold_count[1] = 1
+ rigid_system.cache.contact_manifold_weight_sum[1, 1] = 1.0
+ rigid_system.cache.contact_manifold_normal_sum[:, 1, 1] .= descriptor.normal
+ rigid_system.cache.contact_manifold_wall_position_sum[:, 1,
+ 1] .= descriptor.anchor .+
+ SVector(1.0, 0.0)
+ boundary_index = TrixiParticles.system_indices(boundary_system, semi)
+ TrixiParticles.match_wall_contact_manifolds!(rigid_system, boundary_index,
+ history_model;
+ update_descriptors=true)
+ @test rigid_system.cache.contact_manifold_history_id[1, 1] != old_contact_id
+
+ v_rigid = TrixiParticles.wrap_v(v_ode, rigid_system, semi)
+ u_rigid = TrixiParticles.wrap_u(u_ode, rigid_system, semi)
+ dv = TrixiParticles.wrap_v(dv_ode, rigid_system, semi)
+ # Restart files do not serialize path-dependent contact state.
+ TrixiParticles.restart_with!(rigid_system, v_rigid, u_rigid)
+ @test isempty(rigid_system.cache.contact_tangential_displacement)
+ @test isempty(rigid_system.cache.wall_contact_descriptors)
+ @test rigid_system.cache.next_wall_contact_id[] == 1
+ TrixiParticles.update_rigid_contact_eachstep!(rigid_system, v_ode, u_ode, semi, 0.0,
+ 1.0e-3)
+
+ # Recreated history opposes horizontal slip while the normal force opposes penetration;
+ # both signs must survive reduction from particle forces to acceleration.
+ TrixiParticles.update_final!(rigid_system, v_rigid, u_rigid, v_ode, u_ode, semi,
+ 0.0)
+ TrixiParticles.reset_interaction_caches!(semi)
+ TrixiParticles.interact!(dv_ode, v_ode, u_ode, rigid_system, boundary_system, semi)
+ TrixiParticles.finalize_interaction!(rigid_system, dv, v_rigid, u_rigid,
+ dv_ode, v_ode, u_ode, semi)
+
+ @test rigid_system.force_per_particle[1, 1] < 0.0
+ @test rigid_system.force_per_particle[2, 1] > 0.0
+ @test dv[1, 1] < 0.0
+ @test dv[2, 1] > 0.0
+
+ # Once contact is lost, its path-dependent displacement must not affect future contacts.
+ u_rigid[2, 1] = 0.2
+ TrixiParticles.update_rigid_contact_eachstep!(rigid_system, v_ode, u_ode, semi, 0.0,
+ 1.0e-3)
+ @test isempty(rigid_system.cache.contact_tangential_displacement)
+
+ # Both ordered rigid-rigid passes integrate opposite histories and must return an exact
+ # action-reaction pair, including tangential force.
+ rigid_coordinates_1 = reshape([0.0, 0.0], 2, 1)
+ rigid_coordinates_2 = reshape([0.08, 0.0], 2, 1)
+ rigid_velocity_1 = reshape([1.0, 0.5], 2, 1)
+ rigid_velocity_2 = reshape([-0.5, -0.25], 2, 1)
+ rigid_ic_1 = InitialCondition(; coordinates=rigid_coordinates_1,
+ velocity=rigid_velocity_1,
+ mass=[2.0],
+ density=rigid_density,
+ particle_spacing=0.1)
+ rigid_ic_2 = InitialCondition(; coordinates=rigid_coordinates_2,
+ velocity=rigid_velocity_2,
+ mass=rigid_mass,
+ density=rigid_density,
+ particle_spacing=0.1)
+
+ rigid_contact_model_1 = RigidContactModel(; normal_stiffness=20.0,
+ normal_damping=4.0,
+ static_friction_coefficient=0.6,
+ kinetic_friction_coefficient=0.4,
+ tangential_stiffness=10.0,
+ tangential_damping=2.0,
+ contact_distance=0.1)
+ rigid_contact_model_2 = RigidContactModel(; normal_stiffness=30.0,
+ normal_damping=8.0,
+ static_friction_coefficient=0.5,
+ kinetic_friction_coefficient=0.3,
+ tangential_stiffness=8.0,
+ tangential_damping=1.0,
+ contact_distance=0.12)
+
+ rigid_system_1 = RigidBodySystem(rigid_ic_1;
+ acceleration=(0.0, 0.0),
+ contact_model=rigid_contact_model_1)
+ rigid_system_2 = RigidBodySystem(rigid_ic_2;
+ acceleration=(0.0, 0.0),
+ contact_model=rigid_contact_model_2)
+
+ semi_rigid = Semidiscretization(rigid_system_1, rigid_system_2)
+ ode_rigid = semidiscretize(semi_rigid, (0.0, 0.01))
+ v_ode_rigid, u_ode_rigid = ode_rigid.u0.x
+ dv_ode_rigid = zero(v_ode_rigid)
+ TrixiParticles.update_rigid_contact_eachstep!(rigid_system_1, v_ode_rigid,
+ u_ode_rigid, semi_rigid, 0.0,
+ 1.0e-3)
+ TrixiParticles.update_rigid_contact_eachstep!(rigid_system_2, v_ode_rigid,
+ u_ode_rigid, semi_rigid, 0.0,
+ 1.0e-3)
+
+ rigid_key_1 = first(keys(rigid_system_1.cache.contact_tangential_displacement))
+ rigid_key_2 = first(keys(rigid_system_2.cache.contact_tangential_displacement))
+ @test rigid_key_1.contact_kind == TrixiParticles.RigidRigidContact
+ @test rigid_key_2.contact_kind == TrixiParticles.RigidRigidContact
+
+ v_rigid_1 = TrixiParticles.wrap_v(v_ode_rigid, rigid_system_1, semi_rigid)
+ u_rigid_1 = TrixiParticles.wrap_u(u_ode_rigid, rigid_system_1, semi_rigid)
+ v_rigid_2 = TrixiParticles.wrap_v(v_ode_rigid, rigid_system_2, semi_rigid)
+ u_rigid_2 = TrixiParticles.wrap_u(u_ode_rigid, rigid_system_2, semi_rigid)
+ TrixiParticles.update_final!(rigid_system_1, v_rigid_1, u_rigid_1,
+ v_ode_rigid, u_ode_rigid, semi_rigid, 0.0)
+ TrixiParticles.update_final!(rigid_system_2, v_rigid_2, u_rigid_2,
+ v_ode_rigid, u_ode_rigid, semi_rigid, 0.0)
+ TrixiParticles.interact!(dv_ode_rigid, v_ode_rigid, u_ode_rigid,
+ rigid_system_1, rigid_system_2, semi_rigid)
+ TrixiParticles.interact!(dv_ode_rigid, v_ode_rigid, u_ode_rigid,
+ rigid_system_2, rigid_system_1, semi_rigid)
+
+ # The uncapped pair force uses the symmetric pair parameters and opposite tangential
+ # histories, so both ordered passes must agree on one analytical force.
+ pair_contact_distance = max(rigid_contact_model_1.contact_distance,
+ rigid_contact_model_2.contact_distance)
+ pair_normal_stiffness = (rigid_contact_model_1.normal_stiffness +
+ rigid_contact_model_2.normal_stiffness) / 2
+ pair_normal_damping = (rigid_contact_model_1.normal_damping +
+ rigid_contact_model_2.normal_damping) / 2
+ pair_penetration = pair_contact_distance - 0.08
+ normal_velocity = -1.5
+ expected_force_magnitude = pair_normal_stiffness * pair_penetration -
+ pair_normal_damping * normal_velocity
+ pair_tangential_stiffness = (rigid_contact_model_1.tangential_stiffness +
+ rigid_contact_model_2.tangential_stiffness) / 2
+ pair_tangential_damping = (rigid_contact_model_1.tangential_damping +
+ rigid_contact_model_2.tangential_damping) / 2
+ tangential_velocity = 0.75
+ tangential_displacement = 1.0e-3 * tangential_velocity
+ expected_tangential_force = -(pair_tangential_stiffness *
+ tangential_displacement +
+ pair_tangential_damping * tangential_velocity)
+
+ @test rigid_system_1.force_per_particle[1, 1] ≈ -expected_force_magnitude
+ @test rigid_system_1.force_per_particle[2, 1] ≈ expected_tangential_force
+ @test rigid_system_2.force_per_particle[1, 1] ≈ expected_force_magnitude
+ @test rigid_system_2.force_per_particle[2, 1] ≈ -expected_tangential_force
+ @test rigid_system_1.force_per_particle[:, 1] ≈
+ -rigid_system_2.force_per_particle[:, 1]
+ @test rigid_system_1.cache.contact_count[] == 1
+ @test rigid_system_2.cache.contact_count[] == 1
+ @test rigid_system_1.cache.max_contact_penetration[] ≈ pair_penetration
+ @test rigid_system_2.cache.max_contact_penetration[] ≈ pair_penetration
+
+ # Separating the bodies removes both ordered copies of their shared contact history.
+ u_rigid_2[1, 1] = 0.5
+ TrixiParticles.update_rigid_contact_eachstep!(rigid_system_1, v_ode_rigid,
+ u_ode_rigid, semi_rigid, 0.0,
+ 1.0e-3)
+ TrixiParticles.update_rigid_contact_eachstep!(rigid_system_2, v_ode_rigid,
+ u_ode_rigid, semi_rigid, 0.0,
+ 1.0e-3)
+ @test isempty(rigid_system_1.cache.contact_tangential_displacement)
+ @test isempty(rigid_system_2.cache.contact_tangential_displacement)
+
+ # Offset tangential forces must also produce the expected same-sense body torques.
+ torque_coordinates_1 = [-0.05 0.05; 0.0 0.0]
+ torque_coordinates_2 = [0.13 0.23; 0.0 0.0]
+ torque_velocity_1 = [0.0 0.0; 1.0 1.0]
+ torque_velocity_2 = zeros(2, 2)
+ torque_ic_1 = InitialCondition(; coordinates=torque_coordinates_1,
+ velocity=torque_velocity_1,
+ mass=ones(2), density=fill(1000.0, 2),
+ particle_spacing=0.1)
+ torque_ic_2 = InitialCondition(; coordinates=torque_coordinates_2,
+ velocity=torque_velocity_2,
+ mass=ones(2), density=fill(1000.0, 2),
+ particle_spacing=0.1)
+ torque_system_1 = RigidBodySystem(torque_ic_1;
+ acceleration=(0.0, 0.0),
+ contact_model=rigid_contact_model_1)
+ torque_system_2 = RigidBodySystem(torque_ic_2;
+ acceleration=(0.0, 0.0),
+ contact_model=rigid_contact_model_2)
+ torque_semi = Semidiscretization(torque_system_1, torque_system_2)
+ torque_ode = semidiscretize(torque_semi, (0.0, 0.01))
+ torque_v_ode, torque_u_ode = torque_ode.u0.x
+ torque_dv_ode = zero(torque_v_ode)
+ TrixiParticles.update_rigid_contact_eachstep!(torque_system_1, torque_v_ode,
+ torque_u_ode, torque_semi, 0.0,
+ 1.0e-3)
+ TrixiParticles.update_rigid_contact_eachstep!(torque_system_2, torque_v_ode,
+ torque_u_ode, torque_semi, 0.0,
+ 1.0e-3)
+ TrixiParticles.update_systems_and_nhs(torque_v_ode, torque_u_ode, torque_semi,
+ 0.0)
+ TrixiParticles.system_interaction!(torque_dv_ode, torque_v_ode, torque_u_ode,
+ torque_semi)
+
+ @test torque_system_1.resultant_force[] ≈ -torque_system_2.resultant_force[]
+ @test torque_system_1.resultant_torque[] < 0
+ @test torque_system_2.resultant_torque[] < 0
+end
diff --git a/test/systems/rigid_body/contact_model.jl b/test/systems/rigid_body/contact_model.jl
new file mode 100644
index 0000000000..e8e12485ae
--- /dev/null
+++ b/test/systems/rigid_body/contact_model.jl
@@ -0,0 +1,327 @@
+@trixi_testset "Contact Model and Rigid-Wall Contact" begin
+ # A single particle approaching a wall gives analytically simple penetration and force
+ # values for checking contact-model construction and runtime interaction paths.
+ rigid_coordinates = reshape([0.0, 0.05], 2, 1)
+ rigid_velocity = reshape([0.0, -1.0], 2, 1)
+ rigid_mass = [1.0]
+ rigid_density = [1000.0]
+ rigid_ic = InitialCondition(; coordinates=rigid_coordinates,
+ velocity=rigid_velocity,
+ mass=rigid_mass,
+ density=rigid_density,
+ particle_spacing=0.1)
+
+ boundary_coordinates = reshape([0.0, 0.0], 2, 1)
+ boundary_mass = [1.0]
+ boundary_density = [1000.0]
+ boundary_ic = InitialCondition(; coordinates=boundary_coordinates,
+ mass=boundary_mass,
+ density=boundary_density,
+ particle_spacing=0.1)
+
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ smoothing_length = 0.15
+ boundary_model = BoundaryModelDummyParticles(boundary_density, boundary_mass,
+ SummationDensity(),
+ smoothing_kernel,
+ smoothing_length)
+ boundary_system = WallBoundarySystem(boundary_ic, boundary_model)
+
+ contact_model = RigidContactModel(; normal_stiffness=2.0e4,
+ normal_damping=20.0,
+ contact_distance=0.1)
+
+ # Copying a normal-only model fills all inactive friction parameters with zero.
+ runtime_model = TrixiParticles.copy_contact_model(contact_model, 0.1, Float64)
+ @test runtime_model.normal_stiffness ≈ 2.0e4
+ @test runtime_model.normal_damping ≈ 20.0
+ @test runtime_model.static_friction_coefficient ≈ 0.0
+ @test runtime_model.kinetic_friction_coefficient ≈ 0.0
+ @test runtime_model.tangential_stiffness ≈ 0.0
+ @test runtime_model.tangential_damping ≈ 0.0
+ @test runtime_model.contact_distance ≈ 0.1
+ @test runtime_model.stick_velocity_tolerance ≈ 1.0e-6
+ @test runtime_model.penetration_slop ≈ 0.0
+
+ # Runtime copies adopt the system's scalar type and replace a zero contact distance with
+ # the system particle spacing.
+ advanced_contact_model = RigidContactModel(; normal_stiffness=5.0,
+ normal_damping=1.5,
+ static_friction_coefficient=0.6,
+ kinetic_friction_coefficient=0.4,
+ tangential_stiffness=9.0,
+ tangential_damping=2.5,
+ contact_distance=0.0,
+ stick_velocity_tolerance=1.0e-5,
+ penetration_slop=0.01)
+ advanced_runtime_model = TrixiParticles.copy_contact_model(advanced_contact_model,
+ 0.125, Float32)
+ @test advanced_runtime_model.normal_stiffness ≈ Float32(5.0)
+ @test advanced_runtime_model.normal_damping ≈ Float32(1.5)
+ @test advanced_runtime_model.static_friction_coefficient ≈ Float32(0.6)
+ @test advanced_runtime_model.kinetic_friction_coefficient ≈ Float32(0.4)
+ @test advanced_runtime_model.tangential_stiffness ≈ Float32(9.0)
+ @test advanced_runtime_model.tangential_damping ≈ Float32(2.5)
+ @test advanced_runtime_model.contact_distance ≈ Float32(0.125)
+ @test advanced_runtime_model.stick_velocity_tolerance ≈ Float32(1.0e-5)
+ @test advanced_runtime_model.penetration_slop ≈ Float32(0.01)
+
+ # The same spacing fallback also applies when no contact distance is supplied.
+ spacing_scaled_model = RigidContactModel(; normal_stiffness=5.0)
+ spacing_scaled_runtime = TrixiParticles.copy_contact_model(spacing_scaled_model,
+ 0.125,
+ Float64)
+ @test spacing_scaled_runtime.contact_distance ≈ 0.125
+
+ # Reject invalid normal/friction values and incomplete friction configurations rather
+ # than silently constructing a model with no physical tangential response.
+ @test_throws ArgumentError RigidContactModel(; normal_stiffness=0.0)
+ @test_throws ArgumentError RigidContactModel(; normal_stiffness=1.0,
+ normal_damping=-1.0)
+ @test_throws ArgumentError RigidContactModel(; normal_stiffness=1.0,
+ contact_distance=-1.0)
+ @test_throws ArgumentError RigidContactModel(; normal_stiffness=1.0,
+ static_friction_coefficient=-0.1)
+ @test_throws ArgumentError RigidContactModel(; normal_stiffness=1.0,
+ static_friction_coefficient=0.3,
+ kinetic_friction_coefficient=0.4)
+ @test_throws ArgumentError RigidContactModel(; normal_stiffness=1.0,
+ tangential_stiffness=-1.0)
+ @test_throws ArgumentError RigidContactModel(; normal_stiffness=1.0,
+ tangential_damping=-1.0)
+ @test_throws ArgumentError RigidContactModel(; normal_stiffness=1.0,
+ stick_velocity_tolerance=-1.0)
+ @test_throws ArgumentError RigidContactModel(; normal_stiffness=1.0,
+ penetration_slop=-1.0)
+ @test_throws ArgumentError RigidContactModel(; normal_stiffness=1.0,
+ static_friction_coefficient=0.6,
+ kinetic_friction_coefficient=0.4)
+ @test_throws ArgumentError RigidContactModel(; normal_stiffness=1.0,
+ static_friction_coefficient=0.0,
+ kinetic_friction_coefficient=0.0,
+ tangential_stiffness=1.0)
+
+ # Exercise both the zero-slip restoring direction and regularized kinetic branch.
+ force_model = RigidContactModel(; normal_stiffness=100.0,
+ static_friction_coefficient=0.6,
+ kinetic_friction_coefficient=0.4,
+ tangential_stiffness=100.0,
+ stick_velocity_tolerance=1.0e-6)
+ zero_slip_force = TrixiParticles.tangential_contact_force(force_model,
+ SVector(1.0, 0.0),
+ SVector(0.0, 0.0), 10.0)
+ @test zero_slip_force ≈ SVector(-4.0, 0.0)
+
+ sliding_velocity = SVector(1.0e-5, 0.0)
+ sliding_force = TrixiParticles.tangential_contact_force(force_model,
+ SVector(1.0, 0.0),
+ sliding_velocity, 1.0)
+ @test dot(sliding_force, sliding_velocity) <= 0
+
+ force_model_f32 = RigidContactModel(; normal_stiffness=100.0f0,
+ static_friction_coefficient=0.6f0,
+ kinetic_friction_coefficient=0.4f0,
+ tangential_stiffness=100.0f0,
+ stick_velocity_tolerance=1.0f-6)
+ sliding_force_f32 = TrixiParticles.tangential_contact_force(force_model_f32,
+ SVector(1.0f0, 0.0f0),
+ SVector(1.0f-5, 0.0f0),
+ 1.0f0)
+ @test norm(sliding_force_f32) ≈ norm(sliding_force) rtol = 5.0f-6
+
+ # Pair reduction must be independent of the order in which the two bodies are visited.
+ pair_parameters_12 = TrixiParticles.rigid_contact_pair_parameters(force_model,
+ advanced_contact_model)
+ pair_parameters_21 = TrixiParticles.rigid_contact_pair_parameters(advanced_contact_model,
+ force_model)
+ @test pair_parameters_12 == pair_parameters_21
+
+ # Only frictional contact needs persistent history and therefore an update callback.
+ rigid_system = RigidBodySystem(rigid_ic;
+ acceleration=(0.0, 0.0),
+ contact_model=contact_model)
+ rigid_system_advanced = RigidBodySystem(rigid_ic;
+ acceleration=(0.0, 0.0),
+ contact_model=advanced_contact_model)
+ rigid_system_with_boundary = RigidBodySystem(rigid_ic;
+ acceleration=(0.0, 0.0),
+ boundary_model=boundary_model,
+ contact_model=contact_model)
+ rigid_system_custom_manifolds = RigidBodySystem(rigid_ic;
+ acceleration=(0.0, 0.0),
+ contact_model=contact_model,
+ max_manifolds=3)
+ rigid_system_without_contact = RigidBodySystem(rigid_ic;
+ acceleration=(0.0, 0.0),
+ boundary_model=boundary_model)
+ @test haskey(rigid_system.cache, :contact_manifold_count)
+ @test rigid_system.contact_model.normal_stiffness ≈ contact_model.normal_stiffness
+ @test rigid_system.contact_model.normal_damping ≈ contact_model.normal_damping
+ @test rigid_system.contact_model.contact_distance ≈ contact_model.contact_distance
+ @test !TrixiParticles.requires_update_callback(rigid_system)
+ @test isnothing(rigid_system.cache.contact_tangential_displacement)
+ @test TrixiParticles.requires_update_callback(rigid_system_advanced)
+ @test rigid_system_advanced.cache.contact_tangential_displacement isa Dict
+ # Contact configuration is serialized with the system and determines rigid-wall search
+ # support; the reverse wall-rigid direction does not initiate contact.
+ rigid_system_data = Dict{String, Any}()
+ TrixiParticles.add_system_data!(rigid_system_data, rigid_system)
+ @test rigid_system_data["contact_model"]["model"] ==
+ TrixiParticles.type2string(rigid_system.contact_model)
+ @test rigid_system_data["contact_model"]["normal_stiffness"] ≈
+ contact_model.normal_stiffness
+ @test rigid_system_data["contact_model"]["normal_damping"] ≈
+ contact_model.normal_damping
+ @test rigid_system_data["contact_model"]["contact_distance"] ≈
+ contact_model.contact_distance
+ @test size(rigid_system_custom_manifolds.cache.contact_manifold_weight_sum, 1) == 3
+ @test TrixiParticles.compact_support(rigid_system, boundary_system) ≈
+ contact_model.contact_distance
+ @test TrixiParticles.compact_support(rigid_system_with_boundary,
+ boundary_system) ≈
+ contact_model.contact_distance
+ @test iszero(TrixiParticles.compact_support(boundary_system, rigid_system))
+ @test iszero(TrixiParticles.compact_support(rigid_system_without_contact,
+ boundary_system))
+ @test_throws ArgumentError RigidBodySystem(rigid_ic; contact_model, max_manifolds=0)
+
+ # Runtime metadata must expose every active friction parameter for reproducibility.
+ system_meta_data = Dict{String, Any}()
+ TrixiParticles.add_system_data!(system_meta_data, rigid_system)
+ @test system_meta_data["contact_model"]["normal_stiffness"] ≈ 2.0e4
+ @test system_meta_data["contact_model"]["normal_damping"] ≈ 20.0
+ @test system_meta_data["contact_model"]["contact_distance"] ≈ 0.1
+
+ system_meta_data = Dict{String, Any}()
+ TrixiParticles.add_system_data!(system_meta_data, rigid_system_advanced)
+ @test system_meta_data["contact_model"]["normal_stiffness"] ≈ 5.0
+ @test system_meta_data["contact_model"]["normal_damping"] ≈ 1.5
+ @test system_meta_data["contact_model"]["static_friction_coefficient"] ≈ 0.6
+ @test system_meta_data["contact_model"]["kinetic_friction_coefficient"] ≈ 0.4
+ @test system_meta_data["contact_model"]["tangential_stiffness"] ≈ 9.0
+ @test system_meta_data["contact_model"]["tangential_damping"] ≈ 2.5
+ @test system_meta_data["contact_model"]["contact_distance"] ≈ 0.1
+ @test system_meta_data["contact_model"]["stick_velocity_tolerance"] ≈ 1.0e-5
+ @test system_meta_data["contact_model"]["penetration_slop"] ≈ 0.01
+
+ semi = Semidiscretization(rigid_system, boundary_system)
+ ode = semidiscretize(semi, (0.0, 0.01))
+ v_ode, u_ode = ode.u0.x
+ dv_ode = zero(v_ode)
+ wall_contact_dt = sqrt(rigid_mass[1] / contact_model.normal_stiffness)
+
+ # The stable contact step is the minimum active spring and damping timescale.
+ @test TrixiParticles.contact_time_step(rigid_system) ≈ wall_contact_dt
+ @test TrixiParticles.contact_time_step(rigid_system, boundary_system) ≈
+ wall_contact_dt
+ advanced_contact_dt = min(sqrt(rigid_mass[1] /
+ advanced_runtime_model.normal_stiffness),
+ rigid_mass[1] /
+ advanced_runtime_model.normal_damping,
+ sqrt(rigid_mass[1] /
+ advanced_runtime_model.tangential_stiffness),
+ rigid_mass[1] /
+ advanced_runtime_model.tangential_damping)
+ @test TrixiParticles.contact_time_step(rigid_system_advanced) ≈
+ advanced_contact_dt
+
+ # Check direct and full-RHS wall contact, including contact support wider than the
+ # boundary model's hydrodynamic support.
+ kick_boundary_model = BoundaryModelDummyParticles(boundary_density, boundary_mass,
+ SummationDensity(),
+ smoothing_kernel,
+ smoothing_length)
+ kick_rigid_system = RigidBodySystem(rigid_ic; acceleration=(0.0, 0.0),
+ contact_model)
+ kick_boundary_system = WallBoundarySystem(boundary_ic, kick_boundary_model)
+ kick_semi = Semidiscretization(kick_rigid_system, kick_boundary_system)
+ kick_ode = semidiscretize(kick_semi, (0.0, 0.01))
+ kick_v_ode, kick_u_ode = kick_ode.u0.x
+ kick_dv_ode = zero(kick_v_ode)
+
+ TrixiParticles.kick!(kick_dv_ode, kick_v_ode, kick_u_ode, kick_ode.p, 0.0)
+ kick_dv = TrixiParticles.wrap_v(kick_dv_ode, kick_rigid_system, kick_semi)
+
+ @test kick_dv[2, 1] > 0
+ @test kick_rigid_system.resultant_force[][2] > 0
+
+ TrixiParticles.reset_interaction_caches!(semi)
+ TrixiParticles.interact!(dv_ode, v_ode, u_ode, rigid_system, boundary_system, semi)
+ dv = TrixiParticles.wrap_v(dv_ode, rigid_system, semi)
+ v_rigid = TrixiParticles.wrap_v(v_ode, rigid_system, semi)
+ u_rigid = TrixiParticles.wrap_u(u_ode, rigid_system, semi)
+ TrixiParticles.finalize_interaction!(rigid_system, dv, v_rigid, u_rigid,
+ dv_ode, v_ode, u_ode, semi)
+
+ @test dv[2, 1] > 0
+ @test rigid_system.cache.contact_count[] == 1
+ @test rigid_system.cache.max_contact_penetration[] ≈ 0.05
+ direct_force = copy(rigid_system.force_per_particle)
+ direct_resultant_force = rigid_system.resultant_force[]
+
+ # Repeating the same direct interaction after a cache reset must reproduce, rather than
+ # accumulate, force and diagnostic values.
+ TrixiParticles.set_zero!(dv_ode)
+ TrixiParticles.update_final!(rigid_system, v_rigid, u_rigid, v_ode, u_ode, semi,
+ 0.0)
+ TrixiParticles.reset_interaction_caches!(semi)
+ TrixiParticles.interact!(dv_ode, v_ode, u_ode, rigid_system, boundary_system, semi)
+ TrixiParticles.finalize_interaction!(rigid_system, dv, v_rigid, u_rigid,
+ dv_ode, v_ode, u_ode, semi)
+
+ @test rigid_system.cache.contact_count[] == 1
+ @test rigid_system.cache.max_contact_penetration[] ≈ 0.05
+ @test rigid_system.force_per_particle == direct_force
+ @test rigid_system.resultant_force[] ≈ direct_resultant_force
+
+ # Finalizing without a preceding interaction must not retain stale contact resultants.
+ TrixiParticles.set_zero!(dv_ode)
+ TrixiParticles.update_final!(rigid_system, v_rigid, u_rigid, v_ode, u_ode, semi,
+ 0.0)
+ TrixiParticles.reset_interaction_caches!(semi)
+ TrixiParticles.finalize_interaction!(rigid_system, dv, v_rigid, u_rigid,
+ dv_ode, v_ode, u_ode, semi)
+
+ @test all(iszero, dv)
+ @test iszero(rigid_system.resultant_force[])
+ @test iszero(rigid_system.resultant_torque[])
+ @test iszero(rigid_system.angular_acceleration_force[])
+
+ # Contact distance, not the shorter hydrodynamic kernel support, controls whether the
+ # rigid particle and wall become neighbors.
+ far_rigid_ic = InitialCondition(; coordinates=reshape([0.0, 0.09], 2, 1),
+ velocity=rigid_velocity,
+ mass=rigid_mass,
+ density=rigid_density,
+ particle_spacing=0.1)
+ short_support_boundary_model = BoundaryModelDummyParticles(boundary_density,
+ boundary_mass,
+ SummationDensity(),
+ smoothing_kernel,
+ 0.04)
+ short_support_boundary = WallBoundarySystem(boundary_ic,
+ short_support_boundary_model)
+ far_rigid_system = RigidBodySystem(far_rigid_ic; acceleration=(0.0, 0.0),
+ contact_model)
+ short_support_semi = Semidiscretization(far_rigid_system, short_support_boundary)
+ short_support_ode = semidiscretize(short_support_semi, (0.0, 0.01))
+ short_support_v_ode, short_support_u_ode = short_support_ode.u0.x
+ short_support_dv_ode = zero(short_support_v_ode)
+
+ TrixiParticles.reset_interaction_caches!(short_support_semi)
+ TrixiParticles.interact!(short_support_dv_ode, short_support_v_ode,
+ short_support_u_ode, far_rigid_system,
+ short_support_boundary, short_support_semi)
+ short_support_dv = TrixiParticles.wrap_v(short_support_dv_ode, far_rigid_system,
+ short_support_semi)
+ short_support_v = TrixiParticles.wrap_v(short_support_v_ode, far_rigid_system,
+ short_support_semi)
+ short_support_u = TrixiParticles.wrap_u(short_support_u_ode, far_rigid_system,
+ short_support_semi)
+ TrixiParticles.finalize_interaction!(far_rigid_system, short_support_dv,
+ short_support_v, short_support_u,
+ short_support_dv_ode, short_support_v_ode,
+ short_support_u_ode, short_support_semi)
+
+ @test short_support_dv[2, 1] > 0
+end
diff --git a/test/systems/rigid_body/core.jl b/test/systems/rigid_body/core.jl
new file mode 100644
index 0000000000..0ecc480fa4
--- /dev/null
+++ b/test/systems/rigid_body/core.jl
@@ -0,0 +1,327 @@
+@trixi_testset "Constructor" begin
+ # Construction copies immutable input data but leaves state-derived rigid kinematics at
+ # zero until the first state update.
+ coordinates = [1.0 2.0 3.0
+ 1.0 2.0 3.0]
+ mass = [1.25, 1.5, 1.75]
+ material_densities = [990.0, 995.0, 1000.0]
+
+ initial_condition = InitialCondition(; coordinates, mass,
+ density=material_densities)
+
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ smoothing_length = 0.12
+ boundary_model = BoundaryModelDummyParticles(material_densities, mass,
+ SummationDensity(),
+ smoothing_kernel,
+ smoothing_length)
+
+ system = RigidBodySystem(initial_condition; boundary_model,
+ acceleration=(0.0, -9.81), particle_spacing=0.1)
+
+ @test ndims(system) == 2
+ @test system.initial_condition == initial_condition
+ @test all(iszero, system.relative_coordinates)
+ @test system.mass == mass
+ @test system.material_density == material_densities
+ @test system.initial_velocity == initial_condition.velocity
+ @test system.acceleration == [0.0, -9.81]
+ @test iszero(system.center_of_mass[])
+ @test iszero(system.center_of_mass_velocity[])
+ @test iszero(system.angular_velocity[])
+ @test system.particle_spacing == 0.1
+ @test system.boundary_model == boundary_model
+ @test system.adhesion_coefficient == 0.0
+ @test TrixiParticles.v_nvariables(system) == 2
+
+ semi = Semidiscretization(system, neighborhood_search=nothing)
+ system = semi.systems[1]
+ ode = semidiscretize(semi, (0.0, 0.0); reset_threads=false)
+
+ # A stationary body without contact has no finite stability restriction of its own.
+ dt = TrixiParticles.calculate_dt(ode.u0.x[1], ode.u0.x[2], 0.25, system, semi)
+ @test isinf(dt)
+end
+
+@trixi_testset "Show" begin
+ # A system without contact should omit contact caches and keep both display formats
+ # focused on its active boundary model.
+ coordinates = [1.0 2.0
+ 1.0 2.0]
+ mass = [1.25, 1.5]
+ material_densities = [990.0, 1000.0]
+
+ initial_condition = InitialCondition(; coordinates, mass,
+ density=material_densities)
+
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ smoothing_length = 0.12
+ boundary_model = BoundaryModelDummyParticles(material_densities, mass,
+ SummationDensity(),
+ smoothing_kernel,
+ smoothing_length)
+
+ system = RigidBodySystem(initial_condition; boundary_model,
+ acceleration=(0.0, -9.81))
+ @test !haskey(system.cache, :contact_manifold_count)
+
+ show_compact = "RigidBodySystem{2}([0.0, -9.81], BoundaryModelDummyParticles(SummationDensity, Nothing)) with 2 particles"
+ @test repr(system) == show_compact
+
+ show_box = """
+ ┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
+ │ RigidBodySystem{2} │
+ │ ══════════════════ │
+ │ #particles: ………………………………………………… 2 │
+ │ acceleration: …………………………………………… [0.0, -9.81] │
+ │ boundary model: ……………………………………… BoundaryModelDummyParticles(SummationDensity, Nothing) │
+ └──────────────────────────────────────────────────────────────────────────────────────────────────┘"""
+ @test repr("text/plain", system) == show_box
+end
+
+@trixi_testset "Hydrodynamic Density" begin
+ # Fluid interactions use density, mass, and smoothing length from the boundary model,
+ # while structural mechanics retains the rigid material density.
+ coordinates = [1.0 2.0
+ 1.0 2.0]
+ mass = [1.25, 1.5]
+ material_densities = [990.0, 1000.0]
+ hydrodynamic_densities = [1001.0, 1002.0]
+ hydrodynamic_masses = [2.5, 3.0]
+
+ initial_condition = InitialCondition(; coordinates, mass,
+ density=material_densities)
+
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ smoothing_length = 0.12
+ boundary_model = BoundaryModelDummyParticles(hydrodynamic_densities,
+ hydrodynamic_masses,
+ SummationDensity(),
+ smoothing_kernel,
+ smoothing_length)
+
+ system = RigidBodySystem(initial_condition; boundary_model)
+ v = zeros(TrixiParticles.v_nvariables(system),
+ TrixiParticles.n_integrated_particles(system))
+
+ @test TrixiParticles.current_density(v, system) == hydrodynamic_densities
+ @test TrixiParticles.hydrodynamic_mass(system, 1) == hydrodynamic_masses[1]
+ @test TrixiParticles.smoothing_length(system, 1) == smoothing_length
+ @test system.material_density == material_densities
+
+ monaghan_model = BoundaryModelMonaghanKajtar(10.0, 1.0, smoothing_length,
+ hydrodynamic_masses)
+ system_monaghan = RigidBodySystem(initial_condition; boundary_model=monaghan_model)
+ @test TrixiParticles.hydrodynamic_mass(system_monaghan, 1) == hydrodynamic_masses[1]
+end
+
+@trixi_testset "Source Terms without Boundary Model" begin
+ # Source terms must act on a standalone rigid system without requiring hydrodynamic state.
+ coordinates = [1.0 2.0
+ 1.0 2.0]
+ mass = [1.25, 1.5]
+ material_densities = [990.0, 1000.0]
+ initial_condition = InitialCondition(; coordinates, mass,
+ density=material_densities)
+
+ source_terms = (coords, velocity, density, pressure,
+ t) -> SVector(density, pressure)
+ system = RigidBodySystem(initial_condition; source_terms)
+ semi = Semidiscretization(system, neighborhood_search=nothing)
+ system = semi.systems[1]
+ ode = semidiscretize(semi, (0.0, 0.0); reset_threads=false)
+
+ v_ode = ode.u0.x[1]
+ u_ode = ode.u0.x[2]
+ dv_ode = similar(v_ode)
+ fill!(dv_ode, 0.0)
+
+ TrixiParticles.add_source_terms!(dv_ode, v_ode, u_ode, semi, 0.0)
+
+ dv = TrixiParticles.wrap_v(dv_ode, system, semi)
+ @test dv[1, :] == material_densities
+ @test dv[2, :] == zeros(2)
+end
+
+@trixi_testset "Initial Angular Velocity" begin
+ # `apply_angular_velocity` encodes rigid rotation in particle velocities; initialization
+ # writes those velocities before runtime kinematic caches are populated.
+ coordinates_2d = [0.0 1.0
+ 0.0 0.0]
+ mass_2d = [1.0, 1.0]
+ density_2d = [1000.0, 1000.0]
+ ic_2d = apply_angular_velocity(InitialCondition(; coordinates=coordinates_2d,
+ mass=mass_2d,
+ density=density_2d),
+ 2.0)
+
+ system_2d = RigidBodySystem(ic_2d; particle_spacing=0.1)
+ u0_2d = zeros(2, 2)
+ v0_2d = zeros(2, 2)
+ TrixiParticles.write_u0!(u0_2d, system_2d)
+ TrixiParticles.write_v0!(v0_2d, system_2d)
+
+ @test iszero(system_2d.angular_velocity[])
+ @test v0_2d == [0.0 0.0
+ -1.0 1.0]
+ semi_2d = Semidiscretization(system_2d, neighborhood_search=nothing)
+ system_2d = semi_2d.systems[1]
+ ode_2d = semidiscretize(semi_2d, (0.0, 0.0); reset_threads=false)
+ dt_2d = TrixiParticles.calculate_dt(v0_2d, u0_2d, 0.25, system_2d, semi_2d)
+ @test isapprox(dt_2d, 0.25 * 0.1 / 1.0)
+ dt_2d_larger_cfl = TrixiParticles.calculate_dt(v0_2d, u0_2d, 0.5,
+ system_2d, semi_2d)
+ @test isapprox(dt_2d_larger_cfl, 0.5 * 0.1 / 1.0)
+ dt_2d_semi = TrixiParticles.calculate_dt(ode_2d.u0.x[1], ode_2d.u0.x[2], 0.25,
+ ode_2d.p.semi)
+ @test isapprox(dt_2d_semi, dt_2d)
+
+ TrixiParticles.update_final!(system_2d, v0_2d, u0_2d, nothing, nothing, nothing,
+ 0.0)
+ @test system_2d.angular_velocity[] == 2.0
+
+ # The same initialization and reconstruction path must preserve a vector-valued 3D spin.
+ coordinates_3d = [0.0 1.0
+ 0.0 0.0
+ 0.0 0.0]
+ mass_3d = [1.0, 1.0]
+ density_3d = [1000.0, 1000.0]
+ ic_3d = apply_angular_velocity(InitialCondition(; coordinates=coordinates_3d,
+ mass=mass_3d,
+ density=density_3d),
+ (0.0, 0.0, 2.0))
+
+ system_3d = RigidBodySystem(ic_3d)
+ u0_3d = zeros(3, 2)
+ v0_3d = zeros(3, 2)
+ TrixiParticles.write_u0!(u0_3d, system_3d)
+ TrixiParticles.write_v0!(v0_3d, system_3d)
+
+ @test iszero(system_3d.angular_velocity[])
+ @test v0_3d == [0.0 0.0
+ -1.0 1.0
+ 0.0 0.0]
+ TrixiParticles.update_final!(system_3d, v0_3d, u0_3d, nothing, nothing, nothing,
+ 0.0)
+ @test system_3d.angular_velocity[] == [0.0, 0.0, 2.0]
+end
+
+@trixi_testset "Time Step Estimate 3D Gyroscopic" begin
+ # An asymmetric 3D body is limited by both rotational velocity and the gyroscopic
+ # acceleration generated by its nonuniform principal inertia.
+ coordinates = [1.0 -1.0 0.0 0.0 0.0 0.0
+ 0.0 0.0 2.0 -2.0 0.0 0.0
+ 0.0 0.0 0.0 0.0 3.0 -3.0]
+ mass = fill(1.0, 6)
+ density = fill(1000.0, 6)
+ initial_condition = apply_angular_velocity(InitialCondition(; coordinates, mass,
+ density,
+ particle_spacing=10.0),
+ (1.0, 2.0, 3.0))
+ system = RigidBodySystem(initial_condition; acceleration=(0.0, 0.0, 0.0))
+ semi = Semidiscretization(system, neighborhood_search=nothing)
+ system = semi.systems[1]
+ ode = semidiscretize(semi, (0.0, 0.0); reset_threads=false)
+
+ angular_velocity = SVector(1.0, 2.0, 3.0)
+ gyroscopic_acceleration = SVector(-30 / 13, 12 / 5, -6 / 5)
+ acceleration_scale = 3.0 * (norm(angular_velocity)^2 +
+ norm(gyroscopic_acceleration))
+ dt_acceleration = 0.25 * sqrt(10.0 / acceleration_scale)
+ dt_velocity = 0.25 * 10.0 / (3.0 * norm(angular_velocity))
+
+ dt = TrixiParticles.calculate_dt(ode.u0.x[1], ode.u0.x[2], 0.25, system, semi)
+ @test isapprox(dt, min(dt_acceleration, dt_velocity))
+end
+
+@trixi_testset "Time Step Estimate from Initial Velocity" begin
+ # Timestep estimation must derive rotation directly from the ODE state before
+ # `update_final!` has populated the cached angular velocity.
+ coordinates = [-1.0 1.0
+ 0.0 0.0]
+ velocity = [0.0 0.0
+ -1.0 1.0]
+ mass = [1.0, 1.0]
+ density = [1000.0, 1000.0]
+ initial_condition = InitialCondition(; coordinates, velocity, mass, density,
+ particle_spacing=0.1)
+ system = RigidBodySystem(initial_condition; acceleration=(0.0, 0.0))
+ semi = Semidiscretization(system, neighborhood_search=nothing)
+ system = semi.systems[1]
+ ode = semidiscretize(semi, (0.0, 0.0); reset_threads=false)
+
+ @test iszero(system.angular_velocity[])
+
+ dt = TrixiParticles.calculate_dt(ode.u0.x[1], ode.u0.x[2], 0.25, system, semi)
+ @test isapprox(dt, 0.25 * 0.1 / 1.0)
+
+ v = TrixiParticles.wrap_v(ode.u0.x[1], system, semi)
+ u = TrixiParticles.wrap_u(ode.u0.x[2], system, semi)
+ TrixiParticles.update_final!(system, v, u, ode.u0.x[1], ode.u0.x[2], semi, 0.0)
+ @test system.angular_velocity[] == 1.0
+end
+
+@trixi_testset "Time Step Invariance under Uniform Acceleration" begin
+ # Uniform translation/acceleration does not deform a rigid body and therefore must not
+ # tighten its internal kinematic timestep estimate.
+ coordinates = [-1.0 1.0
+ 0.0 0.0]
+ velocity = [1.0 1.0
+ 0.0 0.0]
+ mass = [1.0, 1.0]
+ density = [1000.0, 1000.0]
+ initial_condition = InitialCondition(; coordinates, velocity, mass, density,
+ particle_spacing=0.1)
+
+ system_ref = RigidBodySystem(initial_condition; acceleration=(0.0, -9.81))
+ semi_ref = Semidiscretization(system_ref, neighborhood_search=nothing)
+ system_ref = semi_ref.systems[1]
+ ode_ref = semidiscretize(semi_ref, (0.0, 0.0); reset_threads=false)
+
+ system_shifted = RigidBodySystem(initial_condition; acceleration=(0.0, -1000.0))
+ semi_shifted = Semidiscretization(system_shifted, neighborhood_search=nothing)
+ system_shifted = semi_shifted.systems[1]
+ ode_shifted = semidiscretize(semi_shifted, (0.0, 0.0); reset_threads=false)
+
+ dt_ref = TrixiParticles.calculate_dt(ode_ref.u0.x[1], ode_ref.u0.x[2], 0.25,
+ system_ref, semi_ref)
+ dt_shifted = TrixiParticles.calculate_dt(ode_shifted.u0.x[1], ode_shifted.u0.x[2],
+ 0.25, system_shifted, semi_shifted)
+
+ @test isapprox(dt_ref, 0.25 * 0.1 / 1.0)
+ @test dt_shifted == dt_ref
+end
+
+@trixi_testset "Rotational Kinematics" begin
+ # Opposite particle velocities represent unit angular velocity; force reduction then
+ # converts the corresponding centripetal acceleration back to particle accelerations.
+ coordinates = [-1.0 1.0
+ 0.0 0.0]
+ velocity = [0.0 0.0
+ -1.0 1.0]
+ mass = [1.0, 1.0]
+ density = [1000.0, 1000.0]
+
+ initial_condition = InitialCondition(; coordinates, velocity, mass, density,
+ particle_spacing=1.0)
+ rigid_system = RigidBodySystem(initial_condition;
+ acceleration=(0.0, 0.0))
+
+ v = copy(velocity)
+ u = copy(coordinates)
+ TrixiParticles.update_final!(rigid_system, v, u, nothing, nothing, nothing, 0.0)
+
+ @test rigid_system.angular_velocity[] == 1.0
+ @test rigid_system.inertia[] == 2.0
+
+ dv = zeros(size(v))
+ semi = DummySemidiscretization()
+ TrixiParticles.interact!(dv, v, u, v, u, rigid_system, rigid_system, semi)
+ @test all(iszero, dv)
+
+ TrixiParticles.finalize_interaction!(rigid_system, dv, v, u,
+ nothing, nothing, nothing, semi)
+
+ @test dv == [1.0 -1.0
+ 0.0 0.0]
+end
diff --git a/test/systems/rigid_body/fluid_interaction.jl b/test/systems/rigid_body/fluid_interaction.jl
new file mode 100644
index 0000000000..1d01d9000d
--- /dev/null
+++ b/test/systems/rigid_body/fluid_interaction.jl
@@ -0,0 +1,256 @@
+@trixi_testset "Akinci Adhesion Matches Wall Boundary" begin
+ # Replacing a stationary wall particle with a rigid particle must leave the fluid-side
+ # Akinci adhesion acceleration unchanged.
+ particle_spacing = 1.0
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ smoothing_length = 1.0
+ fluid_density = 1000.0
+ rigid_density = 2000.0
+ particle_volume = particle_spacing^2
+ adhesion_coefficient = 0.25
+
+ state_equation = StateEquationCole(sound_speed=10.0,
+ reference_density=fluid_density,
+ exponent=1.0)
+
+ function run_setup(boundary_kind)
+ fluid_ic = InitialCondition(; coordinates=reshape([0.0, 0.0], 2, 1),
+ velocity=zeros(2, 1),
+ mass=[particle_volume * fluid_density],
+ density=[fluid_density], particle_spacing)
+
+ fluid_system = WeaklyCompressibleSPHSystem(fluid_ic; smoothing_kernel,
+ smoothing_length,
+ density_calculator=SummationDensity(),
+ state_equation,
+ surface_tension=SurfaceTensionAkinci(surface_tension_coefficient=0.05),
+ reference_particle_spacing=particle_spacing)
+
+ boundary_coordinates = reshape([1.5, 0.0], 2, 1)
+ boundary_model = BoundaryModelDummyParticles([fluid_density],
+ [particle_volume * fluid_density],
+ AdamiPressureExtrapolation(),
+ smoothing_kernel, smoothing_length;
+ state_equation,
+ reference_particle_spacing=particle_spacing)
+
+ boundary_system = if boundary_kind == :wall
+ wall_ic = InitialCondition(; coordinates=boundary_coordinates,
+ velocity=zeros(2, 1),
+ mass=[particle_volume * fluid_density],
+ density=[fluid_density], particle_spacing)
+ WallBoundarySystem(wall_ic, boundary_model; adhesion_coefficient)
+ else
+ rigid_ic = InitialCondition(; coordinates=boundary_coordinates,
+ velocity=zeros(2, 1),
+ mass=[particle_volume * rigid_density],
+ density=[rigid_density], particle_spacing)
+ RigidBodySystem(rigid_ic; boundary_model, adhesion_coefficient)
+ end
+
+ semi_ = Semidiscretization(fluid_system, boundary_system)
+ ode = semidiscretize(semi_, (0.0, 0.01))
+ semi = ode.p.semi
+
+ v_ode, u_ode = ode.u0.x
+ dv_ode = zero(v_ode)
+ TrixiParticles.kick!(dv_ode, v_ode, u_ode, ode.p, 0.0)
+
+ fluid = semi.systems[1]
+ boundary = semi.systems[2]
+ dv_fluid = TrixiParticles.wrap_v(dv_ode, fluid, semi)
+
+ return fluid, boundary, copy(dv_fluid[:, 1])
+ end
+
+ # The rigid resultant is the equal and opposite reaction to the fluid adhesion force.
+ _, _, dv_wall = run_setup(:wall)
+ fluid_rigid, rigid_system, dv_rigid = run_setup(:rigid)
+
+ @test isapprox(dv_rigid, dv_wall; rtol=sqrt(eps()), atol=sqrt(eps()))
+ @test isapprox(rigid_system.resultant_force[],
+ -fluid_rigid.mass[1] * dv_rigid;
+ rtol=sqrt(eps()), atol=sqrt(eps()))
+end
+
+@trixi_testset "Rigid Interaction Caches Stay Zero without Fluid Neighbors" begin
+ # An isolated rigid body exercises the full RHS cache lifecycle without contributing any
+ # interaction force or torque.
+ rigid_ic = InitialCondition(coordinates=reshape([0.0, 0.0], 2, 1),
+ velocity=zeros(2, 1),
+ mass=[1.0],
+ density=[1.0],
+ particle_spacing=1.0)
+ rigid_system = RigidBodySystem(rigid_ic; acceleration=(0.0, 0.0))
+
+ semi_ = Semidiscretization(rigid_system)
+ ode = semidiscretize(semi_, (0.0, 0.01))
+ semi = ode.p.semi
+
+ v_ode, u_ode = ode.u0.x
+ dv_ode = zero(v_ode)
+ TrixiParticles.kick!(dv_ode, v_ode, u_ode, ode.p, 0.0)
+
+ rigid = only(semi.systems)
+ dv_rigid = TrixiParticles.wrap_v(dv_ode, rigid, semi)
+
+ @test all(iszero, dv_rigid)
+ @test iszero(rigid.resultant_force[])
+ @test iszero(rigid.resultant_torque[])
+ @test iszero(rigid.angular_acceleration_force[])
+end
+
+@trixi_testset "Rigid Resultants Accumulate over Multiple Fluid Systems" begin
+ # Compare each fluid independently with the combined setup to ensure each ordered
+ # fluid-rigid pass contributes once to the shared rigid resultants.
+ particle_spacing = 1.0
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ smoothing_length = 1.0
+ fluid_density = 1000.0
+ rigid_density = 2000.0
+ particle_volume = particle_spacing^2
+
+ state_equation = StateEquationCole(sound_speed=10.0,
+ reference_density=fluid_density,
+ exponent=1.0)
+
+ boundary_model = BoundaryModelDummyParticles(fill(fluid_density, 2),
+ fill(particle_volume * fluid_density,
+ 2), AdamiPressureExtrapolation(),
+ smoothing_kernel, smoothing_length;
+ state_equation,
+ reference_particle_spacing=particle_spacing)
+
+ function run_setup(fluid_positions)
+ rigid_ic = InitialCondition(; coordinates=[-0.5 0.5
+ 0.0 0.0],
+ velocity=zeros(2, 2),
+ mass=fill(particle_volume * rigid_density, 2),
+ density=fill(rigid_density, 2), particle_spacing)
+ rigid_system = RigidBodySystem(rigid_ic; boundary_model,
+ acceleration=(0.0, 0.0))
+
+ fluid_systems = map(fluid_positions) do position
+ fluid_ic = InitialCondition(; coordinates=reshape(collect(position), 2, 1),
+ velocity=zeros(2, 1),
+ mass=[particle_volume * fluid_density],
+ density=[fluid_density], particle_spacing)
+
+ WeaklyCompressibleSPHSystem(fluid_ic; smoothing_kernel,
+ smoothing_length,
+ density_calculator=SummationDensity(),
+ state_equation)
+ end
+
+ semi_ = Semidiscretization(fluid_systems..., rigid_system)
+ ode = semidiscretize(semi_, (0.0, 0.01))
+ semi = ode.p.semi
+
+ v_ode, u_ode = ode.u0.x
+ dv_ode = zero(v_ode)
+ TrixiParticles.kick!(dv_ode, v_ode, u_ode, ode.p, 0.0)
+
+ rigid = last(semi.systems)
+ dv_rigid = TrixiParticles.wrap_v(dv_ode, rigid, semi)
+
+ return rigid, copy(dv_rigid)
+ end
+
+ fluid_positions = ((1.5, 0.0), (-1.5, 1.0))
+
+ # Particle accelerations, force, torque, and rotational acceleration are all linear sums
+ # of the two independent fluid interactions.
+ rigid_1, dv_1 = run_setup((fluid_positions[1],))
+ rigid_2, dv_2 = run_setup((fluid_positions[2],))
+ rigid_both, dv_both = run_setup(fluid_positions)
+
+ @test isapprox(dv_both, dv_1 .+ dv_2; rtol=sqrt(eps()), atol=sqrt(eps()))
+ @test isapprox(rigid_both.resultant_force[],
+ rigid_1.resultant_force[] + rigid_2.resultant_force[];
+ rtol=sqrt(eps()), atol=sqrt(eps()))
+ @test isapprox(rigid_both.resultant_torque[],
+ rigid_1.resultant_torque[] + rigid_2.resultant_torque[];
+ rtol=sqrt(eps()), atol=sqrt(eps()))
+ @test isapprox(rigid_both.angular_acceleration_force[],
+ rigid_1.angular_acceleration_force[] +
+ rigid_2.angular_acceleration_force[];
+ rtol=sqrt(eps()), atol=sqrt(eps()))
+end
+
+@trixi_testset "Rigid Bodies Ignore Open Boundary Interactions" begin
+ # Open-boundary buffer particles are not physical walls and must neither enter rigid
+ # neighbor searches nor exchange forces with rigid bodies.
+ particle_spacing = 1.0
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ smoothing_length = 1.0
+ fluid_density = 1000.0
+ rigid_density = 2000.0
+ particle_volume = particle_spacing^2
+
+ state_equation = StateEquationCole(sound_speed=10.0,
+ reference_density=fluid_density,
+ exponent=1.0)
+
+ boundary_model = BoundaryModelDummyParticles([fluid_density],
+ [particle_volume * fluid_density],
+ AdamiPressureExtrapolation(),
+ smoothing_kernel, smoothing_length;
+ state_equation,
+ reference_particle_spacing=particle_spacing)
+
+ rigid_ic = InitialCondition(; coordinates=reshape([0.0, 0.0], 2, 1),
+ velocity=zeros(2, 1),
+ mass=[particle_volume * rigid_density],
+ density=[rigid_density], particle_spacing)
+ rigid_system = RigidBodySystem(rigid_ic; boundary_model, acceleration=(0.0, 0.0))
+
+ open_boundary_ic = InitialCondition(; coordinates=reshape([1.5, 0.0], 2, 1),
+ velocity=zeros(2, 1),
+ mass=[particle_volume * fluid_density],
+ density=[fluid_density], particle_spacing)
+
+ fluid_support_ic = InitialCondition(; coordinates=reshape([10.0, 10.0], 2, 1),
+ velocity=zeros(2, 1),
+ mass=[particle_volume * fluid_density],
+ density=[fluid_density], particle_spacing)
+ fluid_system = WeaklyCompressibleSPHSystem(fluid_support_ic; smoothing_kernel,
+ smoothing_length,
+ density_calculator=SummationDensity(),
+ state_equation)
+
+ boundary_face = ([2.0, -0.5], [2.0, 0.5])
+ zone = BoundaryZone(; boundary_face, face_normal=(1.0, 0.0), density=fluid_density,
+ particle_spacing, initial_condition=open_boundary_ic,
+ open_boundary_layers=1, boundary_type=InFlow())
+
+ open_boundary_system = OpenBoundarySystem(zone; fluid_system,
+ boundary_model=BoundaryModelDynamicalPressureZhang(),
+ buffer_size=0)
+
+ semi_ = Semidiscretization(fluid_system, rigid_system, open_boundary_system)
+ ode = semidiscretize(semi_, (0.0, 0.01))
+ semi = ode.p.semi
+
+ rigid = semi.systems[2]
+ open_boundary = semi.systems[3]
+
+ # Zero support prevents automatic neighbor pairs in both ordered interaction directions.
+ @test iszero(TrixiParticles.compact_support(rigid, open_boundary))
+ @test iszero(TrixiParticles.compact_support(open_boundary, rigid))
+
+ v_ode, u_ode = ode.u0.x
+ dv_ode = zero(v_ode)
+
+ # Direct calls are also no-ops, guarding against accidental dispatch independent of the
+ # neighborhood-search exclusion.
+ TrixiParticles.interact!(dv_ode, v_ode, u_ode, rigid, open_boundary, semi)
+ TrixiParticles.interact!(dv_ode, v_ode, u_ode, open_boundary, rigid, semi)
+
+ dv_rigid = TrixiParticles.wrap_v(dv_ode, rigid, semi)
+ dv_open_boundary = TrixiParticles.wrap_v(dv_ode, open_boundary, semi)
+
+ @test all(iszero, dv_rigid[:, 1])
+ @test all(iszero, dv_open_boundary[:, 1])
+ @test iszero(rigid.resultant_force[])
+ @test iszero(rigid.resultant_torque[])
+end
diff --git a/test/systems/rigid_body/normal_contact.jl b/test/systems/rigid_body/normal_contact.jl
new file mode 100644
index 0000000000..391086c7dd
--- /dev/null
+++ b/test/systems/rigid_body/normal_contact.jl
@@ -0,0 +1,199 @@
+@trixi_testset "Rigid-Rigid Normal Contact" begin
+ # Each ordered interaction updates only its local body. Running both orders must produce
+ # the complete action-reaction pair from one symmetric set of contact parameters.
+ rigid_coordinates_1 = reshape([0.0, 0.0], 2, 1)
+ rigid_coordinates_2 = reshape([0.08, 0.0], 2, 1)
+ rigid_velocity_1 = reshape([1.0, 0.0], 2, 1)
+ rigid_velocity_2 = reshape([-0.5, 0.0], 2, 1)
+ rigid_mass_1 = [2.0]
+ rigid_mass_2 = [1.0]
+ rigid_density_pair = [1000.0]
+
+ rigid_ic_1 = InitialCondition(; coordinates=rigid_coordinates_1,
+ velocity=rigid_velocity_1,
+ mass=rigid_mass_1,
+ density=rigid_density_pair,
+ particle_spacing=0.1)
+ rigid_ic_2 = InitialCondition(; coordinates=rigid_coordinates_2,
+ velocity=rigid_velocity_2,
+ mass=rigid_mass_2,
+ density=rigid_density_pair,
+ particle_spacing=0.1)
+
+ contact_model_1 = RigidContactModel(; normal_stiffness=20.0,
+ normal_damping=4.0,
+ contact_distance=0.1)
+ contact_model_2 = RigidContactModel(; normal_stiffness=30.0,
+ normal_damping=8.0,
+ contact_distance=0.12)
+
+ rigid_system_1 = RigidBodySystem(rigid_ic_1;
+ acceleration=(0.0, 0.0),
+ contact_model=contact_model_1)
+ rigid_system_2 = RigidBodySystem(rigid_ic_2;
+ acceleration=(0.0, 0.0),
+ contact_model=contact_model_2)
+ rigid_system_without_contact = RigidBodySystem(rigid_ic_1;
+ acceleration=(0.0, 0.0))
+
+ semi_rigid = Semidiscretization(rigid_system_1, rigid_system_2)
+ ode_rigid = semidiscretize(semi_rigid, (0.0, 0.01))
+ v_ode_rigid, u_ode_rigid = ode_rigid.u0.x
+ dv_ode_rigid = zero(v_ode_rigid)
+
+ v_rigid_1 = TrixiParticles.wrap_v(v_ode_rigid, rigid_system_1, semi_rigid)
+ u_rigid_1 = TrixiParticles.wrap_u(u_ode_rigid, rigid_system_1, semi_rigid)
+ v_rigid_2 = TrixiParticles.wrap_v(v_ode_rigid, rigid_system_2, semi_rigid)
+ u_rigid_2 = TrixiParticles.wrap_u(u_ode_rigid, rigid_system_2, semi_rigid)
+ TrixiParticles.update_final!(rigid_system_1, v_rigid_1, u_rigid_1,
+ v_ode_rigid, u_ode_rigid, semi_rigid, 0.0)
+ TrixiParticles.update_final!(rigid_system_2, v_rigid_2, u_rigid_2,
+ v_ode_rigid, u_ode_rigid, semi_rigid, 0.0)
+
+ # The forward ordered pass updates only its first (local) system; the reverse pass then
+ # supplies the reaction on the other body without changing the first force again.
+ TrixiParticles.reset_interaction_caches!(semi_rigid)
+ TrixiParticles.interact!(dv_ode_rigid, v_ode_rigid, u_ode_rigid,
+ rigid_system_1, rigid_system_2, semi_rigid)
+ force_after_forward_1 = copy(rigid_system_1.force_per_particle)
+ force_after_forward_2 = copy(rigid_system_2.force_per_particle)
+ @test !all(iszero, force_after_forward_1)
+ @test all(iszero, force_after_forward_2)
+
+ TrixiParticles.interact!(dv_ode_rigid, v_ode_rigid, u_ode_rigid,
+ rigid_system_2, rigid_system_1, semi_rigid)
+ @test rigid_system_1.force_per_particle == force_after_forward_1
+ @test !all(iszero, rigid_system_2.force_per_particle)
+
+ # Both orders use maximum support, averaged normal coefficients, and reduced pair mass.
+ # These choices define one force magnitude and one contact stability timescale.
+ pair_contact_distance = max(contact_model_1.contact_distance,
+ contact_model_2.contact_distance)
+ pair_normal_stiffness = (contact_model_1.normal_stiffness +
+ contact_model_2.normal_stiffness) / 2
+ pair_normal_damping = (contact_model_1.normal_damping +
+ contact_model_2.normal_damping) / 2
+ pair_penetration = pair_contact_distance - 0.08
+ normal_velocity = -1.5
+ reduced_mass = rigid_mass_1[1] * rigid_mass_2[1] /
+ (rigid_mass_1[1] + rigid_mass_2[1])
+ pair_contact_dt = min(sqrt(reduced_mass / pair_normal_stiffness),
+ reduced_mass / pair_normal_damping)
+ expected_force_magnitude = pair_normal_stiffness * pair_penetration -
+ pair_normal_damping * normal_velocity
+ expected_force = SVector(-expected_force_magnitude, 0.0)
+
+ @test vec(force_after_forward_1[:, 1]) ≈ collect(expected_force)
+ @test vec(rigid_system_2.force_per_particle[:, 1]) ≈ collect(-expected_force)
+ @test rigid_system_1.cache.contact_count[] == 1
+ @test rigid_system_2.cache.contact_count[] == 1
+ @test rigid_system_1.cache.max_contact_penetration[] ≈ pair_penetration
+ @test rigid_system_2.cache.max_contact_penetration[] ≈ pair_penetration
+
+ # A missing model disables pair search and pair timestep restrictions in either order.
+ @test TrixiParticles.compact_support(rigid_system_1, rigid_system_2) ≈
+ pair_contact_distance
+ @test iszero(TrixiParticles.compact_support(rigid_system_without_contact,
+ rigid_system_2))
+ @test iszero(TrixiParticles.compact_support(rigid_system_2,
+ rigid_system_without_contact))
+ @test TrixiParticles.contact_time_step(rigid_system_1, rigid_system_2) ≈
+ pair_contact_dt
+ @test TrixiParticles.contact_time_step(rigid_system_without_contact,
+ rigid_system_2) == Inf
+ @test TrixiParticles.contact_time_step(rigid_system_2,
+ rigid_system_without_contact) == Inf
+ @test TrixiParticles.contact_time_step(rigid_system_1) ≈
+ min(sqrt(rigid_mass_1[1] / contact_model_1.normal_stiffness),
+ rigid_mass_1[1] / contact_model_1.normal_damping)
+ @test TrixiParticles.contact_time_step(rigid_system_2) ≈
+ min(sqrt(rigid_mass_2[1] / contact_model_2.normal_stiffness),
+ rigid_mass_2[1] / contact_model_2.normal_damping)
+ # A lone rigid body has no pair restriction; once paired, the semidiscretization applies
+ # the CFL factor exactly once to the reduced-mass contact timescale.
+ semi_single_rigid = Semidiscretization(rigid_system_1)
+ ode_single_rigid = semidiscretize(semi_single_rigid, (0.0, 0.01))
+ zero_velocity_single = zero(ode_single_rigid.u0.x[1])
+ @test TrixiParticles.calculate_dt(zero_velocity_single, ode_single_rigid.u0.x[2],
+ 0.25, rigid_system_1, semi_single_rigid) == Inf
+ zero_velocity_ode = zero(v_ode_rigid)
+ @test TrixiParticles.calculate_dt(zero_velocity_ode, u_ode_rigid, 0.25,
+ rigid_system_1, semi_rigid) ≈
+ 0.25 * pair_contact_dt
+ @test TrixiParticles.calculate_dt(zero_velocity_ode, u_ode_rigid, 0.25,
+ semi_rigid) ≈ 0.25 * pair_contact_dt
+
+ # The high-level interaction path must rebuild the same forces and diagnostics after its
+ # internal cache reset.
+ dv_ode_reset = zero(v_ode_rigid)
+ TrixiParticles.system_interaction!(dv_ode_reset, v_ode_rigid, u_ode_rigid,
+ semi_rigid)
+ @test rigid_system_1.cache.contact_count[] == 1
+ @test rigid_system_2.cache.contact_count[] == 1
+ @test rigid_system_1.cache.max_contact_penetration[] ≈ pair_penetration
+ @test rigid_system_2.cache.max_contact_penetration[] ≈ pair_penetration
+ @test vec(rigid_system_1.force_per_particle[:, 1]) ≈ collect(expected_force)
+ @test vec(rigid_system_2.force_per_particle[:, 1]) ≈ collect(-expected_force)
+
+ # Refreshing system state and neighborhood searches must not erase completed diagnostics.
+ TrixiParticles.update_systems_and_nhs(v_ode_rigid, u_ode_rigid, semi_rigid, 0.0)
+ @test rigid_system_1.cache.contact_count[] == 1
+ @test rigid_system_2.cache.contact_count[] == 1
+ @test rigid_system_1.cache.max_contact_penetration[] ≈ pair_penetration
+ @test rigid_system_2.cache.max_contact_penetration[] ≈ pair_penetration
+ @test vec(rigid_system_1.force_per_particle[:, 1]) ≈ collect(expected_force)
+ @test vec(rigid_system_2.force_per_particle[:, 1]) ≈ collect(-expected_force)
+
+ # A second RHS interaction starts cleanly and reproduces the first evaluation.
+ TrixiParticles.set_zero!(dv_ode_reset)
+ TrixiParticles.system_interaction!(dv_ode_reset, v_ode_rigid, u_ode_rigid,
+ semi_rigid)
+ @test rigid_system_1.cache.contact_count[] == 1
+ @test rigid_system_2.cache.contact_count[] == 1
+ @test rigid_system_1.cache.max_contact_penetration[] ≈ pair_penetration
+ @test rigid_system_2.cache.max_contact_penetration[] ≈ pair_penetration
+ @test vec(rigid_system_1.force_per_particle[:, 1]) ≈ collect(expected_force)
+ @test vec(rigid_system_2.force_per_particle[:, 1]) ≈ collect(-expected_force)
+
+ # Final reduction preserves action-reaction forces and converts each force to acceleration
+ # with its body's own mass.
+ dv_rigid_1 = TrixiParticles.wrap_v(dv_ode_rigid, rigid_system_1, semi_rigid)
+ dv_rigid_2 = TrixiParticles.wrap_v(dv_ode_rigid, rigid_system_2, semi_rigid)
+ TrixiParticles.finalize_interaction!(rigid_system_1, dv_rigid_1, v_rigid_1,
+ u_rigid_1, dv_ode_rigid, v_ode_rigid,
+ u_ode_rigid, semi_rigid)
+ TrixiParticles.finalize_interaction!(rigid_system_2, dv_rigid_2, v_rigid_2,
+ u_rigid_2, dv_ode_rigid, v_ode_rigid,
+ u_ode_rigid, semi_rigid)
+
+ @test rigid_system_1.resultant_force[] ≈ expected_force
+ @test rigid_system_2.resultant_force[] ≈ -expected_force
+ @test dv_rigid_1[1, 1] ≈ expected_force[1] / rigid_mass_1[1]
+ @test dv_rigid_2[1, 1] ≈ -expected_force[1] / rigid_mass_2[1]
+ @test dv_rigid_1[2, 1] ≈ 0.0
+ @test dv_rigid_2[2, 1] ≈ 0.0
+
+ # Contact diagnostics written to VTK must match the active runtime cache, not zeros or
+ # stale values from an earlier RHS evaluation.
+ mktempdir() do tmp_dir
+ du_ode_rigid = zero(u_ode_rigid)
+ dvdu_ode_rigid = (; x=(dv_ode_rigid, du_ode_rigid))
+ vu_ode_rigid = (; x=(v_ode_rigid, u_ode_rigid))
+ trixi2vtk(dvdu_ode_rigid, vu_ode_rigid, semi_rigid, 0.0;
+ output_directory=tmp_dir, iter=1)
+
+ contact_filename = TrixiParticles.system_names(semi_rigid.systems)[1]
+ vtk_contact = TrixiParticles.ReadVTK.VTKFile(joinpath(tmp_dir,
+ "$(contact_filename)_1.vtu"))
+ point_data_contact = TrixiParticles.ReadVTK.get_point_data(vtk_contact)
+
+ @test only(Array(TrixiParticles.ReadVTK.get_data(point_data_contact["contact_count"]))) ==
+ rigid_system_1.cache.contact_count[]
+ @test only(Array(TrixiParticles.ReadVTK.get_data(point_data_contact["contact_count"]))) >
+ 0
+ @test only(Array(TrixiParticles.ReadVTK.get_data(point_data_contact["max_contact_penetration"]))) ≈
+ rigid_system_1.cache.max_contact_penetration[]
+ @test only(Array(TrixiParticles.ReadVTK.get_data(point_data_contact["max_contact_penetration"]))) >
+ 0
+ end
+end
diff --git a/test/systems/rigid_body/state_io.jl b/test/systems/rigid_body/state_io.jl
new file mode 100644
index 0000000000..6e3571c43b
--- /dev/null
+++ b/test/systems/rigid_body/state_io.jl
@@ -0,0 +1,177 @@
+@trixi_testset "IO Data" begin
+ # Exported system data combines state-derived rigid kinematics with zero-valued force and
+ # contact diagnostics before any interactions have run.
+ coordinates = [-1.0 1.0
+ 0.0 0.0]
+ velocity = [0.0 0.0
+ -1.0 1.0]
+ mass = [1.0, 1.0]
+ density = [1000.0, 1000.0]
+
+ initial_condition = InitialCondition(; coordinates, velocity, mass, density,
+ particle_spacing=1.0)
+ rigid_system = RigidBodySystem(initial_condition; acceleration=(0.0, 0.0))
+
+ semi = Semidiscretization(rigid_system)
+ ode = semidiscretize(semi, (0.0, 0.01))
+ v_ode, u_ode = ode.u0.x
+ dv_ode = zeros(eltype(v_ode), size(v_ode))
+ du_ode = zeros(eltype(u_ode), size(u_ode))
+
+ v = TrixiParticles.wrap_v(v_ode, rigid_system, semi)
+ u = TrixiParticles.wrap_u(u_ode, rigid_system, semi)
+ TrixiParticles.update_final!(rigid_system, v, u, v_ode, u_ode, semi, 0.0)
+
+ data = TrixiParticles.system_data(rigid_system, dv_ode, du_ode,
+ v_ode, u_ode, semi)
+ fields = TrixiParticles.available_data(rigid_system)
+
+ @test data.center_of_mass == [0.0, 0.0]
+ @test data.center_of_mass_velocity == [0.0, 0.0]
+ @test data.angular_velocity == 1.0
+ @test data.resultant_force == [0.0, 0.0]
+ @test data.resultant_torque == 0.0
+ @test data.angular_acceleration_force == 0.0
+ @test data.gyroscopic_acceleration == 0.0
+ @test data.contact_count == 0
+ @test data.max_contact_penetration == 0.0
+ @test data.relative_coordinates == rigid_system.relative_coordinates
+ @test :contact_count in fields
+ @test :max_contact_penetration in fields
+ @test !(:local_coordinates in fields)
+end
+
+@trixi_testset "Restart" begin
+ # Restart replaces the ODE initial arrays immediately, while derived kinematics and force
+ # caches remain unchanged until the normal update lifecycle runs.
+ coordinates = [0.0 1.0 2.0
+ 0.0 0.0 0.0]
+ velocity = [0.0 0.0 0.0
+ 0.0 0.0 0.0]
+ mass = [1.0, 1.0, 1.0]
+ density = [1000.0, 1000.0, 1000.0]
+
+ initial_condition = InitialCondition(; coordinates, velocity, mass, density,
+ particle_spacing=1.0)
+ rigid_system = RigidBodySystem(initial_condition; acceleration=(0.0, 0.0))
+
+ u_new = [2.0 4.0 6.0
+ 3.0 3.0 3.0]
+ v_new = [1.0 2.0 3.0
+ 4.0 5.0 6.0]
+
+ TrixiParticles.update_final!(rigid_system, rigid_system.initial_velocity,
+ rigid_system.initial_condition.coordinates,
+ nothing, nothing, nothing, 0.0)
+ stale_relative_coordinates = copy(rigid_system.relative_coordinates)
+ stale_center_of_mass = rigid_system.center_of_mass[]
+ stale_center_of_mass_velocity = rigid_system.center_of_mass_velocity[]
+ stale_angular_velocity = rigid_system.angular_velocity[]
+ stale_force = SVector(7.0, -11.0)
+ stale_torque = 5.0
+ stale_angular_acceleration_force = 2.0
+ rigid_system.resultant_force[] = stale_force
+ rigid_system.resultant_torque[] = stale_torque
+ rigid_system.angular_acceleration_force[] = stale_angular_acceleration_force
+
+ restarted_system = TrixiParticles.restart_with!(rigid_system, v_new, u_new)
+
+ @test restarted_system === rigid_system
+ @test rigid_system.initial_condition.coordinates == u_new
+ @test rigid_system.initial_condition.velocity == v_new
+ @test rigid_system.initial_velocity == v_new
+ @test rigid_system.relative_coordinates == stale_relative_coordinates
+ @test rigid_system.center_of_mass[] == stale_center_of_mass
+ @test rigid_system.center_of_mass_velocity[] == stale_center_of_mass_velocity
+ @test rigid_system.angular_velocity[] == stale_angular_velocity
+ @test rigid_system.resultant_force[] == stale_force
+ @test rigid_system.resultant_torque[] == stale_torque
+ @test rigid_system.angular_acceleration_force[] == stale_angular_acceleration_force
+
+ # Timestep estimation must use the restarted state even before cache refresh; afterwards,
+ # the refreshed center-of-mass and rotation values must describe that same state.
+ expected_center_of_mass = [4.0, 3.0]
+ expected_relative_coordinates = u_new .- expected_center_of_mass
+ semi = Semidiscretization(rigid_system, neighborhood_search=nothing)
+ dt_restarted = TrixiParticles.calculate_dt(v_new, u_new, 0.25, rigid_system, semi)
+
+ TrixiParticles.update_final!(rigid_system, v_new, u_new, nothing, nothing, semi,
+ 0.0)
+ dt_updated = TrixiParticles.calculate_dt(v_new, u_new, 0.25, rigid_system, semi)
+
+ @test rigid_system.center_of_mass[] == expected_center_of_mass
+ @test rigid_system.relative_coordinates == expected_relative_coordinates
+ @test rigid_system.center_of_mass_velocity[] == [2.0, 5.0]
+ @test rigid_system.angular_velocity[] == 0.5
+ @test isapprox(dt_restarted, dt_updated)
+end
+
+@trixi_testset "Velocity Components with ContinuityDensity" begin
+ # Hydrodynamic density adds an ODE component, but exported velocity and acceleration must
+ # still contain exactly the physical spatial dimensions.
+ coordinates = [0.0 0.1
+ 0.0 0.0]
+ mass = [1.0, 1.0]
+ density = [1000.0, 1000.0]
+ initial_condition = InitialCondition(; coordinates, mass, density)
+
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ smoothing_length = 0.12
+ boundary_model = BoundaryModelDummyParticles(density, mass,
+ ContinuityDensity(),
+ smoothing_kernel,
+ smoothing_length)
+
+ rigid_system = RigidBodySystem(initial_condition; boundary_model)
+ semi = Semidiscretization(rigid_system)
+ ode = semidiscretize(semi, (0.0, 0.01))
+ v_ode, u_ode = ode.u0.x
+ dv_ode = zeros(eltype(v_ode), size(v_ode))
+ du_ode = zeros(eltype(u_ode), size(u_ode))
+
+ data = TrixiParticles.system_data(rigid_system, dv_ode, du_ode,
+ v_ode, u_ode, semi)
+
+ @test size(data.velocity, 1) == ndims(rigid_system)
+ @test size(data.acceleration, 1) == ndims(rigid_system)
+end
+
+@trixi_testset "Configuration" begin
+ # Rigid bodies are boundaries, not fluids: reject unsupported fluid-rigid and surface
+ # tension combinations during semidiscretization instead of failing in the RHS.
+ coordinates = [1.0 2.0
+ 1.0 2.0]
+ mass = [1.0, 1.0]
+ density = [1000.0, 1000.0]
+
+ rigid_ic = InitialCondition(; coordinates, mass, density)
+ rigid_system = RigidBodySystem(rigid_ic)
+
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ smoothing_length = 0.12
+ state_equation = StateEquationCole(; sound_speed=10.0, reference_density=1000.0,
+ exponent=7.0)
+ fluid_system = WeaklyCompressibleSPHSystem(rigid_ic; smoothing_kernel,
+ smoothing_length,
+ density_calculator=SummationDensity(),
+ state_equation)
+
+ @test_throws ArgumentError Semidiscretization(fluid_system, rigid_system)
+
+ rigid_boundary_model = BoundaryModelDummyParticles(density, mass,
+ SummationDensity(),
+ smoothing_kernel,
+ smoothing_length)
+ rigid_system_with_dummy = RigidBodySystem(rigid_ic;
+ boundary_model=rigid_boundary_model)
+ fluid_with_surface_tension = WeaklyCompressibleSPHSystem(rigid_ic;
+ smoothing_kernel,
+ smoothing_length,
+ density_calculator=SummationDensity(),
+ state_equation,
+ surface_tension=SurfaceTensionMorris(surface_tension_coefficient=0.072),
+ reference_particle_spacing=0.1)
+
+ @test_throws ArgumentError Semidiscretization(fluid_with_surface_tension,
+ rigid_system_with_dummy)
+end
diff --git a/test/systems/rigid_system.jl b/test/systems/rigid_system.jl
index d549363d9d..5c53e9f776 100644
--- a/test/systems/rigid_system.jl
+++ b/test/systems/rigid_system.jl
@@ -1,1089 +1,8 @@
@testset verbose=true "RigidBodySystem" begin
- @trixi_testset "Constructor" begin
- coordinates = [1.0 2.0 3.0
- 1.0 2.0 3.0]
- mass = [1.25, 1.5, 1.75]
- material_densities = [990.0, 995.0, 1000.0]
-
- initial_condition = InitialCondition(; coordinates, mass,
- density=material_densities)
-
- smoothing_kernel = SchoenbergCubicSplineKernel{2}()
- smoothing_length = 0.12
- boundary_model = BoundaryModelDummyParticles(material_densities, mass,
- SummationDensity(),
- smoothing_kernel,
- smoothing_length)
-
- system = RigidBodySystem(initial_condition; boundary_model,
- acceleration=(0.0, -9.81), particle_spacing=0.1)
-
- @test ndims(system) == 2
- @test system.initial_condition == initial_condition
- @test all(iszero, system.relative_coordinates)
- @test system.mass == mass
- @test system.material_density == material_densities
- @test system.initial_velocity == initial_condition.velocity
- @test system.acceleration == [0.0, -9.81]
- @test iszero(system.center_of_mass[])
- @test iszero(system.center_of_mass_velocity[])
- @test iszero(system.angular_velocity[])
- @test system.particle_spacing == 0.1
- @test system.boundary_model == boundary_model
- @test system.adhesion_coefficient == 0.0
- @test TrixiParticles.v_nvariables(system) == 2
-
- semi = Semidiscretization(system, neighborhood_search=nothing)
- system = semi.systems[1]
- ode = semidiscretize(semi, (0.0, 0.0); reset_threads=false)
-
- dt = TrixiParticles.calculate_dt(ode.u0.x[1], ode.u0.x[2], 0.25, system, semi)
- @test isinf(dt)
- end
-
- @trixi_testset "Show" begin
- coordinates = [1.0 2.0
- 1.0 2.0]
- mass = [1.25, 1.5]
- material_densities = [990.0, 1000.0]
-
- initial_condition = InitialCondition(; coordinates, mass,
- density=material_densities)
-
- smoothing_kernel = SchoenbergCubicSplineKernel{2}()
- smoothing_length = 0.12
- boundary_model = BoundaryModelDummyParticles(material_densities, mass,
- SummationDensity(),
- smoothing_kernel,
- smoothing_length)
-
- system = RigidBodySystem(initial_condition; boundary_model,
- acceleration=(0.0, -9.81))
- @test !haskey(system.cache, :contact_manifold_count)
-
- show_compact = "RigidBodySystem{2}([0.0, -9.81], BoundaryModelDummyParticles(SummationDensity, Nothing)) with 2 particles"
- @test repr(system) == show_compact
-
- show_box = """
- ┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
- │ RigidBodySystem{2} │
- │ ══════════════════ │
- │ #particles: ………………………………………………… 2 │
- │ acceleration: …………………………………………… [0.0, -9.81] │
- │ boundary model: ……………………………………… BoundaryModelDummyParticles(SummationDensity, Nothing) │
- └──────────────────────────────────────────────────────────────────────────────────────────────────┘"""
- @test repr("text/plain", system) == show_box
- end
-
- @trixi_testset "Hydrodynamic Density" begin
- coordinates = [1.0 2.0
- 1.0 2.0]
- mass = [1.25, 1.5]
- material_densities = [990.0, 1000.0]
- hydrodynamic_densities = [1001.0, 1002.0]
- hydrodynamic_masses = [2.5, 3.0]
-
- initial_condition = InitialCondition(; coordinates, mass,
- density=material_densities)
-
- smoothing_kernel = SchoenbergCubicSplineKernel{2}()
- smoothing_length = 0.12
- boundary_model = BoundaryModelDummyParticles(hydrodynamic_densities,
- hydrodynamic_masses,
- SummationDensity(),
- smoothing_kernel,
- smoothing_length)
-
- system = RigidBodySystem(initial_condition; boundary_model)
- v = zeros(TrixiParticles.v_nvariables(system),
- TrixiParticles.n_integrated_particles(system))
-
- @test TrixiParticles.current_density(v, system) == hydrodynamic_densities
- @test TrixiParticles.hydrodynamic_mass(system, 1) == hydrodynamic_masses[1]
- @test TrixiParticles.smoothing_length(system, 1) == smoothing_length
- @test system.material_density == material_densities
-
- monaghan_model = BoundaryModelMonaghanKajtar(10.0, 1.0, smoothing_length,
- hydrodynamic_masses)
- system_monaghan = RigidBodySystem(initial_condition; boundary_model=monaghan_model)
- @test TrixiParticles.hydrodynamic_mass(system_monaghan, 1) == hydrodynamic_masses[1]
- end
-
- @trixi_testset "Source Terms without Boundary Model" begin
- coordinates = [1.0 2.0
- 1.0 2.0]
- mass = [1.25, 1.5]
- material_densities = [990.0, 1000.0]
- initial_condition = InitialCondition(; coordinates, mass,
- density=material_densities)
-
- source_terms = (coords, velocity, density, pressure,
- t) -> SVector(density, pressure)
- system = RigidBodySystem(initial_condition; source_terms)
- semi = Semidiscretization(system, neighborhood_search=nothing)
- system = semi.systems[1]
- ode = semidiscretize(semi, (0.0, 0.0); reset_threads=false)
-
- v_ode = ode.u0.x[1]
- u_ode = ode.u0.x[2]
- dv_ode = similar(v_ode)
- fill!(dv_ode, 0.0)
-
- TrixiParticles.add_source_terms!(dv_ode, v_ode, u_ode, semi, 0.0)
-
- dv = TrixiParticles.wrap_v(dv_ode, system, semi)
- @test dv[1, :] == material_densities
- @test dv[2, :] == zeros(2)
- end
-
- @trixi_testset "Initial Angular Velocity" begin
- coordinates_2d = [0.0 1.0
- 0.0 0.0]
- mass_2d = [1.0, 1.0]
- density_2d = [1000.0, 1000.0]
- ic_2d = apply_angular_velocity(InitialCondition(; coordinates=coordinates_2d,
- mass=mass_2d,
- density=density_2d),
- 2.0)
-
- system_2d = RigidBodySystem(ic_2d; particle_spacing=0.1)
- u0_2d = zeros(2, 2)
- v0_2d = zeros(2, 2)
- TrixiParticles.write_u0!(u0_2d, system_2d)
- TrixiParticles.write_v0!(v0_2d, system_2d)
-
- @test iszero(system_2d.angular_velocity[])
- @test v0_2d == [0.0 0.0
- -1.0 1.0]
- semi_2d = Semidiscretization(system_2d, neighborhood_search=nothing)
- system_2d = semi_2d.systems[1]
- ode_2d = semidiscretize(semi_2d, (0.0, 0.0); reset_threads=false)
- dt_2d = TrixiParticles.calculate_dt(v0_2d, u0_2d, 0.25, system_2d, semi_2d)
- @test isapprox(dt_2d, 0.25 * 0.1 / 1.0)
- dt_2d_larger_cfl = TrixiParticles.calculate_dt(v0_2d, u0_2d, 0.5,
- system_2d, semi_2d)
- @test isapprox(dt_2d_larger_cfl, 0.5 * 0.1 / 1.0)
- dt_2d_semi = TrixiParticles.calculate_dt(ode_2d.u0.x[1], ode_2d.u0.x[2], 0.25,
- ode_2d.p.semi)
- @test isapprox(dt_2d_semi, dt_2d)
-
- TrixiParticles.update_final!(system_2d, v0_2d, u0_2d, nothing, nothing, nothing,
- 0.0)
- @test system_2d.angular_velocity[] == 2.0
-
- coordinates_3d = [0.0 1.0
- 0.0 0.0
- 0.0 0.0]
- mass_3d = [1.0, 1.0]
- density_3d = [1000.0, 1000.0]
- ic_3d = apply_angular_velocity(InitialCondition(; coordinates=coordinates_3d,
- mass=mass_3d,
- density=density_3d),
- (0.0, 0.0, 2.0))
-
- system_3d = RigidBodySystem(ic_3d)
- u0_3d = zeros(3, 2)
- v0_3d = zeros(3, 2)
- TrixiParticles.write_u0!(u0_3d, system_3d)
- TrixiParticles.write_v0!(v0_3d, system_3d)
-
- @test iszero(system_3d.angular_velocity[])
- @test v0_3d == [0.0 0.0
- -1.0 1.0
- 0.0 0.0]
- TrixiParticles.update_final!(system_3d, v0_3d, u0_3d, nothing, nothing, nothing,
- 0.0)
- @test system_3d.angular_velocity[] == [0.0, 0.0, 2.0]
- end
-
- @trixi_testset "Time Step Estimate 3D Gyroscopic" begin
- coordinates = [1.0 -1.0 0.0 0.0 0.0 0.0
- 0.0 0.0 2.0 -2.0 0.0 0.0
- 0.0 0.0 0.0 0.0 3.0 -3.0]
- mass = fill(1.0, 6)
- density = fill(1000.0, 6)
- initial_condition = apply_angular_velocity(InitialCondition(; coordinates, mass,
- density,
- particle_spacing=10.0),
- (1.0, 2.0, 3.0))
- system = RigidBodySystem(initial_condition; acceleration=(0.0, 0.0, 0.0))
- semi = Semidiscretization(system, neighborhood_search=nothing)
- system = semi.systems[1]
- ode = semidiscretize(semi, (0.0, 0.0); reset_threads=false)
-
- angular_velocity = SVector(1.0, 2.0, 3.0)
- gyroscopic_acceleration = SVector(-30 / 13, 12 / 5, -6 / 5)
- acceleration_scale = 3.0 * (norm(angular_velocity)^2 +
- norm(gyroscopic_acceleration))
- dt_acceleration = 0.25 * sqrt(10.0 / acceleration_scale)
- dt_velocity = 0.25 * 10.0 / (3.0 * norm(angular_velocity))
-
- dt = TrixiParticles.calculate_dt(ode.u0.x[1], ode.u0.x[2], 0.25, system, semi)
- @test isapprox(dt, min(dt_acceleration, dt_velocity))
- end
-
- @trixi_testset "Time Step Estimate from Initial Velocity" begin
- coordinates = [-1.0 1.0
- 0.0 0.0]
- velocity = [0.0 0.0
- -1.0 1.0]
- mass = [1.0, 1.0]
- density = [1000.0, 1000.0]
- initial_condition = InitialCondition(; coordinates, velocity, mass, density,
- particle_spacing=0.1)
- system = RigidBodySystem(initial_condition; acceleration=(0.0, 0.0))
- semi = Semidiscretization(system, neighborhood_search=nothing)
- system = semi.systems[1]
- ode = semidiscretize(semi, (0.0, 0.0); reset_threads=false)
-
- @test iszero(system.angular_velocity[])
-
- dt = TrixiParticles.calculate_dt(ode.u0.x[1], ode.u0.x[2], 0.25, system, semi)
- @test isapprox(dt, 0.25 * 0.1 / 1.0)
-
- v = TrixiParticles.wrap_v(ode.u0.x[1], system, semi)
- u = TrixiParticles.wrap_u(ode.u0.x[2], system, semi)
- TrixiParticles.update_final!(system, v, u, ode.u0.x[1], ode.u0.x[2], semi, 0.0)
- @test system.angular_velocity[] == 1.0
- end
-
- @trixi_testset "Time Step Invariance under Uniform Acceleration" begin
- coordinates = [-1.0 1.0
- 0.0 0.0]
- velocity = [1.0 1.0
- 0.0 0.0]
- mass = [1.0, 1.0]
- density = [1000.0, 1000.0]
- initial_condition = InitialCondition(; coordinates, velocity, mass, density,
- particle_spacing=0.1)
-
- system_ref = RigidBodySystem(initial_condition; acceleration=(0.0, -9.81))
- semi_ref = Semidiscretization(system_ref, neighborhood_search=nothing)
- system_ref = semi_ref.systems[1]
- ode_ref = semidiscretize(semi_ref, (0.0, 0.0); reset_threads=false)
-
- system_shifted = RigidBodySystem(initial_condition; acceleration=(0.0, -1000.0))
- semi_shifted = Semidiscretization(system_shifted, neighborhood_search=nothing)
- system_shifted = semi_shifted.systems[1]
- ode_shifted = semidiscretize(semi_shifted, (0.0, 0.0); reset_threads=false)
-
- dt_ref = TrixiParticles.calculate_dt(ode_ref.u0.x[1], ode_ref.u0.x[2], 0.25,
- system_ref, semi_ref)
- dt_shifted = TrixiParticles.calculate_dt(ode_shifted.u0.x[1], ode_shifted.u0.x[2],
- 0.25, system_shifted, semi_shifted)
-
- @test isapprox(dt_ref, 0.25 * 0.1 / 1.0)
- @test dt_shifted == dt_ref
- end
-
- @trixi_testset "Rotational Kinematics" begin
- coordinates = [-1.0 1.0
- 0.0 0.0]
- velocity = [0.0 0.0
- -1.0 1.0]
- mass = [1.0, 1.0]
- density = [1000.0, 1000.0]
-
- initial_condition = InitialCondition(; coordinates, velocity, mass, density,
- particle_spacing=1.0)
- rigid_system = RigidBodySystem(initial_condition;
- acceleration=(0.0, 0.0))
-
- v = copy(velocity)
- u = copy(coordinates)
- TrixiParticles.update_final!(rigid_system, v, u, nothing, nothing, nothing, 0.0)
-
- @test rigid_system.angular_velocity[] == 1.0
- @test rigid_system.inertia[] == 2.0
-
- dv = zeros(size(v))
- semi = DummySemidiscretization()
- TrixiParticles.interact!(dv, v, u, v, u, rigid_system, rigid_system, semi)
- @test all(iszero, dv)
-
- TrixiParticles.finalize_interaction!(rigid_system, dv, v, u,
- nothing, nothing, nothing, semi)
-
- @test dv == [1.0 -1.0
- 0.0 0.0]
- end
-
- @trixi_testset "IO Data" begin
- coordinates = [-1.0 1.0
- 0.0 0.0]
- velocity = [0.0 0.0
- -1.0 1.0]
- mass = [1.0, 1.0]
- density = [1000.0, 1000.0]
-
- initial_condition = InitialCondition(; coordinates, velocity, mass, density,
- particle_spacing=1.0)
- rigid_system = RigidBodySystem(initial_condition; acceleration=(0.0, 0.0))
-
- semi = Semidiscretization(rigid_system)
- ode = semidiscretize(semi, (0.0, 0.01))
- v_ode, u_ode = ode.u0.x
- dv_ode = zeros(eltype(v_ode), size(v_ode))
- du_ode = zeros(eltype(u_ode), size(u_ode))
-
- v = TrixiParticles.wrap_v(v_ode, rigid_system, semi)
- u = TrixiParticles.wrap_u(u_ode, rigid_system, semi)
- TrixiParticles.update_final!(rigid_system, v, u, v_ode, u_ode, semi, 0.0)
-
- data = TrixiParticles.system_data(rigid_system, dv_ode, du_ode,
- v_ode, u_ode, semi)
- fields = TrixiParticles.available_data(rigid_system)
-
- @test data.center_of_mass == [0.0, 0.0]
- @test data.center_of_mass_velocity == [0.0, 0.0]
- @test data.angular_velocity == 1.0
- @test data.resultant_force == [0.0, 0.0]
- @test data.resultant_torque == 0.0
- @test data.angular_acceleration_force == 0.0
- @test data.gyroscopic_acceleration == 0.0
- @test data.contact_count == 0
- @test data.max_contact_penetration == 0.0
- @test data.relative_coordinates == rigid_system.relative_coordinates
- @test :contact_count in fields
- @test :max_contact_penetration in fields
- @test !(:local_coordinates in fields)
- end
-
- @trixi_testset "Restart" begin
- coordinates = [0.0 1.0 2.0
- 0.0 0.0 0.0]
- velocity = [0.0 0.0 0.0
- 0.0 0.0 0.0]
- mass = [1.0, 1.0, 1.0]
- density = [1000.0, 1000.0, 1000.0]
-
- initial_condition = InitialCondition(; coordinates, velocity, mass, density,
- particle_spacing=1.0)
- rigid_system = RigidBodySystem(initial_condition; acceleration=(0.0, 0.0))
-
- u_new = [2.0 4.0 6.0
- 3.0 3.0 3.0]
- v_new = [1.0 2.0 3.0
- 4.0 5.0 6.0]
-
- TrixiParticles.update_final!(rigid_system, rigid_system.initial_velocity,
- rigid_system.initial_condition.coordinates,
- nothing, nothing, nothing, 0.0)
- stale_relative_coordinates = copy(rigid_system.relative_coordinates)
- stale_center_of_mass = rigid_system.center_of_mass[]
- stale_center_of_mass_velocity = rigid_system.center_of_mass_velocity[]
- stale_angular_velocity = rigid_system.angular_velocity[]
- stale_force = SVector(7.0, -11.0)
- stale_torque = 5.0
- stale_angular_acceleration_force = 2.0
- rigid_system.resultant_force[] = stale_force
- rigid_system.resultant_torque[] = stale_torque
- rigid_system.angular_acceleration_force[] = stale_angular_acceleration_force
-
- restarted_system = TrixiParticles.restart_with!(rigid_system, v_new, u_new)
-
- @test restarted_system === rigid_system
- @test rigid_system.initial_condition.coordinates == u_new
- @test rigid_system.initial_condition.velocity == v_new
- @test rigid_system.initial_velocity == v_new
- @test rigid_system.relative_coordinates == stale_relative_coordinates
- @test rigid_system.center_of_mass[] == stale_center_of_mass
- @test rigid_system.center_of_mass_velocity[] == stale_center_of_mass_velocity
- @test rigid_system.angular_velocity[] == stale_angular_velocity
- @test rigid_system.resultant_force[] == stale_force
- @test rigid_system.resultant_torque[] == stale_torque
- @test rigid_system.angular_acceleration_force[] == stale_angular_acceleration_force
-
- expected_center_of_mass = [4.0, 3.0]
- expected_relative_coordinates = u_new .- expected_center_of_mass
- semi = Semidiscretization(rigid_system, neighborhood_search=nothing)
- dt_restarted = TrixiParticles.calculate_dt(v_new, u_new, 0.25, rigid_system, semi)
-
- TrixiParticles.update_final!(rigid_system, v_new, u_new, nothing, nothing, semi,
- 0.0)
- dt_updated = TrixiParticles.calculate_dt(v_new, u_new, 0.25, rigid_system, semi)
-
- @test rigid_system.center_of_mass[] == expected_center_of_mass
- @test rigid_system.relative_coordinates == expected_relative_coordinates
- @test rigid_system.center_of_mass_velocity[] == [2.0, 5.0]
- @test rigid_system.angular_velocity[] == 0.5
- @test isapprox(dt_restarted, dt_updated)
- end
-
- @trixi_testset "Velocity Components with ContinuityDensity" begin
- coordinates = [0.0 0.1
- 0.0 0.0]
- mass = [1.0, 1.0]
- density = [1000.0, 1000.0]
- initial_condition = InitialCondition(; coordinates, mass, density)
-
- smoothing_kernel = SchoenbergCubicSplineKernel{2}()
- smoothing_length = 0.12
- boundary_model = BoundaryModelDummyParticles(density, mass,
- ContinuityDensity(),
- smoothing_kernel,
- smoothing_length)
-
- rigid_system = RigidBodySystem(initial_condition; boundary_model)
- semi = Semidiscretization(rigid_system)
- ode = semidiscretize(semi, (0.0, 0.01))
- v_ode, u_ode = ode.u0.x
- dv_ode = zeros(eltype(v_ode), size(v_ode))
- du_ode = zeros(eltype(u_ode), size(u_ode))
-
- data = TrixiParticles.system_data(rigid_system, dv_ode, du_ode,
- v_ode, u_ode, semi)
-
- @test size(data.velocity, 1) == ndims(rigid_system)
- @test size(data.acceleration, 1) == ndims(rigid_system)
- end
-
- @trixi_testset "Configuration" begin
- coordinates = [1.0 2.0
- 1.0 2.0]
- mass = [1.0, 1.0]
- density = [1000.0, 1000.0]
-
- rigid_ic = InitialCondition(; coordinates, mass, density)
- rigid_system = RigidBodySystem(rigid_ic)
-
- smoothing_kernel = SchoenbergCubicSplineKernel{2}()
- smoothing_length = 0.12
- state_equation = StateEquationCole(; sound_speed=10.0, reference_density=1000.0,
- exponent=7.0)
- fluid_system = WeaklyCompressibleSPHSystem(rigid_ic; smoothing_kernel,
- smoothing_length,
- density_calculator=SummationDensity(),
- state_equation)
-
- @test_throws ArgumentError Semidiscretization(fluid_system, rigid_system)
-
- rigid_boundary_model = BoundaryModelDummyParticles(density, mass,
- SummationDensity(),
- smoothing_kernel,
- smoothing_length)
- rigid_system_with_dummy = RigidBodySystem(rigid_ic;
- boundary_model=rigid_boundary_model)
- fluid_with_surface_tension = WeaklyCompressibleSPHSystem(rigid_ic;
- smoothing_kernel,
- smoothing_length,
- density_calculator=SummationDensity(),
- state_equation,
- surface_tension=SurfaceTensionMorris(surface_tension_coefficient=0.072),
- reference_particle_spacing=0.1)
-
- @test_throws ArgumentError Semidiscretization(fluid_with_surface_tension,
- rigid_system_with_dummy)
- end
-
- @trixi_testset "Akinci Adhesion Matches Wall Boundary" begin
- particle_spacing = 1.0
- smoothing_kernel = SchoenbergCubicSplineKernel{2}()
- smoothing_length = 1.0
- fluid_density = 1000.0
- rigid_density = 2000.0
- particle_volume = particle_spacing^2
- adhesion_coefficient = 0.25
-
- state_equation = StateEquationCole(sound_speed=10.0,
- reference_density=fluid_density,
- exponent=1.0)
-
- function run_setup(boundary_kind)
- fluid_ic = InitialCondition(; coordinates=reshape([0.0, 0.0], 2, 1),
- velocity=zeros(2, 1),
- mass=[particle_volume * fluid_density],
- density=[fluid_density], particle_spacing)
-
- fluid_system = WeaklyCompressibleSPHSystem(fluid_ic; smoothing_kernel,
- smoothing_length,
- density_calculator=SummationDensity(),
- state_equation,
- surface_tension=SurfaceTensionAkinci(surface_tension_coefficient=0.05),
- reference_particle_spacing=particle_spacing)
-
- boundary_coordinates = reshape([1.5, 0.0], 2, 1)
- boundary_model = BoundaryModelDummyParticles([fluid_density],
- [particle_volume * fluid_density],
- AdamiPressureExtrapolation(),
- smoothing_kernel, smoothing_length;
- state_equation,
- reference_particle_spacing=particle_spacing)
-
- boundary_system = if boundary_kind == :wall
- wall_ic = InitialCondition(; coordinates=boundary_coordinates,
- velocity=zeros(2, 1),
- mass=[particle_volume * fluid_density],
- density=[fluid_density], particle_spacing)
- WallBoundarySystem(wall_ic, boundary_model; adhesion_coefficient)
- else
- rigid_ic = InitialCondition(; coordinates=boundary_coordinates,
- velocity=zeros(2, 1),
- mass=[particle_volume * rigid_density],
- density=[rigid_density], particle_spacing)
- RigidBodySystem(rigid_ic; boundary_model, adhesion_coefficient)
- end
-
- semi_ = Semidiscretization(fluid_system, boundary_system)
- ode = semidiscretize(semi_, (0.0, 0.01))
- semi = ode.p.semi
-
- v_ode, u_ode = ode.u0.x
- dv_ode = zero(v_ode)
- TrixiParticles.kick!(dv_ode, v_ode, u_ode, ode.p, 0.0)
-
- fluid = semi.systems[1]
- boundary = semi.systems[2]
- dv_fluid = TrixiParticles.wrap_v(dv_ode, fluid, semi)
-
- return fluid, boundary, copy(dv_fluid[:, 1])
- end
-
- _, _, dv_wall = run_setup(:wall)
- fluid_rigid, rigid_system, dv_rigid = run_setup(:rigid)
-
- @test isapprox(dv_rigid, dv_wall; rtol=sqrt(eps()), atol=sqrt(eps()))
- @test isapprox(rigid_system.resultant_force[],
- -fluid_rigid.mass[1] * dv_rigid;
- rtol=sqrt(eps()), atol=sqrt(eps()))
- end
-
- @trixi_testset "Rigid Interaction Caches Stay Zero without Fluid Neighbors" begin
- rigid_ic = InitialCondition(coordinates=reshape([0.0, 0.0], 2, 1),
- velocity=zeros(2, 1),
- mass=[1.0],
- density=[1.0],
- particle_spacing=1.0)
- rigid_system = RigidBodySystem(rigid_ic; acceleration=(0.0, 0.0))
-
- semi_ = Semidiscretization(rigid_system)
- ode = semidiscretize(semi_, (0.0, 0.01))
- semi = ode.p.semi
-
- v_ode, u_ode = ode.u0.x
- dv_ode = zero(v_ode)
- TrixiParticles.kick!(dv_ode, v_ode, u_ode, ode.p, 0.0)
-
- rigid = only(semi.systems)
- dv_rigid = TrixiParticles.wrap_v(dv_ode, rigid, semi)
-
- @test all(iszero, dv_rigid)
- @test iszero(rigid.resultant_force[])
- @test iszero(rigid.resultant_torque[])
- @test iszero(rigid.angular_acceleration_force[])
- end
-
- @trixi_testset "Rigid Resultants Accumulate over Multiple Fluid Systems" begin
- particle_spacing = 1.0
- smoothing_kernel = SchoenbergCubicSplineKernel{2}()
- smoothing_length = 1.0
- fluid_density = 1000.0
- rigid_density = 2000.0
- particle_volume = particle_spacing^2
-
- state_equation = StateEquationCole(sound_speed=10.0,
- reference_density=fluid_density,
- exponent=1.0)
-
- boundary_model = BoundaryModelDummyParticles(fill(fluid_density, 2),
- fill(particle_volume * fluid_density,
- 2), AdamiPressureExtrapolation(),
- smoothing_kernel, smoothing_length;
- state_equation,
- reference_particle_spacing=particle_spacing)
-
- function run_setup(fluid_positions)
- rigid_ic = InitialCondition(; coordinates=[-0.5 0.5
- 0.0 0.0],
- velocity=zeros(2, 2),
- mass=fill(particle_volume * rigid_density, 2),
- density=fill(rigid_density, 2), particle_spacing)
- rigid_system = RigidBodySystem(rigid_ic; boundary_model,
- acceleration=(0.0, 0.0))
-
- fluid_systems = map(fluid_positions) do position
- fluid_ic = InitialCondition(; coordinates=reshape(collect(position), 2, 1),
- velocity=zeros(2, 1),
- mass=[particle_volume * fluid_density],
- density=[fluid_density], particle_spacing)
-
- WeaklyCompressibleSPHSystem(fluid_ic; smoothing_kernel,
- smoothing_length,
- density_calculator=SummationDensity(),
- state_equation)
- end
-
- semi_ = Semidiscretization(fluid_systems..., rigid_system)
- ode = semidiscretize(semi_, (0.0, 0.01))
- semi = ode.p.semi
-
- v_ode, u_ode = ode.u0.x
- dv_ode = zero(v_ode)
- TrixiParticles.kick!(dv_ode, v_ode, u_ode, ode.p, 0.0)
-
- rigid = last(semi.systems)
- dv_rigid = TrixiParticles.wrap_v(dv_ode, rigid, semi)
-
- return rigid, copy(dv_rigid)
- end
-
- fluid_positions = ((1.5, 0.0), (-1.5, 1.0))
-
- rigid_1, dv_1 = run_setup((fluid_positions[1],))
- rigid_2, dv_2 = run_setup((fluid_positions[2],))
- rigid_both, dv_both = run_setup(fluid_positions)
-
- @test isapprox(dv_both, dv_1 .+ dv_2; rtol=sqrt(eps()), atol=sqrt(eps()))
- @test isapprox(rigid_both.resultant_force[],
- rigid_1.resultant_force[] + rigid_2.resultant_force[];
- rtol=sqrt(eps()), atol=sqrt(eps()))
- @test isapprox(rigid_both.resultant_torque[],
- rigid_1.resultant_torque[] + rigid_2.resultant_torque[];
- rtol=sqrt(eps()), atol=sqrt(eps()))
- @test isapprox(rigid_both.angular_acceleration_force[],
- rigid_1.angular_acceleration_force[] +
- rigid_2.angular_acceleration_force[];
- rtol=sqrt(eps()), atol=sqrt(eps()))
- end
-
- @trixi_testset "Rigid Bodies Ignore Open Boundary Interactions" begin
- particle_spacing = 1.0
- smoothing_kernel = SchoenbergCubicSplineKernel{2}()
- smoothing_length = 1.0
- fluid_density = 1000.0
- rigid_density = 2000.0
- particle_volume = particle_spacing^2
-
- state_equation = StateEquationCole(sound_speed=10.0,
- reference_density=fluid_density,
- exponent=1.0)
-
- boundary_model = BoundaryModelDummyParticles([fluid_density],
- [particle_volume * fluid_density],
- AdamiPressureExtrapolation(),
- smoothing_kernel, smoothing_length;
- state_equation,
- reference_particle_spacing=particle_spacing)
-
- rigid_ic = InitialCondition(; coordinates=reshape([0.0, 0.0], 2, 1),
- velocity=zeros(2, 1),
- mass=[particle_volume * rigid_density],
- density=[rigid_density], particle_spacing)
- rigid_system = RigidBodySystem(rigid_ic; boundary_model, acceleration=(0.0, 0.0))
-
- open_boundary_ic = InitialCondition(; coordinates=reshape([1.5, 0.0], 2, 1),
- velocity=zeros(2, 1),
- mass=[particle_volume * fluid_density],
- density=[fluid_density], particle_spacing)
-
- fluid_support_ic = InitialCondition(; coordinates=reshape([10.0, 10.0], 2, 1),
- velocity=zeros(2, 1),
- mass=[particle_volume * fluid_density],
- density=[fluid_density], particle_spacing)
- fluid_system = WeaklyCompressibleSPHSystem(fluid_support_ic; smoothing_kernel,
- smoothing_length,
- density_calculator=SummationDensity(),
- state_equation)
-
- boundary_face = ([2.0, -0.5], [2.0, 0.5])
- zone = BoundaryZone(; boundary_face, face_normal=(1.0, 0.0), density=fluid_density,
- particle_spacing, initial_condition=open_boundary_ic,
- open_boundary_layers=1, boundary_type=InFlow())
-
- open_boundary_system = OpenBoundarySystem(zone; fluid_system,
- boundary_model=BoundaryModelDynamicalPressureZhang(),
- buffer_size=0)
-
- semi_ = Semidiscretization(fluid_system, rigid_system, open_boundary_system)
- ode = semidiscretize(semi_, (0.0, 0.01))
- semi = ode.p.semi
-
- rigid = semi.systems[2]
- open_boundary = semi.systems[3]
-
- @test iszero(TrixiParticles.compact_support(rigid, open_boundary))
- @test iszero(TrixiParticles.compact_support(open_boundary, rigid))
-
- v_ode, u_ode = ode.u0.x
- dv_ode = zero(v_ode)
-
- TrixiParticles.interact!(dv_ode, v_ode, u_ode, rigid, open_boundary, semi)
- TrixiParticles.interact!(dv_ode, v_ode, u_ode, open_boundary, rigid, semi)
-
- dv_rigid = TrixiParticles.wrap_v(dv_ode, rigid, semi)
- dv_open_boundary = TrixiParticles.wrap_v(dv_ode, open_boundary, semi)
-
- @test all(iszero, dv_rigid[:, 1])
- @test all(iszero, dv_open_boundary[:, 1])
- @test iszero(rigid.resultant_force[])
- @test iszero(rigid.resultant_torque[])
- end
-
- @trixi_testset "Rigid Contact Model" begin
- rigid_coordinates_1 = reshape([0.0, 0.0], 2, 1)
- rigid_coordinates_2 = reshape([0.08, 0.0], 2, 1)
- rigid_velocity_1 = reshape([1.0, 0.0], 2, 1)
- rigid_velocity_2 = reshape([-0.5, 0.0], 2, 1)
- rigid_mass_1 = [2.0]
- rigid_mass_2 = [1.0]
- rigid_density_pair = [1000.0]
-
- rigid_ic_1 = InitialCondition(; coordinates=rigid_coordinates_1,
- velocity=rigid_velocity_1,
- mass=rigid_mass_1,
- density=rigid_density_pair,
- particle_spacing=0.1)
- rigid_ic_2 = InitialCondition(; coordinates=rigid_coordinates_2,
- velocity=rigid_velocity_2,
- mass=rigid_mass_2,
- density=rigid_density_pair,
- particle_spacing=0.1)
-
- contact_model_1 = RigidContactModel(; normal_stiffness=20.0,
- normal_damping=4.0,
- contact_distance=0.1)
- contact_model_2 = RigidContactModel(; normal_stiffness=30.0,
- normal_damping=8.0,
- contact_distance=0.12)
-
- rigid_system_1 = RigidBodySystem(rigid_ic_1;
- acceleration=(0.0, 0.0),
- contact_model=contact_model_1)
- rigid_system_2 = RigidBodySystem(rigid_ic_2;
- acceleration=(0.0, 0.0),
- contact_model=contact_model_2)
- rigid_system_without_contact = RigidBodySystem(rigid_ic_1;
- acceleration=(0.0, 0.0))
-
- semi_rigid = Semidiscretization(rigid_system_1, rigid_system_2)
- ode_rigid = semidiscretize(semi_rigid, (0.0, 0.01))
- v_ode_rigid, u_ode_rigid = ode_rigid.u0.x
- dv_ode_rigid = zero(v_ode_rigid)
-
- v_rigid_1 = TrixiParticles.wrap_v(v_ode_rigid, rigid_system_1, semi_rigid)
- u_rigid_1 = TrixiParticles.wrap_u(u_ode_rigid, rigid_system_1, semi_rigid)
- v_rigid_2 = TrixiParticles.wrap_v(v_ode_rigid, rigid_system_2, semi_rigid)
- u_rigid_2 = TrixiParticles.wrap_u(u_ode_rigid, rigid_system_2, semi_rigid)
- TrixiParticles.update_final!(rigid_system_1, v_rigid_1, u_rigid_1,
- v_ode_rigid, u_ode_rigid, semi_rigid, 0.0)
- TrixiParticles.update_final!(rigid_system_2, v_rigid_2, u_rigid_2,
- v_ode_rigid, u_ode_rigid, semi_rigid, 0.0)
-
- TrixiParticles.reset_interaction_caches!(semi_rigid)
- TrixiParticles.interact!(dv_ode_rigid, v_ode_rigid, u_ode_rigid,
- rigid_system_1, rigid_system_2, semi_rigid)
- force_after_forward_1 = copy(rigid_system_1.force_per_particle)
- force_after_forward_2 = copy(rigid_system_2.force_per_particle)
- @test !all(iszero, force_after_forward_1)
- @test all(iszero, force_after_forward_2)
-
- TrixiParticles.interact!(dv_ode_rigid, v_ode_rigid, u_ode_rigid,
- rigid_system_2, rigid_system_1, semi_rigid)
- @test rigid_system_1.force_per_particle == force_after_forward_1
- @test !all(iszero, rigid_system_2.force_per_particle)
-
- pair_contact_distance = max(contact_model_1.contact_distance,
- contact_model_2.contact_distance)
- pair_normal_stiffness = (contact_model_1.normal_stiffness +
- contact_model_2.normal_stiffness) / 2
- pair_normal_damping = (contact_model_1.normal_damping +
- contact_model_2.normal_damping) / 2
- pair_penetration = pair_contact_distance - 0.08
- normal_velocity = -1.5
- pair_contact_dt = sqrt((rigid_mass_1[1] * rigid_mass_2[1] /
- (rigid_mass_1[1] + rigid_mass_2[1])) /
- pair_normal_stiffness)
- expected_force_magnitude = pair_normal_stiffness * pair_penetration -
- pair_normal_damping * normal_velocity
- expected_force = SVector(-expected_force_magnitude, 0.0)
-
- @test vec(force_after_forward_1[:, 1]) ≈ collect(expected_force)
- @test vec(rigid_system_2.force_per_particle[:, 1]) ≈ collect(-expected_force)
- @test rigid_system_1.cache.contact_count[] == 1
- @test rigid_system_2.cache.contact_count[] == 1
- @test rigid_system_1.cache.max_contact_penetration[] ≈ pair_penetration
- @test rigid_system_2.cache.max_contact_penetration[] ≈ pair_penetration
-
- @test TrixiParticles.compact_support(rigid_system_1, rigid_system_2) ≈
- pair_contact_distance
- @test iszero(TrixiParticles.compact_support(rigid_system_without_contact,
- rigid_system_2))
- @test iszero(TrixiParticles.compact_support(rigid_system_2,
- rigid_system_without_contact))
- @test TrixiParticles.contact_time_step(rigid_system_1, rigid_system_2) ≈
- pair_contact_dt
- @test TrixiParticles.contact_time_step(rigid_system_without_contact,
- rigid_system_2) == Inf
- @test TrixiParticles.contact_time_step(rigid_system_2,
- rigid_system_without_contact) == Inf
- @test TrixiParticles.contact_time_step(rigid_system_1) ≈
- sqrt(rigid_mass_1[1] / contact_model_1.normal_stiffness)
- @test TrixiParticles.contact_time_step(rigid_system_2) ≈
- sqrt(rigid_mass_2[1] / contact_model_2.normal_stiffness)
- semi_single_rigid = Semidiscretization(rigid_system_1)
- ode_single_rigid = semidiscretize(semi_single_rigid, (0.0, 0.01))
- zero_velocity_single = zero(ode_single_rigid.u0.x[1])
- @test TrixiParticles.calculate_dt(zero_velocity_single, ode_single_rigid.u0.x[2],
- 0.25, rigid_system_1, semi_single_rigid) == Inf
- zero_velocity_ode = zero(v_ode_rigid)
- @test TrixiParticles.calculate_dt(zero_velocity_ode, u_ode_rigid, 0.25,
- rigid_system_1, semi_rigid) ≈
- 0.25 * pair_contact_dt
- @test TrixiParticles.calculate_dt(zero_velocity_ode, u_ode_rigid, 0.25,
- semi_rigid) ≈ 0.25 * pair_contact_dt
-
- dv_ode_reset = zero(v_ode_rigid)
- TrixiParticles.system_interaction!(dv_ode_reset, v_ode_rigid, u_ode_rigid,
- semi_rigid)
- @test rigid_system_1.cache.contact_count[] == 1
- @test rigid_system_2.cache.contact_count[] == 1
- @test rigid_system_1.cache.max_contact_penetration[] ≈ pair_penetration
- @test rigid_system_2.cache.max_contact_penetration[] ≈ pair_penetration
- @test vec(rigid_system_1.force_per_particle[:, 1]) ≈ collect(expected_force)
- @test vec(rigid_system_2.force_per_particle[:, 1]) ≈ collect(-expected_force)
-
- TrixiParticles.update_systems_and_nhs(v_ode_rigid, u_ode_rigid, semi_rigid, 0.0)
- @test rigid_system_1.cache.contact_count[] == 1
- @test rigid_system_2.cache.contact_count[] == 1
- @test rigid_system_1.cache.max_contact_penetration[] ≈ pair_penetration
- @test rigid_system_2.cache.max_contact_penetration[] ≈ pair_penetration
- @test vec(rigid_system_1.force_per_particle[:, 1]) ≈ collect(expected_force)
- @test vec(rigid_system_2.force_per_particle[:, 1]) ≈ collect(-expected_force)
-
- TrixiParticles.set_zero!(dv_ode_reset)
- TrixiParticles.system_interaction!(dv_ode_reset, v_ode_rigid, u_ode_rigid,
- semi_rigid)
- @test rigid_system_1.cache.contact_count[] == 1
- @test rigid_system_2.cache.contact_count[] == 1
- @test rigid_system_1.cache.max_contact_penetration[] ≈ pair_penetration
- @test rigid_system_2.cache.max_contact_penetration[] ≈ pair_penetration
- @test vec(rigid_system_1.force_per_particle[:, 1]) ≈ collect(expected_force)
- @test vec(rigid_system_2.force_per_particle[:, 1]) ≈ collect(-expected_force)
-
- dv_rigid_1 = TrixiParticles.wrap_v(dv_ode_rigid, rigid_system_1, semi_rigid)
- dv_rigid_2 = TrixiParticles.wrap_v(dv_ode_rigid, rigid_system_2, semi_rigid)
- TrixiParticles.finalize_interaction!(rigid_system_1, dv_rigid_1, v_rigid_1,
- u_rigid_1, dv_ode_rigid, v_ode_rigid,
- u_ode_rigid, semi_rigid)
- TrixiParticles.finalize_interaction!(rigid_system_2, dv_rigid_2, v_rigid_2,
- u_rigid_2, dv_ode_rigid, v_ode_rigid,
- u_ode_rigid, semi_rigid)
-
- @test rigid_system_1.resultant_force[] ≈ expected_force
- @test rigid_system_2.resultant_force[] ≈ -expected_force
- @test dv_rigid_1[1, 1] ≈ expected_force[1] / rigid_mass_1[1]
- @test dv_rigid_2[1, 1] ≈ -expected_force[1] / rigid_mass_2[1]
- @test dv_rigid_1[2, 1] ≈ 0.0
- @test dv_rigid_2[2, 1] ≈ 0.0
-
- mktempdir() do tmp_dir
- du_ode_rigid = zero(u_ode_rigid)
- dvdu_ode_rigid = (; x=(dv_ode_rigid, du_ode_rigid))
- vu_ode_rigid = (; x=(v_ode_rigid, u_ode_rigid))
- trixi2vtk(dvdu_ode_rigid, vu_ode_rigid, semi_rigid, 0.0;
- output_directory=tmp_dir, iter=1)
-
- contact_filename = TrixiParticles.system_names(semi_rigid.systems)[1]
- vtk_contact = TrixiParticles.ReadVTK.VTKFile(joinpath(tmp_dir,
- "$(contact_filename)_1.vtu"))
- point_data_contact = TrixiParticles.ReadVTK.get_point_data(vtk_contact)
-
- @test only(Array(TrixiParticles.ReadVTK.get_data(point_data_contact["contact_count"]))) ==
- rigid_system_1.cache.contact_count[]
- @test only(Array(TrixiParticles.ReadVTK.get_data(point_data_contact["contact_count"]))) >
- 0
- @test only(Array(TrixiParticles.ReadVTK.get_data(point_data_contact["max_contact_penetration"]))) ≈
- rigid_system_1.cache.max_contact_penetration[]
- @test only(Array(TrixiParticles.ReadVTK.get_data(point_data_contact["max_contact_penetration"]))) >
- 0
- end
-
- rigid_coordinates = reshape([0.0, 0.05], 2, 1)
- rigid_velocity = reshape([0.0, -1.0], 2, 1)
- rigid_mass = [1.0]
- rigid_density = [1000.0]
- rigid_ic = InitialCondition(; coordinates=rigid_coordinates,
- velocity=rigid_velocity,
- mass=rigid_mass,
- density=rigid_density,
- particle_spacing=0.1)
-
- boundary_coordinates = reshape([0.0, 0.0], 2, 1)
- boundary_mass = [1.0]
- boundary_density = [1000.0]
- boundary_ic = InitialCondition(; coordinates=boundary_coordinates,
- mass=boundary_mass,
- density=boundary_density,
- particle_spacing=0.1)
-
- smoothing_kernel = SchoenbergCubicSplineKernel{2}()
- smoothing_length = 0.15
- boundary_model = BoundaryModelDummyParticles(boundary_density, boundary_mass,
- SummationDensity(),
- smoothing_kernel,
- smoothing_length)
- boundary_system = WallBoundarySystem(boundary_ic, boundary_model)
-
- contact_model = RigidContactModel(; normal_stiffness=2.0e4,
- normal_damping=20.0,
- contact_distance=0.1)
-
- runtime_model = TrixiParticles.copy_contact_model(contact_model, 0.1, Float64)
- @test runtime_model.normal_stiffness ≈ 2.0e4
- @test runtime_model.normal_damping ≈ 20.0
- @test runtime_model.contact_distance ≈ 0.1
-
- spacing_scaled_model = RigidContactModel(; normal_stiffness=5.0)
- spacing_scaled_runtime = TrixiParticles.copy_contact_model(spacing_scaled_model,
- 0.125,
- Float64)
- @test spacing_scaled_runtime.contact_distance ≈ 0.125
-
- @test_throws ArgumentError RigidContactModel(; normal_stiffness=0.0)
- @test_throws ArgumentError RigidContactModel(; normal_stiffness=1.0,
- normal_damping=-1.0)
- @test_throws ArgumentError RigidContactModel(; normal_stiffness=1.0,
- contact_distance=-1.0)
-
- rigid_system = RigidBodySystem(rigid_ic; acceleration=(0.0, 0.0), contact_model)
- rigid_system_with_boundary = RigidBodySystem(rigid_ic; acceleration=(0.0, 0.0),
- boundary_model, contact_model)
- rigid_system_custom_manifolds = RigidBodySystem(rigid_ic; acceleration=(0.0, 0.0),
- contact_model, max_manifolds=3)
- rigid_system_without_contact = RigidBodySystem(rigid_ic; acceleration=(0.0, 0.0),
- boundary_model)
- @test haskey(rigid_system.cache, :contact_manifold_count)
- @test rigid_system.contact_model.normal_stiffness ≈ contact_model.normal_stiffness
- @test rigid_system.contact_model.normal_damping ≈ contact_model.normal_damping
- @test rigid_system.contact_model.contact_distance ≈ contact_model.contact_distance
- rigid_system_data = Dict{String, Any}()
- TrixiParticles.add_system_data!(rigid_system_data, rigid_system)
- @test rigid_system_data["contact_model"]["model"] ==
- TrixiParticles.type2string(rigid_system.contact_model)
- @test rigid_system_data["contact_model"]["normal_stiffness"] ≈
- contact_model.normal_stiffness
- @test rigid_system_data["contact_model"]["normal_damping"] ≈
- contact_model.normal_damping
- @test rigid_system_data["contact_model"]["contact_distance"] ≈
- contact_model.contact_distance
- @test size(rigid_system_custom_manifolds.cache.contact_manifold_weight_sum, 1) == 3
- @test TrixiParticles.compact_support(rigid_system, boundary_system) ≈
- contact_model.contact_distance
- @test TrixiParticles.compact_support(rigid_system_with_boundary,
- boundary_system) ≈
- contact_model.contact_distance
- @test iszero(TrixiParticles.compact_support(boundary_system, rigid_system))
- @test iszero(TrixiParticles.compact_support(rigid_system_without_contact,
- boundary_system))
- @test_throws ArgumentError RigidBodySystem(rigid_ic; contact_model, max_manifolds=0)
-
- system_meta_data = Dict{String, Any}()
- TrixiParticles.add_system_data!(system_meta_data, rigid_system)
- @test system_meta_data["contact_model"]["normal_stiffness"] ≈ 2.0e4
- @test system_meta_data["contact_model"]["normal_damping"] ≈ 20.0
- @test system_meta_data["contact_model"]["contact_distance"] ≈ 0.1
-
- semi = Semidiscretization(rigid_system, boundary_system)
- ode = semidiscretize(semi, (0.0, 0.01))
- v_ode, u_ode = ode.u0.x
- dv_ode = zero(v_ode)
- wall_contact_dt = sqrt(rigid_mass[1] / contact_model.normal_stiffness)
-
- @test TrixiParticles.contact_time_step(rigid_system) ≈ wall_contact_dt
- @test TrixiParticles.contact_time_step(rigid_system, boundary_system) ≈
- wall_contact_dt
-
- kick_boundary_model = BoundaryModelDummyParticles(boundary_density, boundary_mass,
- SummationDensity(),
- smoothing_kernel,
- smoothing_length)
- kick_rigid_system = RigidBodySystem(rigid_ic; acceleration=(0.0, 0.0),
- contact_model)
- kick_boundary_system = WallBoundarySystem(boundary_ic, kick_boundary_model)
- kick_semi = Semidiscretization(kick_rigid_system, kick_boundary_system)
- kick_ode = semidiscretize(kick_semi, (0.0, 0.01))
- kick_v_ode, kick_u_ode = kick_ode.u0.x
- kick_dv_ode = zero(kick_v_ode)
-
- TrixiParticles.kick!(kick_dv_ode, kick_v_ode, kick_u_ode, kick_ode.p, 0.0)
- kick_dv = TrixiParticles.wrap_v(kick_dv_ode, kick_rigid_system, kick_semi)
-
- @test kick_dv[2, 1] > 0
- @test kick_rigid_system.resultant_force[][2] > 0
-
- TrixiParticles.reset_interaction_caches!(semi)
- TrixiParticles.interact!(dv_ode, v_ode, u_ode, rigid_system, boundary_system, semi)
- dv = TrixiParticles.wrap_v(dv_ode, rigid_system, semi)
- v_rigid = TrixiParticles.wrap_v(v_ode, rigid_system, semi)
- u_rigid = TrixiParticles.wrap_u(u_ode, rigid_system, semi)
- TrixiParticles.finalize_interaction!(rigid_system, dv, v_rigid, u_rigid,
- dv_ode, v_ode, u_ode, semi)
-
- @test dv[2, 1] > 0
- @test rigid_system.cache.contact_count[] == 1
- @test rigid_system.cache.max_contact_penetration[] ≈ 0.05
- direct_force = copy(rigid_system.force_per_particle)
- direct_resultant_force = rigid_system.resultant_force[]
-
- TrixiParticles.set_zero!(dv_ode)
- TrixiParticles.update_final!(rigid_system, v_rigid, u_rigid, v_ode, u_ode, semi,
- 0.0)
- TrixiParticles.reset_interaction_caches!(semi)
- TrixiParticles.interact!(dv_ode, v_ode, u_ode, rigid_system, boundary_system, semi)
- TrixiParticles.finalize_interaction!(rigid_system, dv, v_rigid, u_rigid,
- dv_ode, v_ode, u_ode, semi)
-
- @test rigid_system.cache.contact_count[] == 1
- @test rigid_system.cache.max_contact_penetration[] ≈ 0.05
- @test rigid_system.force_per_particle == direct_force
- @test rigid_system.resultant_force[] ≈ direct_resultant_force
-
- TrixiParticles.set_zero!(dv_ode)
- TrixiParticles.update_final!(rigid_system, v_rigid, u_rigid, v_ode, u_ode, semi,
- 0.0)
- TrixiParticles.reset_interaction_caches!(semi)
- TrixiParticles.finalize_interaction!(rigid_system, dv, v_rigid, u_rigid,
- dv_ode, v_ode, u_ode, semi)
-
- @test all(iszero, dv)
- @test iszero(rigid_system.resultant_force[])
- @test iszero(rigid_system.resultant_torque[])
- @test iszero(rigid_system.angular_acceleration_force[])
-
- far_rigid_ic = InitialCondition(; coordinates=reshape([0.0, 0.09], 2, 1),
- velocity=rigid_velocity,
- mass=rigid_mass,
- density=rigid_density,
- particle_spacing=0.1)
- short_support_boundary_model = BoundaryModelDummyParticles(boundary_density,
- boundary_mass,
- SummationDensity(),
- smoothing_kernel,
- 0.04)
- short_support_boundary = WallBoundarySystem(boundary_ic,
- short_support_boundary_model)
- far_rigid_system = RigidBodySystem(far_rigid_ic; acceleration=(0.0, 0.0),
- contact_model)
- short_support_semi = Semidiscretization(far_rigid_system, short_support_boundary)
- short_support_ode = semidiscretize(short_support_semi, (0.0, 0.01))
- short_support_v_ode, short_support_u_ode = short_support_ode.u0.x
- short_support_dv_ode = zero(short_support_v_ode)
-
- TrixiParticles.reset_interaction_caches!(short_support_semi)
- TrixiParticles.interact!(short_support_dv_ode, short_support_v_ode,
- short_support_u_ode, far_rigid_system,
- short_support_boundary, short_support_semi)
- short_support_dv = TrixiParticles.wrap_v(short_support_dv_ode, far_rigid_system,
- short_support_semi)
- short_support_v = TrixiParticles.wrap_v(short_support_v_ode, far_rigid_system,
- short_support_semi)
- short_support_u = TrixiParticles.wrap_u(short_support_u_ode, far_rigid_system,
- short_support_semi)
- TrixiParticles.finalize_interaction!(far_rigid_system, short_support_dv,
- short_support_v, short_support_u,
- short_support_dv_ode, short_support_v_ode,
- short_support_u_ode, short_support_semi)
-
- @test short_support_dv[2, 1] > 0
- end
+ include("rigid_body/core.jl")
+ include("rigid_body/state_io.jl")
+ include("rigid_body/fluid_interaction.jl")
+ include("rigid_body/normal_contact.jl")
+ include("rigid_body/contact_model.jl")
+ include("rigid_body/contact_history.jl")
end
diff --git a/test/unittest.jl b/test/unittest.jl
index ae6ac865c8..d008f0ffe1 100644
--- a/test/unittest.jl
+++ b/test/unittest.jl
@@ -9,4 +9,5 @@
include("preprocessing/preprocessing.jl")
include("io/write_vtk.jl")
include("io/read_vtk.jl")
+ include("visualization/makie.jl")
end;
diff --git a/test/validation/validation.jl b/test/validation/validation.jl
index 598c82973c..9f24d7cdc5 100644
--- a/test/validation/validation.jl
+++ b/test/validation/validation.jl
@@ -18,13 +18,7 @@
r"\[ Info: To create the self-interaction neighborhood search.*\n"
]
@test sol.retcode == ReturnCode.Success
- if VERSION < v"1.12"
- # Older Julia versions produce allocations because `get_neighborhood_search`
- # is not type-stable with TLSPH.
- @test count_rhs_allocations(sol) < 200
- else
- @test count_rhs_allocations(sol) == 0
- end
+ @test count_rhs_allocations(sol) == 0
@test isapprox(error_deflection_x, 0, atol=eps())
@test isapprox(error_deflection_y, 0, atol=eps())
diff --git a/test/visualization/makie.jl b/test/visualization/makie.jl
new file mode 100644
index 0000000000..c894c80a2b
--- /dev/null
+++ b/test/visualization/makie.jl
@@ -0,0 +1,77 @@
+using CairoMakie
+
+@testset verbose=true "Makie Extension" begin
+ initial_condition = RectangularShape(0.1, (2, 2), (0.0, 0.0); density=1.0)
+ fluid_system = WeaklyCompressibleSPHSystem(initial_condition;
+ smoothing_kernel=SchoenbergCubicSplineKernel{2}(),
+ smoothing_length=0.1,
+ density_calculator=SummationDensity(),
+ state_equation=nothing)
+ semi = Semidiscretization(fluid_system)
+ ode = semidiscretize(semi, (0.0, 0.01))
+ v_ode, u_ode = ode.u0.x
+
+ makie_extension = Base.get_extension(TrixiParticles, :TrixiParticlesMakieExt)
+ @test makie_extension !== nothing
+
+ figure, axis, plot_object = plot(v_ode, u_ode, semi)
+ @test figure isa Figure
+ @test axis isa Axis
+ @test length(plot_object.plots) == 1
+ @test only(plot_object.plots) isa CairoMakie.MeshScatter
+ @test length(only(plot_object.plots)[1][]) == nparticles(fluid_system)
+
+ figure = Figure(; size=(320, 240))
+ axis = Axis(figure[1, 1])
+ plot_object = trixi2makie!(axis, v_ode, u_ode, semi)
+ @test plot_object isa makie_extension.Trixi2Makie
+
+ solution = TrixiParticles.SciMLBase.build_solution(ode, :NoAlgorithm,
+ [first(ode.tspan)], [ode.u0])
+ figure, axis, plot_object = plot(solution)
+ @test figure isa Figure
+ @test axis isa Axis
+ @test plot_object isa makie_extension.Trixi2Makie
+
+ figure = Figure(; size=(320, 240))
+ axis = Axis(figure[1, 1])
+ @test plot!(axis, solution) isa makie_extension.Trixi2Makie
+
+ initial_condition_3d = RectangularShape(0.1, (2, 2, 2), (0.0, 0.0, 0.0);
+ density=1.0)
+ fluid_system_3d = WeaklyCompressibleSPHSystem(initial_condition_3d;
+ smoothing_kernel=SchoenbergCubicSplineKernel{3}(),
+ smoothing_length=0.1,
+ density_calculator=SummationDensity(),
+ state_equation=nothing)
+ semi_3d = Semidiscretization(fluid_system_3d)
+ ode_3d = semidiscretize(semi_3d, (0.0, 0.01))
+ v_ode_3d, u_ode_3d = ode_3d.u0.x
+
+ figure, axis, plot_object = plot(v_ode_3d, u_ode_3d, semi_3d)
+ @test figure isa Figure
+ @test axis isa Axis3
+ @test only(plot_object.plots) isa CairoMakie.MeshScatter
+ @test length(only(plot_object.plots)[1][]) == nparticles(fluid_system_3d)
+
+ initial_condition_3d_2 = RectangularShape(0.1, (2, 2, 2), (0.3, 0.0, 0.0);
+ density=1.0)
+ fluid_system_3d_2 = WeaklyCompressibleSPHSystem(initial_condition_3d_2;
+ smoothing_kernel=SchoenbergCubicSplineKernel{3}(),
+ smoothing_length=0.1,
+ density_calculator=SummationDensity(),
+ state_equation=nothing)
+ semi_3d_2 = Semidiscretization(fluid_system_3d, fluid_system_3d_2)
+ ode_3d_2 = semidiscretize(semi_3d_2, (0.0, 0.01))
+ v_ode_3d_2, u_ode_3d_2 = ode_3d_2.u0.x
+
+ _, _, plot_object = plot(v_ode_3d_2, u_ode_3d_2, semi_3d_2;
+ system_colors=[:blue, :orange],
+ marker_size_scales=[0.8, 0.5])
+ @test length(plot_object.plots) == 1
+ meshscatter = only(plot_object.plots)
+ @test length(meshscatter[1][]) ==
+ nparticles(fluid_system_3d) + nparticles(fluid_system_3d_2)
+ @test length(meshscatter.color[]) == length(meshscatter[1][])
+ @test length(meshscatter.markersize[]) == length(meshscatter[1][])
+end