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()`). - - - -
-
2D Dam Break
+
+
2D Dam Break
-
Moving Wall
+
+
Moving Wall
-
Oscillating Beam
+
+
Oscillating Beam
-
Dam Break with Elastic Plate
+
+
Dam Break with Elastic Plate
diff --git a/docs/literate/src/tut_2d_geometry.jl b/docs/literate/src/tut_2d_geometry.jl new file mode 100644 index 0000000000..8b7add85de --- /dev/null +++ b/docs/literate/src/tut_2d_geometry.jl @@ -0,0 +1,150 @@ +# # [Setting up a 2D simulation from geometry files](@id tut_2d_geometry) + +# In this tutorial, we build two 2D setups from geometry files: +# 1. a curved pipe, where one geometry file defines the outer wall and another +# defines the channel cut out of it, +# 2. a dam-break basin with a coastline profile, where one geometry file defines +# the coastline wall and the seawall on the right. +# +# We use 2D geometry formats such as `.asc` or `.dxf` for 2D setups. +# STL files describe surfaces and are therefore better suited to 3D setups. + +# First, we import TrixiParticles.jl together with +# `OrdinaryDiffEqLowStorageRK` of +# [OrdinaryDiffEq.jl](https://github.com/SciML/OrdinaryDiffEq.jl) +# and [Plots.jl](https://docs.juliaplots.org/stable/). +using TrixiParticles +using OrdinaryDiffEqLowStorageRK +using Plots + +# ## Resolution + +# We use the same particle spacing for the fluid and for the wall geometries. +particle_spacing = 0.03 +fluid_density = 1000.0 +gravity = 9.81 +sound_speed = 10.0 +state_equation = StateEquationCole(; sound_speed, reference_density=fluid_density, + exponent=7) +nothing # hide + +# ## Loading 2D geometry files + +# The following helper loads a closed 2D geometry file and samples particles in its interior: +# 1. load the polygon with [`load_geometry`](@ref), +# 2. fill the polygon with [`ComplexShape`](@ref). +# +# This creates a filled 2D solid region rather than particles only along the polygon edges. +function solid_from_geometry_file(file; particle_spacing, density) + geometry = load_geometry(file) + solid = ComplexShape(geometry; particle_spacing, density, + grid_offset=0.5particle_spacing) + + return (; geometry, solid) +end + +# ## A curved pipe from two filled geometries + +# The pipe wall is an L-shaped solid region with a channel cut out of it: +# 1. one geometry file describes the outer pipe envelope, +# 2. one geometry file describes the empty channel, +# 3. `setdiff` subtracts the channel from the solid envelope. +pipe_outer_file = pkgdir(TrixiParticles, "examples", "preprocessing", "data", + "curved_pipe_outer_2d.asc") +pipe_channel_file = pkgdir(TrixiParticles, "examples", "preprocessing", "data", + "curved_pipe_channel_2d.asc") + +pipe_outer = solid_from_geometry_file(pipe_outer_file; particle_spacing, + density=fluid_density) +pipe_channel = load_geometry(pipe_channel_file) + +pipe_setup = (; wall=setdiff(pipe_outer.solid, pipe_channel), + outer_geometry=pipe_outer.geometry, + channel_geometry=pipe_channel) + +# ## A dam-break basin with a coastline profile + +# In the second setup, one 2D geometry file defines the coastline wall: +# the beach profile, a finite wall thickness below it, and the seawall on the right. +coast_file = pkgdir(TrixiParticles, "examples", "preprocessing", "data", + "coastline_profile_2d.asc") +coast = solid_from_geometry_file(coast_file; particle_spacing, density=fluid_density) + +# The geometry file defines the coastline bed and right wall as a solid region. +# We add the left wall as a rectangular particle block and place a rectangular +# dam-break water column beside it. +left_wall = RectangularShape(particle_spacing, (5, 50), (0.0, -0.12), + density=fluid_density) +reservoir = RectangularShape(particle_spacing, (28, 42), (0.15, 0.03), + acceleration=(0.0, -gravity), + state_equation=state_equation) +coast_setup = (; geometry=coast.geometry, + wall=union(coast.solid, left_wall), + fluid=setdiff(reservoir, coast.geometry)) + +p_pipe = plot(pipe_setup.wall, label="wall", title="Curved pipe", + markerstrokewidth=0, markersize=4) +plot!(p_pipe, showaxis=false, aspect_ratio=:equal, + xlims=(-0.03, 1.23), ylims=(-0.03, 1.23)) + +p_coast = plot(coast_setup.fluid, coast_setup.wall, + labels=["fluid" "wall"], title="Coastline dam break", + markerstrokewidth=0, markersize=3) +plot!(p_coast, showaxis=false, aspect_ratio=:equal, + xlims=(0.0, 2.75), ylims=(-0.15, 1.35)) + +plot(p_pipe, p_coast, layout=(1, 2), size=(900, 360)) +savefig("tut_2d_geometry_plot.png"); # hide +# ![2D geometry based initial conditions](tut_2d_geometry_plot.png) + +# ## Building the simulation systems + +# We continue with the coastline setup. The remaining steps are the same as for +# other 2D simulations. +setup = coast_setup +tspan = (0.0, 0.03) +nothing # hide + +# We define the state equation, smoothing kernel, and viscosity for the +# weakly compressible SPH simulation. +smoothing_length = 1.2 * particle_spacing +smoothing_kernel = SchoenbergCubicSplineKernel{2}() +viscosity = ArtificialViscosityMonaghan(alpha=0.02, beta=0.0) + +fluid_density_calculator = ContinuityDensity() +density_diffusion = DensityDiffusionMolteniColagrossi(delta=0.1) + +fluid_system = WeaklyCompressibleSPHSystem(setup.fluid; + density_calculator=fluid_density_calculator, + state_equation, smoothing_kernel, + smoothing_length, viscosity=viscosity, + density_diffusion=density_diffusion, + acceleration=(0.0, -gravity)) +nothing # hide + +# For the wall, we reuse the combined solid wall particles created above. The high-level +# constructor obtains the smoothing kernel, smoothing length, and state equation from the fluid. +boundary_model = BoundaryModelDummyParticles(setup.wall; fluid_system) +boundary_system = WallBoundarySystem(setup.wall, boundary_model) +nothing # hide + +# ## Semidiscretization + +# We construct the [`Semidiscretization`](@ref TrixiParticles.Semidiscretization) +# from the fluid and boundary systems. +semi = Semidiscretization(fluid_system, boundary_system) +ode = semidiscretize(semi, tspan) +nothing # hide + +# ## Time integration + +# We can now solve the problem. An [`InfoCallback`](@ref) prints progress during +# the simulation. +callbacks = CallbackSet(InfoCallback(interval=10)) +nothing # hide + +sol = solve(ode, RDPK3SpFSAL35(), save_everystep=false, callback=callbacks) #!md + +# For more accurate body-fitted particles around sharper features, you can also +# apply the [particle packing workflow](@ref tut_packing) to the 2D geometry files +# before starting the simulation. diff --git a/docs/literate/src/tut_packing.jl b/docs/literate/src/tut_packing.jl index d5b9610283..9ae98729d2 100644 --- a/docs/literate/src/tut_packing.jl +++ b/docs/literate/src/tut_packing.jl @@ -75,8 +75,8 @@ plot!(right_margin=5Plots.mm) #hide # ## Creating an initial configuration of boundary particles # To create the initial configuration of the boundary particles, -# we use the sampled points of the SDF whose signed distance lies between 0 -# and `boundary_thickness`. +# we use the sampled points of the SDF whose signed distance lies between the +# geometry offset implied by `place_on_shell` and `boundary_thickness`. # Here, we need to specify the `density` of the boundary particles. # As an example, we choose `1.0` for all particles. # This gives us an [`InitialCondition`](@ref InitialCondition) for the boundary particles. @@ -125,7 +125,8 @@ plot!(geometry, linestyle=:dash, label=nothing, showaxis=false, color=:black, # ## Particle packing # In the following, we will essentially follow the same steps described in the fluid tutorials. -# That means we will generate systems that are then passed to the [`Semidiscretization`](@ref). +# That means we will generate systems that are then passed to the +# [`Semidiscretization`](@ref TrixiParticles.Semidiscretization). # The difference from a typical physical simulation is that we use [`ParticlePackingSystem`](@ref), # which does not represent any physical law. Instead, we only use the simulation framework to time-integrate # the packing process. @@ -211,7 +212,7 @@ plot!(geometry, seriestype=:path, color=:black, label=nothing, linewidth=2) boundary_system = ParticlePackingSystem(boundary_sampled; is_boundary=true, smoothing_kernel, smoothing_length, boundary_compress_factor=0.7, signed_distance_field, - background_pressure) + boundary_thickness, background_pressure) # We can now couple the boundary system with the interior system: semi = Semidiscretization(packing_system, boundary_system) @@ -251,10 +252,12 @@ fixed_system = ParticlePackingSystem(packed_ic; smoothing_kernel, smoothing_leng # Now we define a rectangular domain that we want to pack. # In practice, you could create any `InitialCondition` that encloses your complex geometry. -tank_domain = RectangularTank(particle_spacing, (4, 4), (0, 0), min_coordinates=(-1, -2), - density) +domain_size = (4, 4) +n_particles_per_dimension = round.(Int, domain_size ./ particle_spacing) +tank_domain = RectangularShape(particle_spacing, n_particles_per_dimension, (-1, -2); + density) -sampled_outer_domain = setdiff(tank_domain.fluid, packed_ic) +sampled_outer_domain = setdiff(tank_domain, packed_ic) # If we plot these two `InitialCondition`s, we can see # that the geometry interface is not properly represented yet. diff --git a/docs/literate/src/tut_rigid_body_fsi.jl b/docs/literate/src/tut_rigid_body_fsi.jl index e87a17e88e..7a996be794 100644 --- a/docs/literate/src/tut_rigid_body_fsi.jl +++ b/docs/literate/src/tut_rigid_body_fsi.jl @@ -165,12 +165,8 @@ nothing # hide # See [the docs on dummy particles](@ref boundary_models) for a definition for these terms. boundary_density_calculator = AdamiPressureExtrapolation() -tank_boundary_model = BoundaryModelDummyParticles(tank.boundary.density, - tank.boundary.mass, - boundary_density_calculator, - fluid_smoothing_kernel, - fluid_smoothing_length; - state_equation) +tank_boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system, + boundary_density_calculator) boundary_system = WallBoundarySystem(tank.boundary, tank_boundary_model) nothing # hide @@ -228,10 +224,15 @@ nothing # hide # ## Step 3: With contact model # Finally, we add a `contact_model` to handle collisions between rigid bodies and between -# rigid bodies and the tank. +# rigid bodies and the tank. Here we also enable frictional contact, so we need +# `UpdateCallback(interval=1)` to update tangential contact history after every accepted step. contact_model = RigidContactModel(; normal_stiffness=2.0e5, normal_damping=150.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) nothing # hide @@ -250,15 +251,15 @@ nothing # hide info_callback = InfoCallback(interval=100) # hide saving_callback_step3 = SolutionSavingCallback(dt=0.02, prefix="step3") # hide -callbacks_step3 = CallbackSet(info_callback, saving_callback_step3) # hide +callbacks_step3 = CallbackSet(info_callback, saving_callback_step3, UpdateCallback()) # hide nothing # hide # ```julia # sol_step3 = solve(ode_step3, RDPK3SpFSAL49(), save_everystep=false, -# callback=callbacks, abstol=1e-6, reltol=1e-4, dtmax=2e-3) +# callback=callbacks_step3, abstol=1e-6, reltol=1e-4, dtmax=2e-3) # ``` sol_step3 = solve(ode_step3, RDPK3SpFSAL49(), abstol=1e-6, reltol=1e-4, dtmax=2e-3, # hide - save_everystep=false) # hide + save_everystep=false, callback=callbacks_step3) # hide nothing # hide # The plot now shows the full simulation. The squares collide with the tank bottom and each other. @@ -313,15 +314,15 @@ nothing # hide info_callback = InfoCallback(interval=100) # hide saving_callback_step4 = SolutionSavingCallback(dt=0.02, prefix="step4") # hide -callbacks_step4 = CallbackSet(info_callback, saving_callback_step4) # hide +callbacks_step4 = CallbackSet(info_callback, saving_callback_step4, UpdateCallback()) # hide nothing # hide # ```julia # sol_step4 = solve(ode_step4, RDPK3SpFSAL49(), save_everystep=false, -# callback=callbacks, abstol=1e-6, reltol=1e-4, dtmax=2e-3) +# callback=callbacks_step4, abstol=1e-6, reltol=1e-4, dtmax=2e-3) # ``` sol_step4 = solve(ode_step4, RDPK3SpFSAL49(), abstol=1e-6, reltol=1e-4, dtmax=2e-3, # hide - save_everystep=false) # hide + save_everystep=false, callback=callbacks_step4) # hide nothing # hide # And here is the final plot with circles instead of squares. @@ -363,9 +364,10 @@ semi_next = Semidiscretization(fluid_system, boundary_system, small_sphere_systems...) ode_step_next = semidiscretize(semi_next, tspan) #hide +callbacks_step_next = CallbackSet(UpdateCallback()) # hide sol_step_next = solve(ode_step_next, RDPK3SpFSAL49(), # hide abstol=1e-6, reltol=1e-4, dtmax=2e-3, # hide - save_everystep=false) # hide + save_everystep=false, callback=callbacks_step_next) # hide nothing # hide plot(sol_step_next, legend=nothing) #hide @@ -417,8 +419,9 @@ hexagon_system = RigidBodySystem(hexagon_shape; boundary_model=hexagon_boundary_ semi_hexagon = Semidiscretization(fluid_system, boundary_system, hexagon_system) ode_step_hex = semidiscretize(semi_hexagon, (0.0, 0.4)) +callbacks_step_hex = CallbackSet(UpdateCallback()) # hide sol_step_hex = solve(ode_step_hex, RDPK3SpFSAL49(), abstol=1e-6, reltol=1e-5, dtmax=1e-3, - save_everystep=false) + save_everystep=false, callback=callbacks_step_hex) plot(sol_step_hex, legend=nothing) #hide plot!(dpi=200) # hide savefig("tut_rigid_body_fsi_hex.png"); # hide diff --git a/docs/literate/src/tut_setup.jl b/docs/literate/src/tut_setup.jl index b546c5eb89..2372e45b04 100644 --- a/docs/literate/src/tut_setup.jl +++ b/docs/literate/src/tut_setup.jl @@ -131,20 +131,18 @@ nothing # hide # To model the boundary, we use particle-based boundary conditions, in which particles # are sampled in the boundary that interact with the fluid particles to avoid penetration. -# In order to define a boundary system, we first have to choose a boundary model, -# which defines how the fluid interacts with boundary particles. -# We will use the [`BoundaryModelDummyParticles`](@ref) with [`AdamiPressureExtrapolation`](@ref). -# See [here](@ref boundary_models) for a comprehensive overview over boundary models. -boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass, - AdamiPressureExtrapolation(), - smoothing_kernel, smoothing_length; - state_equation) +# Here, we explicitly choose the dummy-particle boundary model and use its high-level +# builder to infer kernel and equation-of-state-related settings from the adjacent +# fluid system. See [here](@ref boundary_models) for a comprehensive overview over +# boundary models. +boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system) boundary_system = WallBoundarySystem(tank.boundary, boundary_model) nothing # hide # ## [Semidiscretization](@id tut_setup_semi) -# The key component of every simulation is the [`Semidiscretization`](@ref), +# The key component of every simulation is the +# [`Semidiscretization`](@ref TrixiParticles.Semidiscretization), # which couples all systems of the simulation. # All simulation methods in TrixiParticles.jl are semidiscretizations, which discretize # the equations in space to provide an ordinary differential equation that still diff --git a/docs/make.jl b/docs/make.jl index ee7d9f9c8e..237e4978ba 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -42,6 +42,8 @@ Literate.markdown(joinpath("docs", "literate", "src", "tut_rigid_body_fsi.jl"), joinpath("docs", "src", "tutorials")) Literate.markdown(joinpath("docs", "literate", "src", "tut_packing.jl"), joinpath("docs", "src", "tutorials")) +Literate.markdown(joinpath("docs", "literate", "src", "tut_2d_geometry.jl"), + joinpath("docs", "src", "tutorials")) copy_file("AUTHORS.md", "in the [LICENSE.md](LICENSE.md) file" => "under [License](@ref)") @@ -94,6 +96,8 @@ makedocs(sitename="TrixiParticles.jl", "tut_rigid_body_fsi.md") ], "Preprocessing" => [ + "Setting up a 2D simulation from geometry files" => joinpath("tutorials", + "tut_2d_geometry.md"), "Particle packing tutorial" => joinpath("tutorials", "tut_packing.md") ] diff --git a/docs/src/callbacks.md b/docs/src/callbacks.md index e356917226..75b6f1325f 100644 --- a/docs/src/callbacks.md +++ b/docs/src/callbacks.md @@ -1,5 +1,19 @@ # Callbacks +[`UpdateCallback`](@ref) is required for systems that keep mutable state between time +steps. In the current rigid-contact implementation, this applies when a +[`RigidContactModel`](@ref) uses tangential spring history. Rigid contact requires +`UpdateCallback(interval=1)` so history is advanced once after every accepted step. +`UpdateCallback(interval=N)` with `N > 1` and `UpdateCallback(dt=...)` are rejected for +these systems. + +Contact history is updated from accepted endpoint states only. The initialization call uses +zero elapsed time, while later calls use `integrator.t - integrator.tprev` rather than the +next proposed `integrator.dt`. If history changes, the callback invalidates any cached FSAL +derivative so the next step evaluates contact forces from the new state. Exactly one +`UpdateCallback` must own this update; multiple update callbacks are rejected to prevent +advancing the same tangential displacement more than once per step. + ```@autodocs Modules = [TrixiParticles] Pages = map(file -> joinpath("callbacks", file), readdir(joinpath("..", "src", "callbacks"))) diff --git a/docs/src/development.md b/docs/src/development.md index 2e1e81fefe..ba237d4170 100644 --- a/docs/src/development.md +++ b/docs/src/development.md @@ -1,7 +1,6 @@ # [Development](@id development) - -## Preview of the documentation +## Preview of the Documentation To build the documentation, first instantiate the `docs` environment by running the following command from the TrixiParticles.jl root directory: @@ -23,30 +22,30 @@ python3 -m http.server -d docs/build ``` and open `localhost:8000` in your web browser. -## Release management +## Release Management To create a new release for TrixiParticles.jl, perform the following steps: -1) Make sure that all PRs and changes that you want to go into the release are merged to +1. Make sure that all PRs and changes that you want to go into the release are merged to `main` and that the latest commit on `main` has passed all CI tests. -2) Determine the currently released version of TrixiParticles.jl, e.g., on the +2. Determine the currently released version of TrixiParticles.jl, e.g., on the [release page](https://github.com/trixi-framework/TrixiParticles.jl/releases). For this manual, we will assume that the latest release was `v0.2.3`. -3) Decide on the next version number. We follow [semantic versioning](https://semver.org/), +3. Decide on the next version number. We follow [semantic versioning](https://semver.org/), thus each version is of the form `vX.Y.Z` where `X` is the major version, `Y` the minor version, and `Z` the patch version. In this manual, we assume that the major version is always `0`, thus the decision process on the new version is as follows: - * If the new release contains *breaking changes* (i.e., user code might not work as + - If the new release contains *breaking changes* (i.e., user code might not work as before without modifications), increase the *minor* version by one and set the *patch* version to zero. In our example, the new version should thus be `v0.3.0`. - * If the new release only contains minor modifications and/or bug fixes, the *minor* + - If the new release only contains minor modifications and/or bug fixes, the *minor* version is kept as-is and the *patch* version is increased by one. In our example, the new version should thus be `v0.2.4`. -4) Review and update the `NEWS.md` file to ensure all relevant changes, features, and bugfixes +4. Review and update the `NEWS.md` file to ensure all relevant changes, features, and bugfixes are documented for this release under the appropriate version header. -5) Edit the `version` string in the +5. Edit the `version` string in the [`Project.toml`](https://github.com/trixi-framework/TrixiParticles.jl/blob/main/Project.toml) and set it to the new version. Push/merge this change to `main`. -6) Go to GitHub and add a comment to the commit that you would like to become the new +6. Go to GitHub and add a comment to the commit that you would like to become the new release (typically this will be the commit where you just updated the version). You can comment on a commit by going to the [commit overview](https://github.com/trixi-framework/TrixiParticles.jl/commits/main/) and clicking @@ -54,14 +53,14 @@ To create a new release for TrixiParticles.jl, perform the following steps: ``` @JuliaRegistrator register ``` -7) Wait for the magic to happen! Specifically, JuliaRegistrator will create a new PR to the +7. Wait for the magic to happen. Specifically, JuliaRegistrator will create a new PR to the Julia registry with the new release information. After a grace period of ~15 minutes, this PR will be merged automatically. A short while after, [TagBot](https://github.com/trixi-framework/TrixiParticles.jl/blob/main/.github/workflows/TagBot.yml) will create a new release of TrixiParticles.jl in our GitHub repository. -8) Once the new release has been created, the new version can be obtained through the Julia +8. Once the new release has been created, the new version can be obtained through the Julia package manager as usual. -9) To make sure people do not mistake the latest state of `main` as the latest release, we +9. To make sure people do not mistake the latest state of `main` as the latest release, we set the version in the `Project.toml` to a *development* version. The development version should be the latest released version, with the patch version incremented by one, and the `-dev` suffix added. For example, if you just released `v0.3.0`, the new development diff --git a/docs/src/general/neighborhood_search.md b/docs/src/general/neighborhood_search.md index 15eda59ac0..b3b35a0294 100644 --- a/docs/src/general/neighborhood_search.md +++ b/docs/src/general/neighborhood_search.md @@ -8,7 +8,8 @@ different implementations. !!! note "Usage" To run a simulation with a neighborhood search implementation, pass a neighborhood - search template to the constructor of the [`Semidiscretization`](@ref). + search template to the constructor of the + [`Semidiscretization`](@ref TrixiParticles.Semidiscretization). A template is just an empty neighborhood search with search radius `0.0`. See [`copy_neighborhood_search`](@ref) and the examples below for more details. ```jldoctest semi_example; output=false, setup = :(using TrixiParticles; trixi_include(@__MODULE__, joinpath(examples_dir(), "fluid", "hydrostatic_water_column_2d.jl"), sol=nothing); system1 = fluid_system; system2 = boundary_system) diff --git a/docs/src/general/semidiscretization.md b/docs/src/general/semidiscretization.md index 93334679ea..232d253260 100644 --- a/docs/src/general/semidiscretization.md +++ b/docs/src/general/semidiscretization.md @@ -2,5 +2,7 @@ ```@autodocs Modules = [TrixiParticles] -Pages = [joinpath("general", "semidiscretization.jl")] +Pages = [joinpath("general", "semidiscretization.jl"), + joinpath("general", "ode_rhs.jl"), + joinpath("general", "source_terms.jl")] ``` diff --git a/docs/src/getting_started.md b/docs/src/getting_started.md index ce41637552..fae83fd48b 100644 --- a/docs/src/getting_started.md +++ b/docs/src/getting_started.md @@ -1,4 +1,5 @@ -# [Getting started](@id getting_started) +# [Getting Started](@id getting_started) + If you have not installed TrixiParticles.jl yet, please follow the instructions in [Installation](@ref installation). This page provides a short introduction. For a broader introduction, take a look at our [Tutorials](tutorial.md). @@ -28,14 +29,14 @@ This will open a new window with a 2D visualization of the final solution: For more information about visualization, see [Visualization](visualization.md). -## Running other Examples +## Running Other Examples You can find more predefined examples under [Examples](examples.md). Run them from the Julia REPL by replacing `subfolder` and `example_name`: ```julia julia> trixi_include(joinpath(examples_dir(), "subfolder", "example_name.jl")) ``` -## Modifying an example +## Modifying an Example You can pass keyword arguments to the function `trixi_include` to overwrite assignments in the file. With `trixi_include`, we can overwrite variables defined in the example file to run a different simulation without modifying the file itself. diff --git a/docs/src/gpu.md b/docs/src/gpu.md index a7bdd6c3af..4d23e5c206 100644 --- a/docs/src/gpu.md +++ b/docs/src/gpu.md @@ -23,7 +23,7 @@ FullGridCellList{PointNeighbors.DynamicVectorOfVectors{...}(...) ``` We then need to pass this cell list to the neighborhood search and the neighborhood search -to the [`Semidiscretization`](@ref). +to the [`Semidiscretization`](@ref TrixiParticles.Semidiscretization). ```jldoctest gpu; output=false semi = Semidiscretization(fluid_system, boundary_system, neighborhood_search=GridNeighborhoodSearch{2}(; cell_list)) @@ -67,7 +67,7 @@ All data is transferred to the GPU during initialization and all loops over part and their neighbors are executed on the GPU as kernels generated by KernelAbstractions.jl. Data is only copied to the CPU for saving VTK files via the [`SolutionSavingCallback`](@ref). -## Run an existing example file on the GPU +## Run an Existing Example File on the GPU The example file `examples/fluid/dam_break_2d_gpu.jl` demonstrates how to run an existing example file on a GPU. @@ -102,7 +102,7 @@ trixi_include_changeprecision(Float32, coordinates_eltype=Float32) ``` -## [Single precision simulations](@id single_precision) +## [Single Precision Simulations](@id single_precision) All GPU-supported features can also be used with single precision, which is significantly faster on most GPUs and required for many Apple GPUs. diff --git a/docs/src/index.md b/docs/src/index.md index 1b16a8606f..8b0e9a699f 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -28,18 +28,22 @@ Its main features include:
-
2D Dam Break
+
+
2D Dam Break
-
Moving Wall
+
+
Moving Wall
-
Oscillating Beam
+
+
Oscillating Beam
-
Dam Break with Elastic Plate
+
+
Dam Break with Elastic Plate
diff --git a/docs/src/install.md b/docs/src/install.md index 533fd4fc5f..be5945afe4 100644 --- a/docs/src/install.md +++ b/docs/src/install.md @@ -1,11 +1,11 @@ # [Installation](@id installation) -## Setting up Julia +## Setting Up Julia If you have not installed Julia yet, please [follow the instructions on the official website](https://julialang.org/downloads/). TrixiParticles.jl works with Julia v1.10 and newer. We recommend using the latest stable release of Julia. -## For users +## For Users TrixiParticles.jl is a registered Julia package. You can install TrixiParticles.jl, time integration sub-packages of [OrdinaryDiffEq.jl](https://github.com/SciML/OrdinaryDiffEq.jl) @@ -18,7 +18,7 @@ julia> using Pkg julia> Pkg.add(["TrixiParticles", "OrdinaryDiffEqLowStorageRK", "OrdinaryDiffEqSymplecticRK", "Plots"]) ``` -## [For developers](@id for-developers) +## [For Developers](@id for-developers) If you plan on editing TrixiParticles.jl itself, you can download TrixiParticles.jl to a local folder and use the code from the cloned directory: ```bash @@ -41,19 +41,19 @@ related packages (e.g., sub-packages of OrdinaryDiffEq.jl, see above) to the pro in the `run` folder and always have a reproducible environment at hand to share with others. -## Optional software/packages +## Optional Software/Packages - [OrdinaryDiffEq.jl](https://github.com/SciML/OrdinaryDiffEq.jl) -- A Julia package of ordinary differential equation solvers; examples in TrixiParticles.jl use sub-packages such as `OrdinaryDiffEqLowStorageRK` and `OrdinaryDiffEqSymplecticRK` -- [Plots.jl](https://github.com/JuliaPlots/Plots.jl) -- Julia Plotting library that is used in some examples +- [Plots.jl](https://github.com/JuliaPlots/Plots.jl) -- Julia plotting library used in some examples - [PythonPlot.jl](https://github.com/JuliaPy/PythonPlot.jl) -- Plotting library that can be used instead of Plots.jl - [ParaView](https://www.paraview.org/) -- Visualization software for simulation results -## [Common issues](@id installation-issues) +## [Common Issues](@id installation-issues) If you followed the [installation instructions for developers](@ref for-developers) and run into package issues after pulling the latest version of TrixiParticles.jl, start Julia with the project in the `run` folder, ```bash - julia --project=run +julia --project=run ``` then update packages, resolve dependency conflicts, and install new dependencies: ```julia diff --git a/docs/src/preprocessing/preprocessing.md b/docs/src/preprocessing/preprocessing.md index d65fa462b2..beb56b3211 100644 --- a/docs/src/preprocessing/preprocessing.md +++ b/docs/src/preprocessing/preprocessing.md @@ -31,9 +31,9 @@ triangle = [125.0 375.0 250.0 125.0; 175.0 175.0 350.0 175.0] # Delete all edges but one -edge1 = deleteat!(TrixiParticles.Polygon(triangle), [2, 3]) -edge2 = deleteat!(TrixiParticles.Polygon(triangle), [1, 3]) -edge3 = deleteat!(TrixiParticles.Polygon(triangle), [1, 2]) +edge1 = delete_faces(TrixiParticles.Polygon(triangle), [2, 3]) +edge2 = delete_faces(TrixiParticles.Polygon(triangle), [1, 3]) +edge3 = delete_faces(TrixiParticles.Polygon(triangle), [1, 2]) algorithm = WindingNumberJacobson() @@ -269,6 +269,15 @@ For example: 0.0 1.0 ``` It is the user’s responsibility to ensure the points are ordered correctly. +For 2D `.asc` and `.dxf` files, `load_geometry` appends the first point by default +when it is not already repeated. This is only a convenience for complete, ordered +boundaries that omit the final duplicate point; it does not repair missing +segments, gaps, self-intersections, or incorrectly ordered points. Use +`load_geometry(file; close_curve=false)` for intentional open curves. Operations +that sample or classify a region, such as [`ComplexShape`](@ref), `intersect`, +and `setdiff`, require closed geometries. Boundary packing with +[`SignedDistanceField`](@ref) also requires a closed geometry, since it needs a +well-defined outside region. This format is easy to generate and inspect manually. ## DXF Format (.dxf) – recommended @@ -308,6 +317,11 @@ Modules = [TrixiParticles] Pages = [joinpath("preprocessing", "geometries", "triangle_mesh.jl")] ``` +```@docs +TrixiParticles.is_closed_geometry +delete_faces +``` + # [Particle Packing](@id particle_packing) To obtain a body-fitted and isotropic particle distribution, an initial configuration (see [Sampling of Geometries](@ref sampling_of_geometries)) is first generated. This configuration is then packed using a [`ParticlePackingSystem`](@ref) following the steps introduced in [Neher2026](@cite). @@ -325,7 +339,7 @@ The second step involves generating the SDF (see [`SignedDistanceField`](@ref)), The SDF is illustrated in Fig. 2, where the distances to the surface of the geometry are visualized as a color map. As shown, the SDF is computed only within a narrow band around the geometry’s surface, enabling a face-based neighborhood search (NHS) to be used exclusively during this step. In the third step, the initial configuration of the boundary particles is generated (orange particles in Fig. 3). -Boundary particles are created by copying the positions of SDF points located outside the geometry but within a predefined boundary thickness (see [`sample_boundary`](@ref)). +Boundary particles are created by copying the positions of SDF points located outside the geometry, starting at the offset implied by `place_on_shell` and ending at a predefined boundary thickness (see [`sample_boundary`](@ref)). In the fourth step, the initial configuration of the interior particles (green particles in Fig. 4) is generated using the hierarchical winding number approach (see [Hierarchical Winding](@ref hierarchical_winding)). After steps **1** through **4**, the initial configuration of both interior and boundary particles is obtained, as illustrated in Fig. 5. The interface of the geometry surface is not well resolved with the initial particle configuration. diff --git a/docs/src/systems/fluid.md b/docs/src/systems/fluid.md index 80d549fd86..935dca05f9 100644 --- a/docs/src/systems/fluid.md +++ b/docs/src/systems/fluid.md @@ -220,9 +220,9 @@ Pages = [joinpath("general", "corrections.jl")] ### Overview of surface normal calculation in SPH -Surface normals are essential for modeling surface tension as they provide the directionality -of forces acting at the fluid interface. They are calculated based on the particle properties and -their spatial distribution. +Surface normals provide the directionality of forces acting at the fluid interface. They are +used by the full Akinci model and both Morris models, but not by the cohesion-only Akinci model. +They are calculated based on the particle properties and their spatial distribution. #### Color field and gradient-based surface normals @@ -296,6 +296,17 @@ In the following table some values are shown for reference. The values marked wi | **Water** | 0.07288 [Lange](@cite Lange2005) | | **Mercury** | 0.486502 [Lange](@cite Lange2005) | +### Model configuration + +All surface tension coefficients must be finite and non-negative. A zero coefficient disables +the fluid-fluid surface force. Wall adhesion is controlled independently by the boundary's +`adhesion_coefficient`. + +`CohesionForceAkinci` only evaluates the pairwise cohesion and optional wall-adhesion forces. +It does not require surface normals or `reference_particle_spacing`. The full +`SurfaceTensionAkinci` model and both Morris models require a surface-normal method. When one +of these models is selected without an explicit method, `ColorfieldSurfaceNormal()` is used. + ### [Akinci-based intra-particle force surface tension and wall adhesion model](@id akinci_ipf) The [Akinci](@cite Akinci2013) model divides surface tension into distinct force components, @@ -359,6 +370,10 @@ A(r) = \frac{0.007}{h_c^{3.25}} \end{cases} ``` +The published adhesion kernel uses a three-dimensional normalization. In two-dimensional +simulations, `adhesion_coefficient` is therefore an empirical numerical parameter and may need +to be adjusted when changing the particle spacing or smoothing length. + --- ### [Morris surface tension model](@id morris_csf) diff --git a/docs/src/systems/implicit_incompressible_sph.md b/docs/src/systems/implicit_incompressible_sph.md index 3887d56611..b7c26e04f3 100644 --- a/docs/src/systems/implicit_incompressible_sph.md +++ b/docs/src/systems/implicit_incompressible_sph.md @@ -162,7 +162,7 @@ a_{ii} = \sum_j m_j ( d_{ii} - d_{ji}) \cdot \nabla W_{ij}. ``` The remaining part of the equation represents the influence of the other pressure values ``p_j``. -​Hence, the final relaxed Jacobi iteration takes the form: +Hence, the final relaxed Jacobi iteration takes the form: ```math p_i^{l+1} = (1 - \omega) p_i^{l} + \omega \frac{1}{a_{ii}} \left( \rho_0 -\rho_i^{\text{adv}} - \sum_j m_j \left( \sum_k d_{ik} p_k^l - d_{jj} p_j^l - \sum_{k \neq i} d_{jk} p_k^l \right) \cdot \nabla W_{ij} \right). diff --git a/docs/src/systems/rigid_body.md b/docs/src/systems/rigid_body.md index 27928499ce..c730af5464 100644 --- a/docs/src/systems/rigid_body.md +++ b/docs/src/systems/rigid_body.md @@ -17,21 +17,108 @@ Rigid contact is configured through the contact model. This is separate from the boundary model used for fluid-structure interaction; see [Boundary Models](@ref boundary_models) for that part of the rigid-body setup. -`RigidContactModel` currently defines a normal spring-dashpot contact law with the -parameters `normal_stiffness`, `normal_damping`, and `contact_distance`. +`RigidContactModel` defines the rigid-contact law shared by rigid-wall and rigid-rigid +interaction. The always-active parameters are `normal_stiffness`, `normal_damping`, and +`contact_distance`. -The current implementation uses the same model for rigid-wall and rigid-rigid contact: +Rigid-wall and rigid-rigid contact also support tangential friction with +the parameters `static_friction_coefficient`, `kinetic_friction_coefficient`, +`tangential_stiffness`, `tangential_damping`, `stick_velocity_tolerance`, and +`penetration_slop`. When the tangential spring history is active, this requires +`UpdateCallback(interval=1)` so the tangential displacement cache is updated after every +accepted time step. Sparse and time-periodic update callbacks are not supported for contact +history. + +A frictional setup with a tangential spring must install the update callback alongside the +other callbacks used by the simulation: + +```julia +contact_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) +rigid_system = RigidBodySystem(initial_condition; contact_model) +update_callback = UpdateCallback(interval=1) +``` + +### Force Law + +Let ``\delta = d_c - r - \delta_0`` be the effective penetration after subtracting +`penetration_slop`, and let ``v_n`` be relative velocity along the outward contact normal. +The non-attractive normal-force magnitude is + +```math +F_n = \max(k_n \delta - c_n v_n, 0). +``` + +With the convention used here, approaching particles have ``v_n < 0``, so normal damping +increases the repulsive force during approach. No contact force is applied when +``\delta \le 0``. + +For tangential displacement history ``\boldsymbol{\xi}`` and slip velocity +``\boldsymbol{v}_t``, the trial force is + +```math +\boldsymbol{F}_t^{\mathrm{trial}} = +-k_t \boldsymbol{\xi} - c_t \boldsymbol{v}_t. +``` + +The contact sticks while +``\lVert\boldsymbol{F}_t^{\mathrm{trial}}\rVert \le \mu_s F_n``. Otherwise the model +uses kinetic friction of limiting magnitude ``\mu_k F_n`` opposite the current slip +velocity. `stick_velocity_tolerance` supplies a `tanh` regularization close to zero slip +speed. At exactly zero slip speed, the restoring direction of the trial force is retained. + +After an accepted time step of length ``\Delta t``, history is advanced and projected back +onto the current contact plane: + +```math +\boldsymbol{\xi} \leftarrow +\left(\boldsymbol{I} - \boldsymbol{n}\boldsymbol{n}^{T}\right) +\left(\boldsymbol{\xi} + \Delta t\,\boldsymbol{v}_t\right). +``` + +The stored extension is capped at the static Coulomb limit. Initialization uses +``\Delta t = 0`` so contacts are registered without adding displacement before the first +accepted step. Rejected steps and intermediate Runge-Kutta stages never advance history. + +### Contact Pairs + +The same contact model is used for both contact paths: - rigid-wall contact groups penetrating wall neighbors into a small number of contact - manifolds per rigid particle and applies one normal contact force per manifold, -- rigid-rigid contact evaluates direct pairwise normal contact forces between rigid - particles, -- and both paths are currently normal-only, i.e. there are no tangential/frictional - forces or contact-history terms yet. + manifolds per rigid particle and applies one normal-plus-tangential contact force per + manifold, +- rigid-rigid contact evaluates direct pairwise normal-plus-tangential contact forces between + rigid particles. + +For rigid-rigid contact, normal and tangential stiffness and damping are averaged between +the two models. Contact distance is the larger value, friction coefficients are the smaller +values, and the larger stick-velocity tolerance and penetration slop are used. These +symmetric rules ensure that the two ordered interaction passes produce equal-and-opposite +contact forces. + +If either rigid body has zero friction coefficients, the minimum-coefficient rule makes the +pair frictionless. A tangential spring on only one body can contribute to a pair only when +both bodies have nonzero friction coefficients. + +### Wall Manifolds Here, a contact manifold is a discrete approximation of one locally smooth contact patch. A rigid particle touching a flat wall will usually produce one manifold, while corners or edges can produce several. +Tangential history is associated with persistent contact IDs obtained by matching manifold +anchor positions and normals between accepted steps. Transient manifold array slots are not +used as physical contact identities. + +Each accepted manifold stores a weighted wall-position anchor and contact normal. On the +next accepted step, matching is restricted to the same rigid particle and wall system. A +candidate must be within one `contact_distance` and its normal must be within 60 degrees of +the previous normal. Matching is one-to-one; unmatched manifolds receive monotonically +increasing IDs. RHS stages may read this mapping but only the accepted-step callback may +change it. The number of cached rigid-wall manifolds per rigid particle is controlled by the `RigidBodySystem(...; max_manifolds=8)` keyword argument. If more wall-contact @@ -39,11 +126,27 @@ patches are detected than cached manifold slots are available, the implementatio falls back to the best-matching existing manifold for that particle. `contact_distance` defines when contact starts. If `contact_distance == 0`, the -particle spacing of the `RigidBodySystem` is used. +particle spacing of the `RigidBodySystem` is used when the contact model is adapted to +the runtime system. If no `contact_model` is specified for a rigid body, rigid-wall and rigid-rigid contact for that system are disabled. +### Lifecycle and Limitations + +Positive friction coefficients require positive tangential stiffness or damping. Contact +friction currently uses CPU-managed dictionaries for history and wall descriptors and is +therefore not supported on GPU backends. +Fresh semidiscretizations and restarts clear tangential contact history; coordinates and +velocities are restored, but static-friction memory is reinitialized. Restarting a live +system with nonempty history emits a warning before discarding that state. + +The contact contribution to automatic time-step selection includes normal and tangential +elastic and damping scales. For effective contact mass ``m``, the active scales are +``\sqrt{m/k_n}``, ``m/c_n``, ``\sqrt{m/k_t}``, and ``m/c_t``; the smallest is used before +the global CFL factor is applied. Rigid-wall contact uses the rigid particle mass. A +rigid-rigid pair uses the reduced mass formed from the lightest particle in each body. + For output and postprocessing, rigid bodies also expose the diagnostics `contact_count` and `max_contact_penetration`. They are available through rigid-body system data and VTK output. diff --git a/docs/src/systems/total_lagrangian_sph.md b/docs/src/systems/total_lagrangian_sph.md index 3b391abb37..7666fcab18 100644 --- a/docs/src/systems/total_lagrangian_sph.md +++ b/docs/src/systems/total_lagrangian_sph.md @@ -9,7 +9,7 @@ The governing equations with respect to the initial configuration are given by: \frac{\mathrm{D}\bm{v}}{\mathrm{D}t} = \frac{1}{\rho_0} \nabla_0 \cdot \bm{P} + \bm{g}, ``` where the zero subscript denotes a derivative with respect to the initial configuration -and $\bm{P}$ is the first Piola-Kirchhoff (PK1) stress tensor. +and ``\bm{P}`` is the first Piola-Kirchhoff (PK1) stress tensor. The discretized version of this equation is given by [O’Connor & Rogers (2021)](@cite OConnor2021): ```math @@ -21,17 +21,17 @@ with the correction matrix (see also [`GradientCorrection`](@ref)) ```math \bm{L}_{0a} := \left( -\sum_{b} \frac{m_{0b}}{\rho_{0b}} \nabla_{0a} W(\bm{X}_{ab}) \bm{X}_{ab}^T \right)^{-1} \in \R^{d \times d}. ``` -The subscripts $a$ and $b$ denote quantities of particle $a$ and $b$, respectively. +The subscripts ``a`` and ``b`` denote quantities of particles ``a`` and ``b``, respectively. The zero subscript on quantities denotes that the quantity is to be measured in the initial configuration. -The difference in the initial coordinates is denoted by $\bm{X}_{ab} = \bm{X}_a - \bm{X}_b$, -the difference in the current coordinates is denoted by $\bm{x}_{ab} = \bm{x}_a - \bm{x}_b$. +The difference in the initial coordinates is denoted by ``\bm{X}_{ab} = \bm{X}_a - \bm{X}_b``, +the difference in the current coordinates is denoted by ``\bm{x}_{ab} = \bm{x}_a - \bm{x}_b``. -For the computation of the PK1 stress tensor, the deformation gradient $\bm{F}$ is computed per particle as +For the computation of the PK1 stress tensor, the deformation gradient ``\bm{F}`` is computed per particle as ```math \bm{F}_a = \sum_b \frac{m_{0b}}{\rho_{0b}} \bm{x}_{ba} (\bm{L}_{0a}\nabla_{0a} W(\bm{X}_{ab}))^T \\ \qquad = -\left(\sum_b \frac{m_{0b}}{\rho_{0b}} \bm{x}_{ab} (\nabla_{0a} W(\bm{X}_{ab}))^T \right) \bm{L}_{0a}^T ``` -with $1 \leq i,j \leq d$. +with ``1 \leq i,j \leq d``. From the deformation gradient, the Green-Lagrange strain ```math \bm{E} = \frac{1}{2}(\bm{F}^T\bm{F} - \bm{I}) @@ -53,9 +53,9 @@ and ```math \lambda = \frac{E\nu}{(1 + \nu)(1 - 2\nu)} ``` -are the Lamé coefficients, where $E$ is the Young's modulus and $\nu$ is the Poisson ratio. +are the Lamé coefficients, where ``E`` is the Young's modulus and ``\nu`` is the Poisson ratio. -The term $\bm{f}_a^{PF}$ is an optional penalty force. See e.g. [`PenaltyForceGanzenmueller`](@ref). +The term ``\bm{f}_a^{PF}`` is an optional penalty force. See e.g. [`PenaltyForceGanzenmueller`](@ref). ```@autodocs Modules = [TrixiParticles] @@ -69,26 +69,26 @@ This is caused by the stiffness matrix having zero eigenvalues (so-called hourgl The name "hourglass modes" comes from the fact that elements can deform into an hourglass shape. Similar effects can occur in SPH as well. -Particles can change positions without changing the SPH approximation of the deformation gradient $\bm{F}$, +Particles can change positions without changing the SPH approximation of the deformation gradient ``\bm{F}``, thus, without causing an increase of energy. To ensure regular particle positions, we can apply similar correction forces as are used in FEM. -[Ganzenmüller (2015)](@cite Ganzenmueller2015) introduced a so-called hourglass correction force or penalty force $f^{PF}$, +[Ganzenmüller (2015)](@cite Ganzenmueller2015) introduced a so-called hourglass correction force or penalty force ``f^{PF}``, which is given by ```math \bm{f}_a^{PF} = \frac{1}{2} \alpha \sum_b \frac{m_{0a} m_{0b} W_{0ab}}{\rho_{0a}\rho_{0b} |\bm{X}_{ab}|^2} \left( E \delta_{ab}^a + E \delta_{ba}^b \right) \frac{\bm{x}_{ab}}{|\bm{x}_{ab}|} ``` -The subscripts $a$ and $b$ denote quantities of particle $a$ and $b$, respectively. +The subscripts ``a`` and ``b`` denote quantities of particles ``a`` and ``b``, respectively. The zero subscript on quantities denotes that the quantity is to be measured in the initial configuration. -The difference in the initial coordinates is denoted by $\bm{X}_{ab} = \bm{X}_a - \bm{X}_b$, -the difference in the current coordinates is denoted by $\bm{x}_{ab} = \bm{x}_a - \bm{x}_b$. -Note that [Ganzenmüller (2015)](@cite Ganzenmueller2015) has a flipped sign here because they define $\bm{x}_{ab}$ the other way around. +The difference in the initial coordinates is denoted by ``\bm{X}_{ab} = \bm{X}_a - \bm{X}_b``, +the difference in the current coordinates is denoted by ``\bm{x}_{ab} = \bm{x}_a - \bm{x}_b``. +Note that [Ganzenmüller (2015)](@cite Ganzenmueller2015) has a flipped sign here because they define ``\bm{x}_{ab}`` the other way around. This correction force is based on the potential energy density of a Hookean material. -Thus, $E$ is the Young's modulus and $\alpha$ is a dimensionless coefficient that controls +Thus, ``E`` is the Young's modulus and ``\alpha`` is a dimensionless coefficient that controls the amplitude of hourglass correction. -The separation vector $\delta_{ab}^a$ indicates the change of distance which the particle separation should attain +The separation vector ``\delta_{ab}^a`` indicates the change of distance which the particle separation should attain in order to minimize the error and is given by ```math \delta_{ab}^a = \frac{\bm{\epsilon}_{ab}^a \cdot \bm{x_{ab}}}{|\bm{x}_{ab}|}, diff --git a/docs/src/tutorial.md b/docs/src/tutorial.md index 4225faf486..6b41b5cede 100644 --- a/docs/src/tutorial.md +++ b/docs/src/tutorial.md @@ -6,7 +6,9 @@ 1. [Setting up your simulation from scratch](tutorials/tut_setup.md): learn the structure of a simulation file and run a complete WCSPH example. 2. [Modifying or extending components of TrixiParticles.jl within a simulation file](tutorials/tut_custom_kernel.md): replace selected parts of an existing setup without cloning the package. -3. [Particle packing tutorial](tutorials/tut_packing.md): build a body-fitted particle configuration for complex geometries. +3. [Setting up a 2D simulation from geometry files](tutorials/tut_2d_geometry.md): load 2D geometry files, create wall regions, and combine them with a rectangular fluid region. +4. [Particle packing tutorial](tutorials/tut_packing.md): build a body-fitted particle configuration for complex geometries. +5. [Fluid-structure interaction with rigid bodies](tutorials/tut_rigid_body_fsi.md): simulate objects moving in a fluid. ## Tutorials @@ -38,6 +40,20 @@ directly in the file you run. - Focus: `trixi_include`, custom kernels, rapid iteration - Choose this if: you want to prototype changes without cloning and modifying the package +### [Setting up a 2D simulation from geometry files](tutorials/tut_2d_geometry.md) + +```@raw html +2D pipe and coastline geometries converted to wall and fluid particles +``` + +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: + ![image](https://github.com/user-attachments/assets/383d323a-3020-4232-9dc3-682b0afe8653) 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`". + ![image](https://github.com/user-attachments/assets/194d9a09-5937-4ee4-b229-07078afe3ff0) -#### 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). + ![image](https://github.com/user-attachments/assets/10dcf7eb-5808-4d4d-9db8-4beb25b5e51a) ## 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