From ef67faae5cf194e7af2da7879773793940375887 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Mon, 9 Mar 2026 16:47:21 +0100 Subject: [PATCH 01/59] correct language and general update pass --- README.md | 35 ++++++++++++++----------- docs/src/development.md | 14 +++++----- docs/src/examples.md | 4 +-- docs/src/general/neighborhood_search.md | 9 ++++--- docs/src/getting_started.md | 22 ++++++++-------- docs/src/gpu.md | 19 +++++++------- docs/src/index.md | 30 +++++++++++---------- docs/src/install.md | 23 ++++++++-------- docs/src/overview.md | 31 +++++++++++----------- docs/src/visualization.md | 16 ++++++----- 10 files changed, 106 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 185f9317a0..27a54e37e6 100644 --- a/README.md +++ b/README.md @@ -15,30 +15,29 @@ TrixiP_logo

-**TrixiParticles.jl** is a high-performance numerical simulation framework for particle-based methods, focused on the simulation of complex multiphysics problems, and written in [Julia](https://julialang.org). +**TrixiParticles.jl** is a high-performance simulation framework for particle-based methods for complex multiphysics applications, written in [Julia](https://julialang.org). TrixiParticles.jl focuses on the following use cases: -- Accurate and efficient physics-based modelling of complex multiphysics problems. +- Accurate and efficient physics-based modeling of complex multiphysics problems. - Development of new particle-based methods and models. -- Easy setup of accessible simulations for educational purposes, including student projects, coursework, and thesis work. +- Accessible simulation setup for educational purposes, including student projects, coursework, and thesis work. -It offers intuitive configuration, robust pre- and post-processing, and vendor-agnostic GPU-support based on the Julia package [KernelAbstractions.jl](https://github.com/JuliaGPU/KernelAbstractions.jl). +It offers intuitive configuration, robust pre- and post-processing, and vendor-agnostic GPU support based on the Julia package [KernelAbstractions.jl](https://github.com/JuliaGPU/KernelAbstractions.jl). [![YouTube](https://github.com/user-attachments/assets/dc2be627-a799-4bfd-9226-2077f737c4b0)](https://www.youtube.com/watch?v=V7FWl4YumcA&t=4667s) ## Features -- Incompressible Navier-Stokes - - Methods: Weakly Compressible Smoothed Particle Hydrodynamics (WCSPH), - Entropically Damped Artificial Compressibility (EDAC), - Implicit Incompressible SPH (IISPH) +- Incompressible Navier-Stokes flows + - Methods: Weakly Compressible Smoothed Particle Hydrodynamics (WCSPH), Entropically Damped Artificial Compressibility (EDAC), + Implicit Incompressible Smoothed Particle Hydrodynamics (IISPH) - Models: Surface Tension, Open Boundaries -- Solid-body mechanics - - Methods: Total Lagrangian SPH (TLSPH), Discrete Element Method (DEM) +- Structural mechanics + - Methods: Total Lagrangian SPH (TLSPH), Discrete Element Method (DEM) - Fluid-Structure Interaction -- Particle sampling of complex geometries from `.stl` and `.asc` files. +- Particle sampling of complex geometries from `.stl`, `.asc`, and `.dxf` files. - Output formats: - VTK -- Support for GPUs by Nvidia, AMD and Apple (experimental) +- GPU support for NVIDIA, AMD, and Apple devices ## Examples We provide several example simulation setups in the `examples` folder (which can be accessed from Julia via `examples_dir()`). @@ -68,7 +67,7 @@ We provide several example simulation setups in the `examples` folder (which can ## Installation -If you have not yet installed Julia, please [follow the instructions for your +If you have not installed Julia yet, please [follow the instructions for your operating system](https://julialang.org/downloads/platform/). TrixiParticles.jl works with Julia v1.10 and newer. We recommend using the latest stable release of Julia. @@ -115,7 +114,12 @@ Then start the simulation by executing julia> trixi_include(joinpath(examples_dir(), "fluid", "hydrostatic_water_column_2d.jl")) ``` -This will open a new window with a 2D visualization of the final solution: +To visualize the result quickly, use Plots.jl: +```julia +julia> using Plots; plot(sol) +``` + +This opens a new window with a 2D visualization of the final solution: Further details can be found in the [documentation](https://trixi-framework.github.io/TrixiParticles.jl/stable). @@ -160,7 +164,7 @@ and ## Authors Erik Faulhaber (University of Cologne) and Niklas Neher (HLRS) implemented the foundations -for TrixiParticles.jl and are principal developers along with Sven Berger (hereon). +for TrixiParticles.jl and are principal developers along with Sven Berger (Hereon). The project was started by Michael Schlottke-Lakemper (University of Augsburg) and Gregor Gassner (University of Cologne), who provide scientific direction and technical advice. The full list of contributors can be found in [AUTHORS.md](AUTHORS.md). @@ -183,4 +187,3 @@ or [create an issue](https://github.com/trixi-framework/TrixiParticles.jl/issues

The project has benefited from funding from [hereon](https://www.hereon.de/), [HiRSE](https://www.helmholtz-hirse.de/), and through [ScienceServe](https://www.helmholtz.de/en/research/current-calls-for-applications/article/scienceserve-boosting-research-software-at-helmholtz/) for the MATRIX project. - diff --git a/docs/src/development.md b/docs/src/development.md index 8ffb75af52..09201863c1 100644 --- a/docs/src/development.md +++ b/docs/src/development.md @@ -3,24 +3,25 @@ ## Preview of the documentation -To generate the Documentation, first instantiate the `docs` environment -by executing the following command from the TrixiParticles.jl root directory: +To build the documentation, first instantiate the `docs` environment by running the +following command from the TrixiParticles.jl root directory: ```bash julia --project=docs -e "using Pkg; Pkg.develop(PackageSpec(path=pwd())); Pkg.instantiate()" ``` -This command only has to be run once. After that, maintain the `docs` environment +You only need to run this command once. After that, maintain the `docs` environment as described under [Installation](@ref installation-issues). -With an instantiated `docs` environment, generate the docs with the following command (again from the TrixiParticles.jl root directory): +Once the `docs` environment is instantiated, build the documentation with the following +command (again from the TrixiParticles.jl root directory): ```bash julia --project=docs --color=yes docs/make.jl ``` -You can then open the generated files in `docs/build` with your webbrowser. +You can then open the generated files in `docs/build` in your web browser. Alternatively, run ```bash python3 -m http.server -d docs/build ``` -and open `localhost:8000` in your webbrowser. +and open `localhost:8000` in your web browser. ## Release management @@ -65,4 +66,3 @@ To create a new release for TrixiParticles.jl, perform the following steps: `-dev` suffix added. For example, if you just released `v0.3.0`, the new development version should be `v0.3.1-dev`. If you just released `v0.2.4`, the new development version should be `v0.2.5-dev`. - diff --git a/docs/src/examples.md b/docs/src/examples.md index f19e623e9e..59e2bda55c 100644 --- a/docs/src/examples.md +++ b/docs/src/examples.md @@ -42,7 +42,7 @@ ``` -## Fluid Structure Interaction +## Fluid-Structure Interaction ### Dam Break with Elastic Plate (`fsi/dam_break_plate_2d.jl`) ```@raw html @@ -59,7 +59,7 @@ ``` -## Structure Mechanics +## Structural Mechanics ### Oscillating Beam (`solid/oscillating_beam_2d.jl`) ```@raw html diff --git a/docs/src/general/neighborhood_search.md b/docs/src/general/neighborhood_search.md index b639e4a05f..93019eaffc 100644 --- a/docs/src/general/neighborhood_search.md +++ b/docs/src/general/neighborhood_search.md @@ -1,13 +1,14 @@ # Neighborhood Search -The neighborhood search is the most essential component for performance. +The neighborhood search is one of the most performance-critical components. We provide several implementations in the package [PointNeighbors.jl](https://github.com/trixi-framework/PointNeighbors.jl). -See the docs of this package for an overview and a comparison of different implementations. +See the PointNeighbors.jl documentation for an overview and a comparison of the +different implementations. !!! note "Usage" - To run a simulation with a neighborhood search implementation, pass a template of the - neighborhood search to the constructor of the [`Semidiscretization`](@ref). + To run a simulation with a neighborhood search implementation, pass a neighborhood + search template to the constructor of the [`Semidiscretization`](@ref). 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/getting_started.md b/docs/src/getting_started.md index a68bb620c0..08ebfeb066 100644 --- a/docs/src/getting_started.md +++ b/docs/src/getting_started.md @@ -1,14 +1,14 @@ # [Getting started](@id getting_started) -If you have not installed TrixiParticles.jl, please follow the instructions given [here](install.md). +If you have not installed TrixiParticles.jl yet, please follow the instructions in [Installation](@ref installation). -In the following sections, we will give a short introduction. For a more thorough discussion, take a look at our [Tutorials](tutorial.md). +This page provides a short introduction. For a broader introduction, take a look at our [Tutorials](tutorial.md). ## Running an Example -The easiest way to run a simulation is to run one of our predefined example files. +The easiest way to start is to run one of the predefined example files. We will run the file `examples/fluid/hydrostatic_water_column_2d.jl`, which simulates a fluid resting in a rectangular tank. Since TrixiParticles.jl uses multithreading, you should start Julia with the flag `--threads auto` (or, e.g. `--threads 4` for 4 threads). -In the Julia REPL, first load the package TrixiParticles.jl. +In the Julia REPL, first load `TrixiParticles.jl`. ```jldoctest getting_started julia> using TrixiParticles ``` @@ -29,8 +29,8 @@ 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 -You can find a list of our other predefined examples under [Examples](examples.md). -Execute them as follows from the Julia REPL by replacing `subfolder` and `example_name` +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")) ``` @@ -38,18 +38,18 @@ julia> trixi_include(joinpath(examples_dir(), "subfolder", "example_name.jl")) ## 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 example file. +With `trixi_include`, we can overwrite variables defined in the example file to run a different simulation without modifying the file itself. ```jldoctest getting_started; filter = r".*"s julia> trixi_include(joinpath(examples_dir(), "fluid", "hydrostatic_water_column_2d.jl"), initial_fluid_size=(1.0, 0.5)) ``` -This for example, will change the fluid size from ``(0.9, 1.0)`` to ``(1.0, 0.5)``. +This, for example, changes the fluid size from ``(0.9, 1.0)`` to ``(1.0, 0.5)``. -To understand why, take a look into the file `hydrostatic_water_column_2d.jl` in the subfolder `fluid` inside the examples directory, which is the file that we executed earlier. +To understand why, take a look at the file `hydrostatic_water_column_2d.jl` in the `fluid` subdirectory of the examples directory, which is the file we executed earlier. You can see that the initial size of the fluid is defined in the variable `initial_fluid_size`, which we could overwrite with the `trixi_include` call above. Another variable that is worth experimenting with is `fluid_particle_spacing`, which controls the resolution of the simulation in this case. A lower value will increase the resolution and the runtime. -## Set up you first simulation from scratch +## Set Up Your First Simulation from Scratch See [Set up your first simulation](tutorials/tut_setup.md). -Find an overview over the available tutorials under [Tutorials](tutorial.md). +An overview of the available tutorials is available under [Tutorials](tutorial.md). diff --git a/docs/src/gpu.md b/docs/src/gpu.md index bfcebe24e0..0b14c12c70 100644 --- a/docs/src/gpu.md +++ b/docs/src/gpu.md @@ -1,9 +1,8 @@ # [GPU Support](@id gpu_support) -GPU support is still an experimental feature that is actively being worked on. Currently, the [`WeaklyCompressibleSPHSystem`](@ref), [`TotalLagrangianSPHSystem`](@ref) and [`WallBoundarySystem`](@ref) support GPU execution. -We have tested GPU support on Nvidia, AMD and Apple GPUs. +We have tested GPU support on NVIDIA, AMD, and Apple GPUs. Note that most Apple GPUs do not support `Float64`. See [below on how to run single precision simulations](@ref single_precision). @@ -42,14 +41,14 @@ semi = Semidiscretization(fluid_system, boundary_system, └──────────────────────────────────────────────────────────────────────────────────────────────────┘ ``` -At this point, we should run the simulation and make sure that it still works and that -the bounding box is large enough. +At this point, run the simulation and make sure that it still works and that the +bounding box is large enough. For some simulations where particles move outside the initial tank coordinates, for example when the tank is not closed or when the tank is moving, an appropriate bounding box has to be specified. -Then, we only need to specify the parallelization backend that is used for the simulation. -On an Nvidia GPU, we specify: +Then, we only need to specify the parallelization backend used for the simulation. +On an NVIDIA GPU, we specify: ```julia using CUDA semi = Semidiscretization(fluid_system, boundary_system, @@ -65,7 +64,7 @@ semi = Semidiscretization(fluid_system, boundary_system, ``` Now, we can run the simulation as usual. All data is transferred to the GPU during initialization and all loops over particles -and their neighbors will be executed on the GPU as kernels generated by KernelAbstractions.jl. +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 @@ -84,7 +83,7 @@ Note that in `examples/fluid/dam_break_2d.jl`, we explicitly set so that we can use `trixi_include` to replace this value. To run this simulation on a GPU, simply update `parallelization_backend` to the backend -of the installed GPU. We can run this simulation on an Nvidia GPU as follows. +of the installed GPU. We can run this simulation on an NVIDIA GPU as follows. ```julia using CUDA trixi_include(joinpath(examples_dir(), "fluid", "dam_break_2d_gpu.jl"), parallelization_backend=CUDABackend()) @@ -94,7 +93,7 @@ For AMD GPUs, use using AMDGPU trixi_include(joinpath(examples_dir(), "fluid", "dam_break_2d_gpu.jl"), parallelization_backend=ROCBackend()) ``` -For Apple GPUs (which don't support double precision, see below), use +For Apple GPUs (which do not support double precision, see below), use ```julia using Metal trixi_include_changeprecision(Float32, @@ -110,7 +109,7 @@ which is significantly faster on most GPUs and required for many Apple GPUs. To run a simulation with single precision, all `Float64` literals in an example file must be converted to `Float32` (e.g. `0.0` to `0.0f0`). -TrixiParticles provides a function to automate this conversion: +TrixiParticles.jl provides a function to automate this conversion: ```@docs trixi_include_changeprecision ``` diff --git a/docs/src/index.md b/docs/src/index.md index 27c1e9d474..19b4a32b87 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -1,24 +1,26 @@ # TrixiParticles.jl -**TrixiParticles.jl** is a high-performance particle simulation framework designed to overcome challenges of particle-based numerical methods in multiphysics applications. Existing frameworks often lack user-friendliness, involve complex configuration, and are not easily extensible for development of new methods. In the future we also want to provide seamless scalability from CPU to Exascale-level computing with GPU support. **TrixiParticles.jl** addresses these limitations with an intuitive interface, straightforward configuration, and an extensible design, facilitating efficient simulation setup and execution. +**TrixiParticles.jl** is a high-performance simulation framework for particle-based methods in complex multiphysics applications. It combines an accessible user interface with an extensible architecture for developing new methods, while also providing GPU-accelerated execution. TrixiParticles.jl focuses on the following use cases: -- Development of new particle-based methods and models. By providing an extensible architecture to incorporate additional particle methods easily and not focusing on a single model or numerical method. -- Accurate, reliable and efficient physics-based modelling of complex multiphysics problems by providing a flexible configuration system, tools, high performance and a wide range of validation and test cases. -- Easy setup of accessible simulations for educational purposes, including student projects, coursework, and thesis work through extensive documentation, community engagement and readable configuration files. +- Development of new particle-based methods and models through an extensible architecture that is not tied to a single numerical method. +- Accurate, reliable, and efficient physics-based modeling of complex multiphysics problems through a flexible configuration system, high performance, and a broad set of validation and test cases. +- Accessible simulation setup for educational purposes, including student projects, coursework, and thesis work, supported by extensive documentation and readable configuration files. -Its features include: +Its main features include: ## Features -- Incompressible Navier-Stokes - - Methods: Weakly Compressible Smoothed Particle Hydrodynamics (WCSPH), Entropically Damped Artificial Compressibility (EDAC) - - Models: Surface Tension -- Solid-body mechanics - - Methods: Total Lagrangian SPH (TLSPH) +- Incompressible Navier-Stokes flows + - Methods: Weakly Compressible Smoothed Particle Hydrodynamics (WCSPH), Entropically Damped Artificial Compressibility (EDAC), Implicit Incompressible Smoothed Particle Hydrodynamics (IISPH) + - Models: Surface Tension, Open Boundaries +- Structural mechanics + - Methods: Total Lagrangian SPH (TLSPH), Discrete Element Method (DEM) - Fluid-Structure Interaction +- Particle sampling of complex geometries from `.stl`, `.asc`, and `.dxf` files - Output formats: - VTK +- Experimental GPU support for NVIDIA, AMD, and Apple devices ## Examples ```@raw html @@ -42,14 +44,14 @@ Its features include: ``` -## Quickstart +## Quick Start 1. [Installation](@ref installation) 2. [Getting started](@ref getting_started) -If you have any questions concerning **TrixiParticles.jl** you can join our community [on Slack](https://join.slack.com/t/trixi-framework/shared_invite/zt-sgkc6ppw-6OXJqZAD5SPjBYqLd8MU~g) or open an issue with your question. +If you have questions about **TrixiParticles.jl**, join our community [on Slack](https://join.slack.com/t/trixi-framework/shared_invite/zt-sgkc6ppw-6OXJqZAD5SPjBYqLd8MU~g) or open an issue. -## Start with development -To get started with development have a look at these pages: +## Getting Started with Development +If you want to contribute or extend the code, start with: 1. [Installation](@ref installation) 2. [Development](@ref development) diff --git a/docs/src/install.md b/docs/src/install.md index 15c6668b9c..ad379e6859 100644 --- a/docs/src/install.md +++ b/docs/src/install.md @@ -1,7 +1,7 @@ # [Installation](@id installation) ## Setting up Julia -If you have not yet installed Julia, please [follow the instructions on the +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. @@ -35,26 +35,25 @@ julia --project=run ``` from the TrixiParticles.jl root directory. -The advantage of using a separate `run` directory is that you can also add other -related packages (e.g., OrdinaryDiffEq.jl, see above) to the project in the `run` folder -and always have a reproducible environment at hand to share with others. +The advantage of using a separate `run` directory is that you can add other related +packages (e.g., OrdinaryDiffEq.jl, see above) to the project in that folder while +keeping a reproducible environment that is easy to share with others. ## Optional software/packages -- [OrdinaryDiffEq.jl](https://github.com/SciML/OrdinaryDiffEq.jl) -- A Julia package of ordinary differential equation solvers that is used in the examples -- [Plots.jl](https://github.com/JuliaPlots/Plots.jl) -- Julia Plotting library that is used in some examples +- [OrdinaryDiffEq.jl](https://github.com/SciML/OrdinaryDiffEq.jl) -- Julia package of ordinary differential equation solvers used in the 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/) -- Software that can be used for visualization of results +- [ParaView](https://www.paraview.org/) -- Visualization software for simulation results ## [Common issues](@id installation-issues) -If you followed the [installation instructions for developers](@ref for-developers) and you -run into any problems with packages when pulling the latest version of TrixiParticles.jl, -start Julia with the project in the `run` folder, +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 ``` -update all packages in that project, resolve all conflicts in the project, and install all -new dependencies: +then update packages, resolve dependency conflicts, and install new dependencies: ```julia julia> using Pkg diff --git a/docs/src/overview.md b/docs/src/overview.md index a1b6a70d5a..fc500ab85d 100644 --- a/docs/src/overview.md +++ b/docs/src/overview.md @@ -1,18 +1,18 @@ # Overview -The actual API reference is not listed on a single page, like in most Julia packages, -but instead is split into multiple sections that follow a similar structure -as the code files themselves. -In these sections, API docs are combined with explanations of the theoretical background -of these methods. +The API reference is not collected on a single page. +Instead, it is split into sections that largely mirror the source tree. +These sections combine API documentation with short explanations of the +underlying methods. The following page gives a rough overview of important parts of the code. ## Program flow -To initiate a simulation, the goal is to solve an ordinary differential equation, for example, -by employing the time integration schemes provided by OrdinaryDiffEq.jl. These schemes are then -utilized to integrate ``\mathrm{d}u/\mathrm{d}t`` and ``\mathrm{d}v/\mathrm{d}t``, where ``u`` -represents the particles' positions and ``v`` their properties such as velocity and density. +To run a simulation, TrixiParticles.jl solves an ordinary differential equation, +typically with a time integration scheme from OrdinaryDiffEq.jl. These schemes are +used to integrate ``\mathrm{d}u/\mathrm{d}t`` and ``\mathrm{d}v/\mathrm{d}t``, +where ``u`` represents particle positions and ``v`` particle properties such as +velocity and density. During a single time step or an intermediate step of the time integration scheme, the functions `drift!` and `kick!` are invoked, followed by the functions depicted in this diagram (with key parts highlighted in orange/yellow). @@ -78,9 +78,10 @@ flowchart TD ``` ## Structure -What we refer to as schemes are various models such as Weakly Compressible Smoothed Particle Hydrodynamics (WCSPH) -or Total Lagrangian Smoothed Particle Hydrodynamics (TLSPH). These schemes are categorized based on the applicable -physical regimes, namely fluid, solid, gas, and others. Each scheme comprises at least two files: a `system.jl` file -and an `rhs.jl` file. The `system.jl` file provides the data structure holding the particles of this scheme and some -routines, particularly those for allocation and the main update routines, excluding system interactions. -The interactions between particles of this scheme (and with particles of other schemes) are handled in the `rhs.jl` file. +In the codebase, a scheme denotes a particle method or model such as Weakly Compressible +SPH (WCSPH) or Total Lagrangian SPH (TLSPH). Schemes are organized by application area, +for example fluid, structure, and boundary systems. A scheme typically comprises at least +two files: a `system.jl` file and an `rhs.jl` file. The `system.jl` file defines the data +structure that stores the particles of the scheme together with routines for allocation and +main updates that do not involve particle interactions. The `rhs.jl` file contains the +interaction terms between particles of the same scheme and between different schemes. diff --git a/docs/src/visualization.md b/docs/src/visualization.md index 1f74108b4f..9dda94bfba 100644 --- a/docs/src/visualization.md +++ b/docs/src/visualization.md @@ -2,8 +2,10 @@ ## Export VTK files You can export particle data as VTK files by using the [`SolutionSavingCallback`](@ref). -All our [predefined examples](examples.md) are already using this callback to export VTK files to the `out` directory (relative to the directory that you are running Julia from). -VTK files can be read by visualization tools like [ParaView](https://www.paraview.org/) and [VisIt](https://visit.llnl.gov/). +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/). ### ParaView @@ -29,8 +31,8 @@ Then, in the Properties panel (bottom left), adjust the following settings: ![image](https://github.com/user-attachments/assets/194d9a09-5937-4ee4-b229-07078afe3ff0) #### Visualization with Macro -To simplify the visualization of your particle data in ParaView, you can use a macro. -This macro automates the manual steps in the previous section to a single click of a button. +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. 1. **Save the macro code** (see below) as a `.py` file, e.g. `PointGaussianMacro.py`. @@ -77,8 +79,10 @@ sourceDisplay.GaussianRadius = 0.5 ``` #### Show results -To now view the result variables **first** make sure you have "fluid_1.pvd" highlighted in the "Pipeline Browser" then select them in the variable selection combo box (see picture below). -Let's, for example, pick "density". To now view the time progression of the result hit the "play button" (see picture below). +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 From a84e50c792a2d99e872589054bc0235f906cef9d Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Mon, 9 Mar 2026 18:43:51 +0100 Subject: [PATCH 02/59] improve maths --- docs/src/systems/boundary.md | 53 +++++--- docs/src/systems/entropically_damped_sph.md | 37 ++++-- docs/src/systems/fluid.md | 117 +++++++++++------ .../systems/implicit_incompressible_sph.md | 100 +++++++------- docs/src/systems/total_lagrangian_sph.md | 8 +- docs/src/systems/weakly_compressible_sph.md | 123 ++++++++++++++---- docs/src/time_integration.md | 4 +- 7 files changed, 291 insertions(+), 151 deletions(-) diff --git a/docs/src/systems/boundary.md b/docs/src/systems/boundary.md index 94fbe6ce05..46463b10ac 100644 --- a/docs/src/systems/boundary.md +++ b/docs/src/systems/boundary.md @@ -16,6 +16,11 @@ Pages = [joinpath("schemes", "boundary", "prescribed_motion.jl")] # [Boundary Models](@id boundary_models) +!!! note + The pairwise interaction terms below are written in force form, following the SPH literature. + TrixiParticles.jl applies the corresponding accelerations internally. Where the implemented + boundary discretization differs from the literature formula, both forms are stated explicitly. + ## Dummy Particles Boundaries modeled as dummy particles, which are treated like fluid particles, @@ -25,7 +30,7 @@ dummy particles need to have a mass corresponding to the fluid's rest density, w "hydrodynamic mass", as opposed to mass corresponding to the material density of a [`TotalLagrangianSPHSystem`](@ref). -Here, `initial_density` and `hydrodynamic_mass` are vectors that contains the initial density +Here, `initial_density` and `hydrodynamic_mass` are vectors that contain the initial density and the hydrodynamic mass respectively for each boundary particle. Note that when used with [`SummationDensity`](@ref) (see below), this is only used to determine the element type and the number of boundary particles. @@ -37,14 +42,16 @@ This should be the same as for the adjacent fluid system with the largest smooth In the literature, this kind of boundary particles is referred to as "dummy particles" ([Adami et al., 2012](@cite Adami2012) and [Valizadeh & Monaghan, 2015](@cite Valizadeh2015)), -"frozen fluid particles" ([Akinci et al., 2012](@cite Akinci2012)) or "dynamic boundaries [Crespo et al., 2007](@cite Crespo2007). +"frozen fluid particles" ([Akinci et al., 2012](@cite Akinci2012)) or "dynamic boundaries" ([Crespo et al., 2007](@cite Crespo2007)). The key detail of this boundary condition and the only difference between the boundary models in these references is the way the density and pressure of boundary particles is computed. -Since boundary particles are treated like fluid particles, the force -on fluid particle ``a`` due to boundary particle ``b`` is given by +For the standard summation-density pressure force, the force on fluid particle ``a`` +due to boundary particle ``b`` is ```math -f_{ab} = m_a m_b \left( \frac{p_a}{\rho_a^2} + \frac{p_b}{\rho_b^2} \right) \nabla_{r_a} W(\Vert r_a - r_b \Vert, h). +\bm{f}_{ab}^{p} += -m_a m_b \left( \frac{p_a}{\rho_a^2} + \frac{p_b}{\rho_b^2} \right) +\nabla_{r_a} W(\Vert r_a - r_b \Vert, h). ``` The quantities to be defined here are the density ``\rho_b`` and pressure ``p_b`` of the boundary particle ``b``. @@ -99,11 +106,16 @@ where the sum is over all fluid particles, ``\rho_f`` and ``p_f`` denote the den ``` #### 2. [`BernoulliPressureExtrapolation`](@ref) -Identical to the pressure ``p_b `` calculated via [`AdamiPressureExtrapolation`](@ref), but it adds the dynamic pressure component of the Bernoulli equation: +Identical to the pressure ``p_b`` calculated via [`AdamiPressureExtrapolation`](@ref), but it adds an additional dynamic pressure term. For moving wall boundaries, the implementation uses +```math +p_b = \frac{\sum_f (p_f + p_{f,\mathrm{dyn}} + \rho_f (\bm{g} - \bm{a}_b) \cdot \bm{r}_{bf}) W(\Vert r_{bf} \Vert, h)}{\sum_f W(\Vert r_{bf} \Vert, h)}, +``` +with ```math -p_b = \frac{\sum_f (p_f + \frac{1}{2} \, \rho_{\text{neighbor}} \left( \frac{ (\mathbf{v}_f - \mathbf{v}_{\text{body}}) \cdot (\mathbf{x}_f - \mathbf{x}_{\text{neighbor}}) }{ \left\| \mathbf{x}_f - \mathbf{x}_{\text{neighbor}} \right\| } \right)^2 \times \text{factor} +\rho_f (\bm{g} - \bm{a}_b) \cdot \bm{r}_{bf}) W(\Vert r_{bf} \Vert, h)}{\sum_f W(\Vert r_{bf} \Vert, h)} +p_{f,\mathrm{dyn}} = \frac{1}{2} \, \text{factor} \, \rho_f +\frac{\left((\bm{v}_b - \bm{v}_f) \cdot \bm{r}_{bf}\right)^2}{\Vert \bm{r}_{bf} \Vert}, ``` -where ``\mathbf{v}_f`` is the velocity of the fluid and ``\mathbf{v}_{\text{body}}`` is the velocity of the body. +where ``\bm{v}_f`` is the fluid velocity and ``\bm{v}_b`` is the boundary velocity. This adjustment provides a higher boundary pressure for bodies moving with a relative velocity to the fluid to prevent penetration. This modification is original and not derived from any literature source. @@ -123,17 +135,16 @@ reference pressure (the corresponding pressure to the reference density by the s #### 6. [`PressureMirroring`](@ref) Instead of calculating density and pressure for each boundary particle, we modify the -momentum equation, +pressure force, ```math -\frac{\mathrm{d}v_a}{\mathrm{d}t} = -\sum_b m_b \left( \frac{p_a}{\rho_a^2} + \frac{p_b}{\rho_b^2} \right) \nabla_a W_{ab} +\bm{F}_a^{p} = -m_a \sum_b m_b \left( \frac{p_a}{\rho_a^2} + \frac{p_b}{\rho_b^2} \right) \nabla_a W_{ab}, ``` to replace the unknown density $\rho_b$ if $b$ is a boundary particle by the reference density and the unknown pressure $p_b$ if $b$ is a boundary particle by the pressure $p_a$ of the -interacting fluid particle. -The momentum equation therefore becomes +interacting fluid particle. The force therefore becomes ```math -\frac{\mathrm{d}v_a}{\mathrm{d}t} = -\sum_f m_f \left( \frac{p_a}{\rho_a^2} + \frac{p_f}{\rho_f^2} \right) \nabla_a W_{af} --\sum_b m_b \left( \frac{p_a}{\rho_a^2} + \frac{p_a}{\rho_0^2} \right) \nabla_a W_{ab}, +\bm{F}_a^{p} = -m_a \sum_f m_f \left( \frac{p_a}{\rho_a^2} + \frac{p_f}{\rho_f^2} \right) \nabla_a W_{af} +-m_a \sum_b m_b \left( \frac{p_a}{\rho_a^2} + \frac{p_a}{\rho_0^2} \right) \nabla_a W_{ab}, ``` where the first sum is over all fluid particles and the second over all boundary particles. @@ -168,18 +179,22 @@ condition is applied. ## Repulsive Particles -Boundaries modeled as boundary particles which exert forces on the fluid particles ([Monaghan, Kajtar, 2009](@cite Monaghan2009)). -The force on fluid particle ``a`` due to boundary particle ``b`` is given by +Boundaries modeled as boundary particles which exert repulsive interactions on the fluid particles ([Monaghan, Kajtar, 2009](@cite Monaghan2009)). +The literature force on fluid particle ``a`` due to boundary particle ``b`` is ```math -f_{ab} = m_a \left(\tilde{f}_{ab} - m_b \Pi_{ab} \nabla_{r_a} W(\Vert r_a - r_b \Vert, h)\right) +\bm{f}_{ab} = m_a \left(\tilde{\bm{f}}_{ab} - m_b \Pi_{ab} +\nabla_{r_a} W(\Vert r_a - r_b \Vert, h)\right) ``` with ```math -\tilde{f}_{ab} = \frac{K}{\beta^{n-1}} \frac{r_{ab}}{\Vert r_{ab} \Vert (\Vert r_{ab} \Vert - d)} \Phi(\Vert r_{ab} \Vert, h) +\tilde{\bm{f}}_{ab} = +\frac{K}{\beta^{n-1}} \frac{\bm{r}_{ab}} +{\Vert \bm{r}_{ab} \Vert (\Vert \bm{r}_{ab} \Vert - d)} +\Phi(\Vert \bm{r}_{ab} \Vert, h)\, \frac{2 m_b}{m_a + m_b}, ``` where ``m_a`` and ``m_b`` are the masses of fluid particle ``a`` and boundary particle ``b`` -respectively, ``r_{ab} = r_a - r_b`` is the difference of the coordinates of particles +respectively, ``\bm{r}_{ab} = \bm{r}_a - \bm{r}_b`` is the difference of the coordinates of particles ``a`` and ``b``, ``d`` denotes the boundary particle spacing and ``n`` denotes the number of dimensions (see [Monaghan & Kajtar, 2009](@cite Monaghan2009), Equation (3.1) and [Valizadeh & Monaghan, 2015](@cite Valizadeh2015)). Note that the repulsive acceleration $\tilde{f}_{ab}$ does not depend on the masses of diff --git a/docs/src/systems/entropically_damped_sph.md b/docs/src/systems/entropically_damped_sph.md index 96acbad352..be1772bcd7 100644 --- a/docs/src/systems/entropically_damped_sph.md +++ b/docs/src/systems/entropically_damped_sph.md @@ -3,42 +3,55 @@ As opposed to the [weakly compressible SPH scheme](weakly_compressible_sph.md), which uses an equation of state, this scheme uses a pressure evolution equation to calculate the pressure ```math -\frac{\mathrm{d} p_a}{\mathrm{d}t} = - \rho c_s^2 \nabla \cdot v + \nu \nabla^2 p, +\frac{\mathrm{d} p_a}{\mathrm{d}t} = - \rho_a c_s^2 (\nabla \cdot v)_a + \nu_{\mathrm{EDAC}} (\nabla^2 p)_a, ``` which is derived by [Clausen (2013)](@cite Clausen2013). This equation is similar to the continuity equation (first term, see -[`ContinuityDensity`](@ref)), but also contains a pressure damping term (second term, similar to density diffusion +[`ContinuityDensity`](@ref)), but also contains a pressure damping term (second term, similar to density diffusion, see [`AbstractDensityDiffusion`](@ref TrixiParticles.AbstractDensityDiffusion)), which reduces acoustic pressure waves through an entropy-generation mechanism. -The pressure evolution is discretized with the SPH method by [Ramachandran (2019)](@cite Ramachandran2019) as following: +The pressure evolution is discretized with the SPH method by [Ramachandran (2019)](@cite Ramachandran2019) as follows: The first term is equivalent to the classical artificial compressible methods, which are commonly motivated by assuming the artificial equation of state ([`StateEquationCole`](@ref) with `exponent=1`) and is discretized as ```math -- \rho c_s^2 \nabla \cdot v = \sum_{b} m_b \frac{\rho_a}{\rho_b} c_s^2 v_{ab} \cdot \nabla_{r_a} W(\Vert r_a - r_b \Vert, h), +\left.- \rho c_s^2 \nabla \cdot v \right|_a += \sum_{b} m_b \frac{\rho_a}{\rho_b} c_s^2 v_{ab} \cdot \nabla_{r_a} W(\Vert r_a - r_b \Vert, h), ``` where ``\rho_a``, ``\rho_b``, ``r_a``, ``r_b``, denote the density and coordinates of particles ``a`` and ``b`` respectively, ``c_s`` is the speed of sound and ``v_{ab} = v_a - v_b`` is the difference in the velocity. The second term smooths the pressure through the introduction of entropy and is discretized as ```math -\nu \nabla^2 p = \frac{V_a^2 + V_b^2}{m_a} \tilde{\eta}_{ab} \frac{p_{ab}}{\Vert r_{ab}^2 \Vert + \eta h_{ab}^2} \nabla_{r_a} -W(\Vert r_a - r_b \Vert, h) \cdot r_{ab}, +\left.\nu_{\mathrm{EDAC}} \nabla^2 p \right|_a += \sum_b \frac{V_a^2 + V_b^2}{m_a}\, +\tilde{\eta}_{ab}\, +\frac{p_{ab}}{\Vert r_{ab} \Vert^2 + 0.01 h_{ab}^2}\, +\nabla_{r_a} W(\Vert r_a - r_b \Vert, h) \cdot r_{ab}, ``` -where ``V_a``, ``V_b`` denote the volume of particles ``a`` and ``b`` respectively and ``p_{ab}= p_a -p_b`` is the difference in the pressure. +where ``V_a``, ``V_b`` denote the particle volumes, ``p_{ab}= p_a - p_b``, +``r_{ab} = r_a - r_b``, and ``h_{ab} = \frac{1}{2}(h_a + h_b)``. -The viscosity parameter ``\eta_a`` for a particle ``a`` is given as +The dynamic EDAC viscosity for particle ``a`` is ```math -\eta_a = \rho_a \frac{\alpha h c_s}{8}, +\eta_a = \rho_a \nu_{\mathrm{EDAC}}, ``` -where it is found in the numerical experiments of [Ramachandran (2019)](@cite Ramachandran2019) that ``\alpha = 0.5`` +with +```math +\nu_{\mathrm{EDAC}} = \frac{\alpha h c_s}{8}, +``` +and the harmonic mean +```math +\tilde{\eta}_{ab} = \frac{2 \eta_a \eta_b}{\eta_a + \eta_b}. +``` +It is found in the numerical experiments of [Ramachandran (2019)](@cite Ramachandran2019) that ``\alpha = 0.5`` is a good choice for a wide range of Reynolds numbers (0.0125 to 10000). !!! note > The EDAC formulation keeps the density constant and this eliminates the need for the continuity equation - > or the use of a summation density to find the pressure. However, in SPH discretizations, ``m/\rho`` - > is typically used as a proxy for the particle volume. The density of the fluids can + > or the use of a summation density to find the pressure. However, in SPH discretizations, ``m/\rho`` + > is typically used as a proxy for the particle volume. The density of the fluids can > therefore be computed using the summation density approach. [Ramachandran2019](@cite) diff --git a/docs/src/systems/fluid.md b/docs/src/systems/fluid.md index c9b9860ab9..a22bf11e9e 100644 --- a/docs/src/systems/fluid.md +++ b/docs/src/systems/fluid.md @@ -4,6 +4,12 @@ Currently available fluid methods are the [weakly compressible SPH method](@ref [entropically damped artificial compressibility for SPH](@ref edac). This page lists models and techniques that apply to both of these methods. +!!! note + The formulas on this page follow the force notation commonly used in the SPH literature. + TrixiParticles.jl usually evaluates the corresponding acceleration contributions internally. + Whenever the implemented discretization differs from the literature formula, both forms are + stated explicitly. + ## [Viscosity](@id viscosity_sph) Viscosity is a critical physical property governing momentum diffusion within a fluid. @@ -55,10 +61,10 @@ by Balsara ([Balsara1995](@cite)) or Morris ([Morris1997](@cite)). ##### Mathematical Formulation -The force exerted by particle ``b`` on particle ``a`` due to artificial viscosity is given by: +The force exerted by particle ``b`` on particle ``a`` due to artificial viscosity is given by ```math -F_{ab}^{\text{AV}} = - m_a m_b \Pi_{ab} \nabla W_{ab} +\bm{F}_{ab}^{\text{AV}} = - m_a m_b \Pi_{ab} \nabla_a W_{ab}. ``` where: @@ -75,18 +81,19 @@ where: - ``c`` is the local speed of sound, - ``\bar{\rho}_{ab}`` is the arithmetic mean of the densities of particles ``a`` and ``b``. -The term ``\mu_{ab}`` is defined as: +The term ``\mu_{ab}`` is defined as ```math -\mu_{ab} = \frac{h \, v_{ab} \cdot r_{ab}}{\Vert r_{ab} \Vert^2 + \epsilon h^2}, +\mu_{ab} = \frac{h \, \bm{v}_{ab} \cdot \bm{r}_{ab}} + {\Vert \bm{r}_{ab} \Vert^2 + \epsilon h^2}, ``` with: - ``h`` being the smoothing length, - ``\epsilon`` a small parameter to prevent singularities, -- ``r_{ab} = r_a - r_b`` representing the difference of the coordinate vectors, -- ``v_{ab} = v_a - v_b`` representing the relative velocity between particles. +- ``\bm{r}_{ab} = \bm{r}_a - \bm{r}_b`` representing the difference of the coordinate vectors, +- ``\bm{v}_{ab} = \bm{v}_a - \bm{v}_b`` representing the relative velocity between particles. ##### Resolution Dependency and Effective Viscosity @@ -109,19 +116,21 @@ This results in a more realistic representation of flow dynamics in weakly compr ##### Mathematical Formulation -An additional force term ``\tilde{f}_{ab}`` is introduced to the pressure gradient force ``f_{ab}`` between particles ``a`` and ``b``: +An additional force term ``\tilde{\bm{F}}_{ab}`` is introduced in the momentum equation: ```math -\tilde{f}_{ab} = m_a m_b \frac{(\mu_a + \mu_b)\, r_{ab} \cdot \nabla W_{ab}}{\rho_a \rho_b (\Vert r_{ab} \Vert^2 + \epsilon h^2)}\, v_{ab}, +\tilde{\bm{F}}_{ab} = +m_a m_b \frac{(\mu_a + \mu_b)\, \bm{r}_{ab} \cdot \nabla_a W_{ab}} +{\rho_a \rho_b (\Vert \bm{r}_{ab} \Vert^2 + \epsilon h^2)}\, \bm{v}_{ab}, ``` where: -- ``\mu_a = \rho_a \nu`` and ``\mu_b = \rho_b \nu`` represent the dynamic viscosities of particles ``a``and ``b`` (with ``\nu`` being the kinematic viscosity), -- ``r_{ab} = r_a - r_b`` represents the difference of the coordinate vectors, -- ``v_{ab} = v_a - v_b`` represents the relative velocity between particles. +- ``\mu_a = \rho_a \nu`` and ``\mu_b = \rho_b \nu`` represent the dynamic viscosities of particles ``a`` and ``b`` (with ``\nu`` being the kinematic viscosity), +- ``\bm{r}_{ab} = \bm{r}_a - \bm{r}_b`` represents the difference of the coordinate vectors, +- ``\bm{v}_{ab} = \bm{v}_a - \bm{v}_b`` represents the relative velocity between particles, - `` h `` is the smoothing length, -- `` \nabla W_{ab} `` is the gradient of the smoothing kernel, +- `` \nabla_a W_{ab} `` is the gradient of the smoothing kernel, - `` \epsilon `` is a small parameter to prevent singularities. #### ViscosityAdami @@ -132,19 +141,24 @@ while minimizing compressibility effects. This results in accurate laminar flow ##### Mathematical Formulation -The viscous interaction is modeled through a shear force for incompressible flows: +The viscous interaction is modeled through the following pairwise force: ```math -f_{ab} = \sum_w \bar{\eta}_{ab} \left( V_a^2 + V_b^2 \right) \frac{v_{ab}}{||r_{ab}||^2 + \epsilon h_{ab}^2} \, (\nabla W_{ab} \cdot r_{ab}), +\bm{F}_{ab}^{\nu} = +\left( V_a^2 + V_b^2 \right)\, +\bar{\eta}_{ab}\, +\frac{\nabla_a W_{ab} \cdot \bm{r}_{ab}} +{\Vert \bm{r}_{ab} \Vert^2 + \epsilon h_{ab}^2}\, +\bm{v}_{ab}. ``` where: -- `` r_{ab} = r_a - r_b `` is the difference of the coordinate vectors, -- `` v_{ab} = v_a - v_b `` is their relative velocity, +- `` \bm{r}_{ab} = \bm{r}_a - \bm{r}_b `` is the difference of the coordinate vectors, +- `` \bm{v}_{ab} = \bm{v}_a - \bm{v}_b `` is their relative velocity, - `` V_a = m_a / \rho_a`` and `` V_b = m_b / \rho_b`` are the particle volumes, -- `` h_{ab} `` is the smoothing length, -- `` \nabla W_{ab} `` is the gradient of the smoothing kernel, +- `` h_{ab} = \frac{1}{2}(h_a + h_b) `` is the arithmetic mean of the smoothing lengths, +- `` \nabla_a W_{ab} `` is the gradient of the smoothing kernel, - `` \epsilon `` is a small parameter that prevents singularities (see [Ramachandran (2019)](@cite Ramachandran2019)). The inter-particle-averaged shear stress is defined as: @@ -225,10 +239,10 @@ The surface normal at a particle is derived from the color field, a scalar field to distinguish between different fluid phases or between fluid and air. The color field gradients point towards the interface, and the normalized gradient defines the surface normal direction. -The simplest SPH formulation for a surface normal, ``n_a`` is given as +In the literature, the unnormalized surface normal ``\bm{n}_a`` is commonly written as ```math -n_a = \sum_b m_b \frac{c_b}{\rho_b} \nabla_a W_{ab}, +\bm{n}_a = \sum_b m_b \frac{c_b}{\rho_b} \nabla_a W_{ab}, ``` where: @@ -238,12 +252,19 @@ where: - ``\rho_b`` is the density of particle ``b``, - ``\nabla_a W_{ab}`` is the gradient of the smoothing kernel ``W_{ab}`` with respect to particle ``a``. +Implementation note: for the single-fluid surface-normal calculation in TrixiParticles.jl, +this reduces to +```math +\bm{n}_a = \sum_b m_b \frac{1}{\rho_b} \nabla_a W_{ab}, +``` +i.e. effectively ``c_b = 1`` for neighboring fluid particles. + #### Normalization of surface normals The calculated normals are normalized to unit vectors: ```math -\hat{n}_a = \frac{n_a}{\Vert n_a \Vert}. +\hat{\bm{n}}_a = \frac{\bm{n}_a}{\Vert \bm{n}_a \Vert}. ``` Normalization ensures that the magnitude of the normals does not bias the curvature calculations or the resulting surface tension forces. @@ -303,10 +324,10 @@ It is defined by the distance between particles and the support radius ``h_c``, - Particles within half the support radius experience a repulsive force to prevent clustering. - Particles beyond half the radius but within the support radius experience an attractive force to simulate cohesion. -Mathematically: +The pairwise cohesion force is ```math -F_{\text{cohesion}} = -\sigma m_b C(r) \frac{r}{\Vert r \Vert}, +\bm{F}_{\text{cohesion}} = -\sigma m_b C(r) \frac{\bm{r}}{\Vert \bm{r} \Vert}, ``` where ``C(r)``, the cohesion kernel, is defined as: @@ -315,21 +336,30 @@ where ``C(r)``, the cohesion kernel, is defined as: C(r)=\frac{32}{\pi h_c^9} \begin{cases} (h_c-r)^3 r^3, & \text{if } 2r > h_c, \\ -2(h_c-r)^3 r^3 - \frac{h^6}{64}, & \text{if } r > 0 \text{ and } 2r \leq h_c, \\ +2(h_c-r)^3 r^3 - \frac{h_c^6}{64}, & \text{if } r > 0 \text{ and } 2r \leq h_c, \\ 0, & \text{otherwise.} \end{cases} ``` #### Surface area minimization force -The surface area minimization force models the curvature reduction effects, aligning particle motion to reduce the interface's total area. -It acts based on the difference in surface normals: +The surface area minimization force models curvature reduction and acts on the +difference in surface normals: ```math -F_{\text{curvature}} = -\sigma (n_a - n_b), +\bm{F}_{\text{curvature}} = -\sigma (\bm{n}_a - \bm{n}_b), ``` -where ``n_a`` and ``n_b`` are the surface normals of the interacting particles. +where ``\bm{n}_a`` and ``\bm{n}_b`` are the surface normals of the interacting particles. +Implementation note: TrixiParticles.jl uses +```math +\left.\frac{\mathrm{d}\bm{v}_a}{\mathrm{d} t}\right|_{ab}^{\text{curvature}} += -\sigma h (\bm{n}_a - \bm{n}_b), +``` +which uses the same normal-difference direction but scales the magnitude with the +local smoothing length ``h``. For constant ``h``, this factor can be absorbed into +the coefficient ``\sigma``; for variable smoothing lengths, it makes the curvature +contribution depend on the local kernel support. #### Wall adhesion force @@ -337,7 +367,7 @@ This force models the interaction between fluid and solid boundaries, simulating It uses a custom kernel with a peak at 0.75 times the support radius: ```math -F_{\text{adhesion}} = -\beta m_b A(r) \frac{r}{\Vert r \Vert}, +\bm{F}_{\text{adhesion}} = -\beta m_b A(r) \frac{\bm{r}}{\Vert \bm{r} \Vert}, ``` where ``A(r)`` is the adhesion kernel: @@ -358,11 +388,13 @@ The method described by [Morris](@cite Morris2000) estimates curvature by combin The computed curvature is then used to determine forces acting perpendicular to the interface. While this method provides accurate surface tension forces, it does not explicitly conserve momentum. -In the Morris model, surface tension is computed based on local interface curvature ``\kappa`` and the unit surface normal ``\hat{n}.`` -By estimating ``\hat{n}`` and ``\kappa`` at each particle near the interface, the surface tension force for particle a can be written as: +In the Morris model, surface tension is computed based on local interface curvature ``\kappa`` and the unit surface normal ``\hat{\bm{n}}``. +By estimating ``\hat{\bm{n}}`` and ``\kappa`` at each particle near the interface, the +surface tension force for particle ``a`` can be written as ```math -F_{\text{surface tension}} = - \sigma \frac{\kappa_a}{\rho_a}\hat{n}_a +\bm{F}_{a}^{\sigma} += - m_a \sigma \frac{\kappa_a}{\rho_a}\hat{\bm{n}}_a. ``` This formulation focuses directly on geometric properties of the interface, making it relatively straightforward to implement when a reliable interface detection @@ -379,29 +411,38 @@ where accumulated numerical error can be significant. #### Stress tensor formulation -The surface tension force can be seen as a divergence of a stress tensor ``S`` +The surface tension force can be written as the divergence of a stress tensor ``\bm{S}``: ```math -F_{\text{surface tension}} = \nabla \cdot S, +\bm{F}_{a}^{\sigma} = m_a \nabla \cdot \bm{S}, ``` -with ``S`` defined as +with ```math -S = \sigma \delta_s (I - \hat{n} \otimes \hat{n}), +\bm{S} = \sigma \delta_s (I - \hat{\bm{n}} \otimes \hat{\bm{n}}). ``` with: - ``\delta_s``: Surface delta function, -- ``\hat{n}``: Unit normal vector, +- ``\hat{\bm{n}}``: Unit normal vector, - ``I``: Identity matrix. This divergence can be computed numerically in the SPH framework as ```math -\sum_b \frac{m_b}{\rho_a \rho_b} (S_a + S_b) \nabla W_{ab} +\bm{F}_{a}^{\sigma} += m_a \sum_b \frac{m_b}{\rho_a \rho_b} (\bm{S}_a + \bm{S}_b) \nabla_a W_{ab}. +``` + +Implementation note: TrixiParticles.jl evaluates the corresponding acceleration and uses +the stabilized stress tensor +```math +\bm{S}_a^{\text{impl}} += \delta_{s,a} (I - \hat{\bm{n}}_a \otimes \hat{\bm{n}}_a) - \delta_{s,\max} I, ``` +with the factor ``\sigma`` applied outside the pairwise sum. #### Advantages and limitations diff --git a/docs/src/systems/implicit_incompressible_sph.md b/docs/src/systems/implicit_incompressible_sph.md index 0927aa8821..575d7d84a9 100644 --- a/docs/src/systems/implicit_incompressible_sph.md +++ b/docs/src/systems/implicit_incompressible_sph.md @@ -5,7 +5,10 @@ is a method that achieves incompressibility by solving the pressure Poisson equa The resulting linear system is iteratively solved with the relaxed Jacobi method. Unlike the [weakly compressible SPH method](@ref wcsph), incompressible methods determine pressure by enforcing the incompressibility constraint rather than using an equation of -state. +state. In the derivation below, we keep the force notation of +[Ihmsen et al. (2013)](@cite Ihmsen2013), which is also the standard presentation in the +IISPH literature. Internally, TrixiParticles.jl applies the equivalent pressure +accelerations ``\bm{F}_i^p / m_i``; this changes only the presentation, not the algebra. ```@autodocs Modules = [TrixiParticles] @@ -28,14 +31,15 @@ difference yields The divergence in the right-hand side is discretized with the SPH discretization for particle ``i`` as ```math --\frac{1}{\rho_i} \sum_j m_j \bm{v}_{ij} \nabla W_{ij}, +-\frac{1}{\rho_i} \sum_j m_j \bm{v}_{ij} \cdot \nabla W_{ij}, ``` where ``\bm{v}_{ij} = \bm{v}_i - \bm{v}_j``. Together, the following discretized version of the continuity equation for a particle ``i`` is achieved: ```math -\frac{\rho_i(t + \Delta t) - \rho_i(t)}{\Delta t} = \sum_j m_j \bm{v}_{ij}(t+\Delta t) \nabla W_{ij}. +\frac{\rho_i(t + \Delta t) - \rho_i(t)}{\Delta t} += \sum_j m_j \bm{v}_{ij}(t+\Delta t) \cdot \nabla W_{ij}. ``` Note that the linear system is only solved for fluid particles, so ``i`` always represents @@ -50,12 +54,12 @@ Using the semi-implicit Euler method, we can obtain the velocity in the next tim ``` where ``\bm{F}_i^{\text{adv}}`` denotes all non-pressure forces such as gravity, viscosity, surface -tension and more, while ``\bm{F}_i^p``denotes the unknown pressure forces, which we +tension and more, while ``\bm{F}_i^p`` denotes the unknown pressure forces, which we want to solve for. Note that the IISPH is an incompressible method, which means that the density of the -fluid remain constant over time. By assuming a fixed reference density ``\rho_0`` for all -fluid particle over the whole time of the simulation, the density value at the next time +fluid remains constant over time. By assuming a fixed reference density ``\rho_0`` for all +fluid particles over the whole simulation, the density value at the next time step ``\rho_i(t + \Delta t)`` also has to be this rest density. So ``\rho_0`` can be plugged in for ``\rho_i(t + \Delta t)`` in the equation above. @@ -72,7 +76,7 @@ Using this predicted velocity and the continuity equation, a predicted density c in a similar way as ```math -\rho_i^{\text{adv}}(t + \Delta t)= \rho_i(t) + \Delta t \sum_j m_j \bm{v}_{ij}^{\text{adv}} \nabla W_{ij}(t). +\rho_i^{\text{adv}}(t + \Delta t)= \rho_i(t) + \Delta t \sum_j m_j \bm{v}_{ij}^{\text{adv}}(t+\Delta t) \cdot \nabla W_{ij}(t). ``` To achieve the rest density, the unknown pressure forces must counteract the compression @@ -81,7 +85,7 @@ the predicted density and the reference density. Therefore, the following equation needs to be fulfilled: ```math -\Delta t ^2 \sum_j m_j \left( \frac{\bm{F}_i^p(t)}{m_i} - \frac{\bm{F}_j^p(t)}{m_j} \right) \nabla W_{ij}(t) = \rho_0 - \rho_i^{\text{adv}}. +\Delta t ^2 \sum_j m_j \left( \frac{\bm{F}_i^p(t)}{m_i} - \frac{\bm{F}_j^p(t)}{m_j} \right) \cdot \nabla W_{ij}(t) = \rho_0 - \rho_i^{\text{adv}}. ``` This expression is derived by substituting the reference density ``\rho_0`` for @@ -130,10 +134,10 @@ The pressure acceleration is given by: The ``d_{ii}p_i`` value describes the displacement of particle ``i`` because of the particle ``i`` and ``d_{ij}p_j`` describes the influence from the neighboring particles ``j``. -Using this new values the linear system can be rewritten as +Using these values, the linear system can be rewritten as ```math -\rho_0 - \rho_i^{\text{adv}} = \sum_j m_j \left( d_{ii}p_i + \sum_k d_{ik}p_k - d_{jj}p_j - \sum_k d_{jk}p_k \right) \nabla W_{ij}, +\rho_0 - \rho_i^{\text{adv}} = \sum_j m_j \left( d_{ii}p_i + \sum_k d_{ik}p_k - d_{jj}p_j - \sum_k d_{jk}p_k \right) \cdot \nabla W_{ij}, ``` where the first sum over ``k`` loops over all neighbor particles of ``i`` and @@ -150,21 +154,21 @@ To separate this sum, it can be written as With this separation, the equation for the linear system can again be rewritten as ```math -\rho_0 - \rho_i^{\text{adv}} = p_i \sum_j m_j ( d_{ii} - d_{ji})\nabla W_{ij} + \sum_j m_j \left ( \sum_k d_{ik} p_k - d_{jj} p_j - \sum_{k \neq i} d_{jk}p_k \right) \nabla W_{ij}. +\rho_0 - \rho_i^{\text{adv}} = p_i \sum_j m_j ( d_{ii} - d_{ji}) \cdot \nabla W_{ij} + \sum_j m_j \left ( \sum_k d_{ik} p_k - d_{jj} p_j - \sum_{k \neq i} d_{jk}p_k \right) \cdot \nabla W_{ij}. ``` In this formulation all coefficients that are getting multiplied with the pressure value ``p_i`` are separated from the other. The diagonal elements ``a_{ii}`` can therefore be defined as: ```math -a_{ii} = \sum_j m_j ( d_{ii} - d_{ji})\nabla W_{ij}. +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: ```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) \nabla W_{ij} \right). +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). ``` Because interactions are local, limited to particles within the kernel support defined by @@ -209,7 +213,7 @@ as only isolated or almost isolated particles are affected. ## Boundary Handling The previously introduced formulation did not distinguish between fluid and boundary -particles. To account boundary interactions correctly, a few modifications to the previous +particles. To account for boundary interactions correctly, a few modifications to the previous equations are required. First, the discretized form of the continuity equation must be adapted for the case in which @@ -220,7 +224,7 @@ neighboring fluid particles (indexed by ``f``) and neighboring boundary particle The updated discretized continuity equation becomes: ```math -\frac{\rho_i(t + \Delta t) - \rho_i(t)}{\Delta t} = \sum_f m_f \bm{v}_{if}(t+\Delta t) \nabla W_{if} + \sum_b m_b \bm{v}_{ib}(t+\Delta t) \nabla W_{ib}. +\frac{\rho_i(t + \Delta t) - \rho_i(t)}{\Delta t} = \sum_f m_f \bm{v}_{if}(t+\Delta t) \cdot \nabla W_{if} + \sum_b m_b \bm{v}_{ib}(t+\Delta t) \cdot \nabla W_{ib}. ``` Since boundary particles have zero velocity, the difference between the fluid @@ -229,13 +233,13 @@ particle's velocity ``\bm{v}_{ib}(t+\Delta t) = \bm{v}_{i}(t+\Delta t)``. Accordingly, the predicted density ``\rho^{\text{adv}}`` becomes: ```math -\rho_i^{\text{adv}} = \rho_i (t) + \Delta t \sum_f m_f \bm{v}_{if}^{\text{adv}} \nabla W_{if}(t) + \Delta t \sum_b m_b \bm{v}_{i}^{\text{adv}} \nabla W_{ib}(t). +\rho_i^{\text{adv}} = \rho_i (t) + \Delta t \sum_f m_f \bm{v}_{if}^{\text{adv}} \cdot \nabla W_{if}(t) + \Delta t \sum_b m_b \bm{v}_{i}^{\text{adv}} \cdot \nabla W_{ib}(t). ``` This leads to the following updated formulation of the linear system: ```math -\Delta t^2 \sum_f m_f \left( \frac{\bm{F}_i^p(t)}{m_i} - \frac{\bm{F}_f^p(t)}{m_f} \right) \nabla W_{if} + \Delta t^2 \sum_b m_b \frac{\bm{F}_i^p(t)}{m_i} \nabla W_{ib} = \rho_0 - \rho_i^{\text{adv}}. +\Delta t^2 \sum_f m_f \left( \frac{\bm{F}_i^p(t)}{m_i} - \frac{\bm{F}_f^p(t)}{m_f} \right) \cdot \nabla W_{if} + \Delta t^2 \sum_b m_b \frac{\bm{F}_i^p(t)}{m_i} \cdot \nabla W_{ib} = \rho_0 - \rho_i^{\text{adv}}. ``` Note that, since boundary particles are fixed, the force ``F_b^p`` is zero and does not appear @@ -244,12 +248,12 @@ in this equation. The pressure force acting on a fluid particle is computed as: ```math -\bm{F}_i^p(t) = -\sum_f m_f \left( \frac{p_i(t)}{\rho_i^2(t)} + \frac{p_f(t)}{\rho_f^2(t)} \right) \nabla W_{if}(t) - \sum_b m_b \left( \frac{p_i(t)}{\rho_i^2(t)} + \frac{p_b(t)}{\rho_b^2(t)} \right) \nabla W_{ib}(t). +\bm{F}_i^p(t) = -m_i \sum_f m_f \left( \frac{p_i(t)}{\rho_i^2(t)} + \frac{p_f(t)}{\rho_f^2(t)} \right) \nabla W_{if}(t) - m_i \sum_b m_b \left( \frac{p_i(t)}{\rho_i^2(t)} + \frac{p_b(t)}{\rho_b^2(t)} \right) \nabla W_{ib}(t). ``` This also leads to an updated version of the equation for the diagonal elements: ```math -a_{ii} = \sum_j m_j ( d_{ii} - d_{ji})\nabla W_{ij} + \sum_b m_b (-d_{bi}) \nabla W_{ib}. +a_{ii} = \sum_f m_f ( d_{ii} - d_{fi}) \cdot \nabla W_{if} + \sum_b m_b d_{ii} \cdot \nabla W_{ib}. ``` From this point forward, the computation of the coefficients required for the Jacobi scheme @@ -263,11 +267,12 @@ When using pressure mirroring, the pressure value ``p_b`` of a boundary particle above is defined to be equal to the pressure of the corresponding fluid particle ``p_i``. In other words, the boundary particle "mirrors" the pressure of the fluid particle interacting with it. As a result, the coefficient that describes the influence of a particle's own -pressure value ``p_i`` ​must also include contributions from boundary particles. Therefore, -the equation for calculating the coefficient ``d_{ii}`` must be adjusted as follows: +pressure value ``p_i`` must include a doubled contribution from each boundary particle. +Therefore, ``d_{ii}`` becomes ```math -d_{ii} = -\Delta t^2 \sum_f \frac{m_f}{\rho_i^2} \nabla W_{if} - \Delta t^2 \sum_b \frac{m_b}{\rho_i^2} \nabla W_{ib}. +d_{ii} = -\Delta t^2 \sum_f \frac{m_f}{\rho_i^2} \nabla W_{if} + - 2\Delta t^2 \sum_b \frac{m_b}{\rho_i^2} \nabla W_{ib}. ``` The corresponding relaxed Jacobi iteration for pressure mirroring then becomes: @@ -275,30 +280,29 @@ The corresponding relaxed Jacobi iteration for pressure mirroring then becomes: ```math \begin{align*} p_i^{l+1} = (1 - \omega) p_i^l + \omega \frac{1}{a_{ii}} &\left( \rho_0 - \rho_i^{\text{adv}} - - \sum_f m_f \left( \sum_k d_{ik} p_k^l - d_{ff}p_f^l - \sum_{k \neq i} d_{fk} p_k^l \right) \nabla W_{if} \right. \\ -& \quad - \left. \sum_b m_b \sum_f d_{if} p_f^l \nabla W_{ib} \right). + - \sum_f m_f \left( \sum_k d_{ik} p_k^l - d_{ff}p_f^l - \sum_{k \neq i} d_{fk} p_k^l \right) \cdot \nabla W_{if} \right. \\ +& \quad - \left. \sum_b m_b \left( \sum_f d_{if} p_f^l \right) \cdot \nabla W_{ib} \right). \end{align*} ``` ### Pressure Zeroing If pressure zeroing is used instead, the pressure value of a boundary particle ``p_b`` -​is assumed to be zero. Consequently, boundary particles do not contribute to the pressure -forces acting on fluid particles. -In this case, the computation of the coefficient ``d_{ii}`` remains unchanged and is given by: +is assumed to be zero. In the linear system, this removes the boundary pressure unknowns, +but boundary particles still contribute through the ``p_i/\rho_i^2`` part of the pressure +acceleration. Therefore ``d_{ii}`` is ```math -d_{ii} = -\Delta t^2 \sum_f \frac{m_f}{\rho_i^2} \nabla W_{if}. +d_{ii} = -\Delta t^2 \sum_f \frac{m_f}{\rho_i^2} \nabla W_{if} + - \Delta t^2 \sum_b \frac{m_b}{\rho_i^2} \nabla W_{ib}. ``` -The equation for the relaxed Jacobi iteration remains the same as in the pressure mirroring -approach. However, the contribution from boundary particles vanishes due to their zero -pressure: +The corresponding relaxed Jacobi iteration reads ```math \begin{align*} p_i^{l+1} = (1 - \omega) p_i^l + \omega \frac{1}{a_{ii}} &\left( \rho_0 - \rho_i^{\text{adv}} - - \sum_f m_f \left( \sum_k d_{ik} p_k^l - d_{ff}p_f^l - \sum_{k \neq i} d_{fk} p_k^l \right) \nabla W_{if} \right. \\ -& \quad - \left. \sum_b m_b \sum_j d_{if} p_f^l \nabla W_{ib} \right). + - \sum_f m_f \left( \sum_k d_{ik} p_k^l - d_{ff}p_f^l - \sum_{k \neq i} d_{fk} p_k^l \right) \cdot \nabla W_{if} \right. \\ +& \quad - \left. \sum_b m_b \left( \sum_f d_{if} p_f^l \right) \cdot \nabla W_{ib} \right). \end{align*} ``` @@ -307,8 +311,8 @@ The density calculators [`AdamiPressureExtrapolation`](@ref) and [`BernoulliPres can also be used with IISPH. When using one of these pressure extrapolation methods the calculation of the PPE is exactly the same as when using pressure zeroing. -So within the linear systems the pressure values are equal to zero (``p_b=0``) and therefore -are not considered in the calculations. Only in the pressure acceleration, the extrapolated +So within the linear system the boundary pressures are treated as zero (``p_b=0``), exactly +as in pressure zeroing. Only in the pressure acceleration, the extrapolated pressure values are used for the boundary particles. For more information on these two methods, refer to the docs for the [boundary models](@ref boundary_models). @@ -318,7 +322,7 @@ The [`PressureBoundaries`](@ref) density calculator was introduced by only be used with IISPH. In the standard IISPH method the PPE is solved only for fluid particles. The pressure values for the boundary particles are then approximated, for example by using -pressure mirroing. +pressure mirroring. With `PressureBoundaries`, however, the linear system is extended to include the boundary particles as well. This means that the pressure values of both the fluid and the boundary particles are computed directly by solving the PPE. @@ -333,22 +337,22 @@ also solved as part of the linear system. This leads to the following condition for the boundary particles ``b``: ```math -\Delta t^2 \sum_f m_f \left( \frac{\bm{F}_i^p(t)}{m_i} - \frac{\bm{F}_f^p(t)}{m_f} \right) \nabla W_{if} + \Delta t^2 \sum_b m_b \left( \frac{\bm{F}_i^p(t)}{m_i} - \frac{\bm{F}_b^p(t)}{m_b} \right) \nabla W_{ib} = \rho_0 - \rho_i^{\text{adv}}. +\Delta t^2 \sum_f m_f \left( \frac{\bm{F}_i^p(t)}{m_i} - \frac{\bm{F}_f^p(t)}{m_f} \right) \cdot \nabla W_{if} + \Delta t^2 \sum_b m_b \left( \frac{\bm{F}_i^p(t)}{m_i} - \frac{\bm{F}_b^p(t)}{m_b} \right) \cdot \nabla W_{ib} = \rho_0 - \rho_i^{\text{adv}}. ``` -Note that in this case ``i`` is a boundary particle,``f`` are its fluid neighbors, and ``b`` +Note that in this case ``i`` is a boundary particle, ``f`` are its fluid neighbors, and ``b`` its boundary neighbors. Since the pressure force for boundary particles is zero (as mentioned before), and because in this case ``i`` and ``b`` are both boundary particles, the PPE simplifies to ```math -\Delta t^2 \sum_f m_f - \frac{\bm{F}_f^p(t)}{m_f} \nabla W_{if} = \rho_0 - \rho_i^{\text{adv}}. +-\Delta t^2 \sum_f m_f \frac{\bm{F}_f^p(t)}{m_f} \cdot \nabla W_{if} = \rho_0 - \rho_i^{\text{adv}}. ``` If we substitute the definition of the pressure force from above, we obtain ```math -\Delta t^2 \sum_f m_f \left( \sum_k m_k \left( \frac{p_f(t)}{\rho_j^2(t)} + \frac{p_k(t)}{\rho_k^2(t)} \right) \nabla W_{fk}\right) \nabla W_{if} = \rho_0 - \rho_i^{\text{adv}}. +\Delta t^2 \sum_f m_f \left( \sum_k m_k \left( \frac{p_f(t)}{\rho_f^2(t)} + \frac{p_k(t)}{\rho_k^2(t)} \right) \nabla W_{fk}\right) \cdot \nabla W_{if} = \rho_0 - \rho_i^{\text{adv}}. ``` where ``k`` represents all neighboring particles (fluid and boundary) of fluid particle ``f``. @@ -358,23 +362,23 @@ indirectly as neighbors of fluid particles), their ``d_{ii}`` values are zero. T diagonal elements simplify to ```math -a_{ii} = \sum_f \left( -d_{fi}\right) \nabla W_{if}. +a_{ii} = - \sum_f m_f d_{fi} \cdot \nabla W_{if}. ``` The off-diagonal term ``\sum_{j \neq i} a_{ij} p_j`` in the relaxed Jacobi iteration for boundary particles takes the following form ```math -\sum_{j \neq i} a_{ij} p_j = \sum_f m_f \left( d_{ff} - \sum_{f_j} d_{ff_j}p_{f_j}\right) \nabla W_{if}. +\sum_{j \neq i} a_{ij} p_j = \sum_f m_f \left( - d_{ff} p_f - \sum_{k \neq i} d_{fk}p_k \right) \cdot \nabla W_{if}. ``` But not only the addition of the boundary particles to the linear system changes when using pressure boundaries, also the PPE for the fluid particles is changing slightly. Since the boundary particles have now their own pressure values, ``p_b`` is no longer -eliminated (as in pressure zeroing or pressure extrapolation with ``p_b=0``) nor simply +eliminated (as in pressure zeroing or pressure extrapolation with ``p_b=0``) nor simply replaced by the fluid particle's pressure (as in pressure mirroring with ``p_b=p_i``). -Instead, ``p_b``remains part of the PPE. +Instead, ``p_b`` remains part of the PPE. This has no effect on the diagonal elements ``a_{ii}``, which are identical to those of the other density calculators. The ``d_{ii}`` values are also computed in the same way as for @@ -383,11 +387,11 @@ However, the off-diagonal term ``\sum_{j \neq i} a_{ij} p_j`` in the relaxed Ja changes slightly. For pressure boundaries it takes the form ```math - \sum_{j \neq i} a_{ij} p_j = \sum_f m_f \left( \sum_k d_{ik} p_k - d_{ff} p_f - \sum_{k \neq f} d_{fk} p_k \right) \nabla W_{if} \\ - + \sum_b m_b \left( \sum_k d_{ik} p_k \right) \nabla W_{ib}, +\sum_{j \neq i} a_{ij} p_j = \sum_f m_f \left( \sum_k d_{ik} p_k - d_{ff} p_f - \sum_{k \neq f} d_{fk} p_k \right) \cdot \nabla W_{if} ++ \sum_b m_b \left( \sum_k d_{ik} p_k \right) \cdot \nabla W_{ib}, ``` -where ``k``represents all neighboring particles of ``i`` (both fluid and boundary). +where ``k`` represents all neighboring particles of ``i`` (both fluid and boundary). ```@docs PressureBoundaries -``` \ No newline at end of file +``` diff --git a/docs/src/systems/total_lagrangian_sph.md b/docs/src/systems/total_lagrangian_sph.md index 86167d164b..56b30ebde0 100644 --- a/docs/src/systems/total_lagrangian_sph.md +++ b/docs/src/systems/total_lagrangian_sph.md @@ -106,16 +106,16 @@ Pages = [joinpath("schemes", "structure", "total_lagrangian_sph", "penalty_force ## Viscosity Another technique that is used to correct the hourglass instability is artificial viscosity. -Hereby, a viscosity term designed for fluids (see [Viscosity](@ref viscosity_sph)) is applied. -First, the force ``f_{ab}^{\text{fluid}}`` exerted by particle ``b`` on particle ``a`` +Here, a viscosity term designed for fluids (see [Viscosity](@ref viscosity_sph)) is applied. +First, the force ``\bm{F}_{ab}^{\text{fluid}}`` exerted by particle ``b`` on particle ``a`` due to artificial viscosity is computed as if both particles were fluid particles (see [Viscosity](@ref viscosity_sph) for the relevant equations). Then, according to [Lin et al. (2015)](@cite Lin2015), this force can be applied to TLSPH with the following conversion: ```math -f_{ab}^{\text{AV}} = \det(F_a) F_a^{-1} f_{ab}^{\text{fluid}}, +\bm{F}_{ab}^{\text{AV}} = \det(\bm{F}_a) \bm{F}_a^{-T} \bm{F}_{ab}^{\text{fluid}}, ``` -where ``F_a`` is the deformation gradient at particle ``a``. +where ``\bm{F}_a`` is the deformation gradient at particle ``a``. We found that artificial viscosity is not effective at correcting the incorrect particle positions due to hourglass modes. diff --git a/docs/src/systems/weakly_compressible_sph.md b/docs/src/systems/weakly_compressible_sph.md index 2c69f4b3c9..7044ef9efa 100644 --- a/docs/src/systems/weakly_compressible_sph.md +++ b/docs/src/systems/weakly_compressible_sph.md @@ -52,7 +52,8 @@ pressure field. It is highly recommended to use density diffusion when using WCS ### Formulation All density diffusion terms extend the continuity equation (see [`ContinuityDensity`](@ref)) -by an additional term +by an additional term. In the literature, this is typically written for a fixed smoothing +length ``h`` as ```math \frac{\mathrm{d}\rho_a}{\mathrm{d}t} = \sum_{b} m_b v_{ab} \cdot \nabla W_{ab} + \delta h c \sum_{b} V_b \psi_{ab} \cdot \nabla W_{ab}, @@ -62,12 +63,20 @@ the density diffusion method (see [`AbstractDensityDiffusion`](@ref TrixiParticles.AbstractDensityDiffusion) for available terms). Also, ``\rho_a`` denotes the density of particle ``a`` and ``r_{ab} = r_a - r_b`` is the difference of the coordinates, ``v_{ab} = v_a - v_b`` of the velocities of particles -``a`` and ``b``. +``a`` and ``b``. When particle-wise smoothing lengths are used, the corresponding +pairwise form is +```math +\frac{\mathrm{d}\rho_a}{\mathrm{d}t} = \sum_{b} m_b v_{ab} \cdot \nabla W_{ab} + + \delta c \sum_{b} \bar{h}_{ab} V_b \psi_{ab} \cdot \nabla W_{ab}, +``` +with ``\bar{h}_{ab} = \frac{1}{2}(h_a + h_b)``. TrixiParticles.jl uses this pairwise +average, which is the natural extension of the standard formula to variable smoothing +lengths. For fixed smoothing length, both expressions coincide. ### Numerical Results All density diffusion terms remove numerical noise in the pressure field and produce more -accurate results than weakly commpressible SPH without density diffusion. +accurate results than weakly compressible SPH without density diffusion. This can be demonstrated with dam break examples in 2D and 3D. Here, ``δ = 0.1`` has been used for all terms. Note that, due to added stability, the adaptive time integration method that was used here @@ -129,11 +138,11 @@ in such simulations. ### Mathematical formulation We use the following formulation by [Sun et al. (2018)](@cite Sun2018). -After each time step, a correction term ``\delta r_a`` is added to the position ``r_a`` +After each time step, a correction term ``\delta \bm{r}_a`` is added to the position ``\bm{r}_a`` of particle ``a``, which is given by ```math -\delta r_a = -4 \Delta t \, v_\text{max} h - \sum_b \left( 1 + R \left( \frac{W_{ab}}{W(\Delta x_a)} \right)^n \right) \nabla W_{ab} +\delta \bm{r}_a = -4 \Delta t \, v_\text{max} h + \sum_b \left( 1 + R \left( \frac{W_{ab}}{W(\Delta x_a)} \right)^n \right) \nabla_a W_{ab} \frac{m_b}{\rho_a + \rho_b}, ``` where: @@ -141,14 +150,27 @@ where: - ``v_\text{max}`` is the maximum velocity over all particles, - ``h`` is the smoothing length, - ``R`` and ``n`` are constants, which are set to ``0.2`` and ``4`` respectively, -- ``W(\Delta x_a)`` is the smoothing kernel of the particle size of particle ``a``, - which can be interpreted as the target particle spacing that we want to achieve. -- ``\nabla W_{ab}`` is the gradient of the smoothing kernel, +- ``\Delta x_a`` is the target particle spacing associated with particle ``a``, +- ``W(\Delta x_a)`` is the smoothing kernel evaluated at that target particle spacing, +- ``\nabla_a W_{ab}`` is the gradient of the smoothing kernel with respect to particle ``a``, - ``m_b`` is the mass of particle ``b``, - ``\rho_a, \rho_b`` is the density of particles ``a`` and ``b``, respectively. -Note that we replaced ``\text{CFL} \cdot \text{Ma}`` by ``\Delta t \cdot v_\text{max} / h``, -as explained in [Sun2018](@cite Sun2018) on page 29, right above Equation 9. +In TrixiParticles.jl, the same correction is applied through a shifting velocity +```math +\delta \bm{r}_a = \Delta t \, \delta \bm{v}_a, +``` +with +```math +\delta \bm{v}_a = - v_\text{max} \frac{(2h)^2}{2\Delta x} + \sum_b \left( 1 + \frac{2}{10} \left( \frac{W_{ab}}{W(\Delta x)} \right)^4 \right) + \frac{m_b}{\rho_a + \rho_b} \nabla_a W_{ab}. +``` +This corresponds to the same PST idea, but with the commonly used constants fixed to +``R = 0.2`` and ``n = 4``. The prefactor is written in a form that keeps the magnitude of +the shifting correction consistent when the smoothing-length factor changes. In particular, +``\text{CFL} \cdot \text{Ma}`` is replaced by ``\Delta t \, v_\text{max} / h``, as explained +by [Sun et al. (2018)](@cite Sun2018) on page 29, immediately above Equation 9. The ``\delta``-SPH method (WCSPH with density diffusion) together with this formulation of PST is commonly referred to as ``\delta^+``-SPH. @@ -178,18 +200,45 @@ is a constant background pressure field. The tilde in the second term of the right-hand side indicates that the material derivative has an advection part. -The discretized form of the last term is +In the literature, the discretized form of the last term is ```math -\frac{1}{\rho_a} \nabla p_{\text{background}} \approx -\frac{p_{\text{background}}}{m_a} \sum_b \left(V_a^2 + V_b^2 \right) \nabla_a W_{ab}, ``` -where ``V_a``, ``V_b`` denote the volume of particles ``a`` and ``b`` respectively. +where ``V_a`` and ``V_b`` denote the particle volumes of particles ``a`` and ``b`` respectively. Note that although in the continuous case ``\nabla p_{\text{background}} = 0``, -the discretization is not 0th-order consistent for **non**-uniform particle distribution, -which means that there is a non-vanishing contribution only when particles are disordered. -That also means that ``p_{\text{background}}`` occurs as pre-factor to correct -the trajectory of a particle resulting in uniform pressure distributions. -Suggested is a background pressure which is in the order of the reference pressure, -but it can be chosen arbitrarily large when the time-step criterion is adjusted. +the discretization is not 0th-order consistent for non-uniform particle distributions. +This means that a non-vanishing contribution appears only when the particles are disordered, +so ``p_{\text{background}}`` acts as a prefactor that regularizes the trajectories and promotes +more uniform particle distributions. + +In TrixiParticles.jl, this background-pressure contribution is evaluated through the +currently selected pressure-acceleration formulation instead of hard-coding the +specific ``(V_a^2 + V_b^2)`` discretization. This keeps the TVF term consistent with the +pressure discretization used in the rest of the scheme and automatically adapts it when a +different pressure-acceleration formulation is chosen. For the default +continuity-density formulation, +```math +\left.\frac{\mathrm{d}\bm{v}_a}{\mathrm{d} t}\right|_{p} += - \sum_b m_b \frac{p_a + p_b}{\rho_a \rho_b} \nabla_a W_{ab}, +``` +setting ``p_a = p_b = 1`` gives the discrete background-pressure operator +```math +- \sum_b \frac{2m_b}{\rho_a \rho_b} \nabla_a W_{ab}. +``` +In the TVF update this operator is multiplied by the background pressure and the time-step +factor from the transport-velocity correction. TrixiParticles.jl then removes the explicit +dependence on ``\Delta t`` by using the CFL estimate employed by [Adami et al. (2013)](@cite Adami2013), +```math +\Delta t \leq \frac{1}{4} \frac{h}{c_s}, +``` +as an equality, so that the resulting shifting-velocity contribution becomes +```math +\delta \bm{v}_a = - \frac{p_{\text{background}}}{8} \frac{h}{c_s} +\sum_b \frac{2m_b}{\rho_a \rho_b} \nabla_a W_{ab}, +``` +where ``h`` is the smoothing length and ``c_s`` is the speed of sound. This explains +both why the implemented pairwise factor differs from ``V_a^2 + V_b^2`` and why the +factor ``h / c_s`` appears in the final expression. The inviscid momentum equation with an additional convection term for a particle moving with ``\tilde{v}`` is @@ -200,18 +249,36 @@ where the tensor ``\bm{A} = \rho v\left(\tilde{v}-v\right)^T`` is a consequence of the modified advection velocity and can be interpreted as the convection of momentum with the relative velocity ``\tilde{v}-v``. -The discretized form of the momentum equation for a particle ``a`` reads as +The discretized form of the momentum equation for a particle ``a`` reads ```math -\frac{\tilde{\mathrm{d}} v_a}{\mathrm{d}t} = \frac{1}{m_a} \sum_b \left(V_a^2 + V_b^2 \right) \left[ -\tilde{p}_{ab} \nabla_a W_{ab} + \frac{1}{2} \left(\bm{A}_a + \bm{A}_b \right) \cdot \nabla_a W_{ab} \right]. +\frac{\tilde{\mathrm{d}} v_a}{\mathrm{d}t} += \frac{1}{m_a} \sum_b \left(V_a^2 + V_b^2 \right) +\left[ -\tilde{p}_{ab} \nabla_a W_{ab} ++ \frac{1}{2} \left(\bm{A}_a + \bm{A}_b \right) \cdot \nabla_a W_{ab} \right]. ``` Here, ``\tilde{p}_{ab}`` is the density-weighted pressure ```math \tilde{p}_{ab} = \frac{\rho_b p_a + \rho_a p_b}{\rho_a + \rho_b}, ``` -with the density ``\rho_a``, ``\rho_b`` and the pressure ``p_a``, ``p_b`` of particles ``a`` -and ``b``, respectively. ``\bm{A}_a`` and ``\bm{A}_b`` are the convection tensors -for particle ``a`` and ``b``, respectively, and are given, e.g., for particle ``a``, -as ``\bm{A}_a = \rho v_a\left(\tilde{v}_a-v_a\right)^T``. +with ``\rho_a``, ``\rho_b`` and ``p_a``, ``p_b`` denoting the densities and pressures +of particles ``a`` and ``b``, respectively. + +As for the background-pressure term above, TrixiParticles.jl evaluates the additional +convection term through the selected pressure-acceleration formulation instead of +hard-coding the ``(V_a^2 + V_b^2)`` form. For the default continuity-density +formulation, this gives +```math +\left.\frac{\tilde{\mathrm{d}} v_a}{\mathrm{d}t}\right|_{\bm{A}} += - \sum_b \frac{m_b}{\rho_a \rho_b} +\left(\bm{A}_a + \bm{A}_b \right) \cdot \nabla_a W_{ab}. +``` +Here, ``\bm{A}_a`` and ``\bm{A}_b`` are the convection tensors of particles ``a`` and ``b``, +with, for example, +```math +\bm{A}_a = \rho_a \bm{v}_a \left(\tilde{\bm{v}}_a - \bm{v}_a\right)^T. +``` +Thus, the implemented form is again the operator-consistent version of the literature +discretization for the pressure-acceleration formulation used by the scheme. To apply the TVF, use the keyword argument `shifting_technique` in the constructor of a system that supports it. @@ -247,13 +314,13 @@ Only the combination of PST and TIC is able to produce physical results. The force that particle ``a`` experiences from particle ``b`` due to pressure is given by ```math -f_{ab} = -m_a m_b \frac{p_a + p_b}{\rho_a \rho_b} \nabla W_{ab} +\bm{f}_{ab} = -m_a m_b \frac{p_a + p_b}{\rho_a \rho_b} \nabla_a W_{ab} ``` for the WCSPH method with [`ContinuityDensity`](@ref). -The TIC formulation changes this force to +The TIC formulation changes this term to ```math -f_{ab} = -m_a m_b \frac{|p_a| + p_b}{\rho_a \rho_b} \nabla W_{ab}. +\bm{f}_{ab}^{\mathrm{TIC}} = -m_a m_b \frac{|p_a| + p_b}{\rho_a \rho_b} \nabla_a W_{ab}. ``` Note that this formulation is asymmetric and sacrifices conservation of linear and angular momentum. diff --git a/docs/src/time_integration.md b/docs/src/time_integration.md index 006c83261d..04f68e990e 100644 --- a/docs/src/time_integration.md +++ b/docs/src/time_integration.md @@ -106,7 +106,7 @@ half step for ``v``, yielding u^{1/2} &= u^0 + \frac{1}{2} \Delta t\, \operatorname{drift}(v^0, u^0, t^0), \\ v^{1/2} &= v^0 + \frac{1}{2} \Delta t\, \operatorname{kick}(v^0, u^0, t^0), \\ v^1 &= v^0 + \Delta t\, \operatorname{kick} \left( v^{1/2}, u^{1/2}, t^0 + \frac{1}{2} \Delta t \right), \\ -u^1 &= u^{1/2} + \frac{1}{2} \Delta t\, \operatorname{drift}(v^{1}, u^{1}, t^0 + \Delta t). +u^1 &= u^{1/2} + \frac{1}{2} \Delta t\, \operatorname{drift}(v^{1}, u^{1/2}, t^0 + \Delta t). \end{align*} ``` This scheme is implemented in OrdinaryDiffEq.jl as `LeapfrogDriftKickDrift` and yields @@ -132,7 +132,7 @@ v^{1/2} &= v^0 + \frac{1}{2} \Delta t\, \operatorname{kick}(v^0, u^0, t^0), \\ \rho^{1/2} &= \rho^0 + \frac{1}{2} \Delta t\, R(v^0, u^0, t^0), \\ v^1 &= v^0 + \Delta t\, \operatorname{kick} \left( v^{1/2}, u^{1/2}, t^0 + \frac{1}{2} \Delta t \right), \\ \rho^1 &= \rho^0 \frac{2 - \varepsilon^{1/2}}{2 + \varepsilon^{1/2}}, \\ -u^1 &= u^{1/2} + \frac{1}{2} \Delta t\, \operatorname{drift}(v^{1}, u^{1}, t^0 + \Delta t), +u^1 &= u^{1/2} + \frac{1}{2} \Delta t\, \operatorname{drift}(v^{1}, u^{1/2}, t^0 + \Delta t), \end{align*} ``` where From 1182397e78d636ef07c09670df44b4de3ad4f923 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Mon, 9 Mar 2026 18:52:07 +0100 Subject: [PATCH 03/59] improve the formatting --- README.md | 16 +++++----- docs/src/development.md | 25 +++++++-------- docs/src/getting_started.md | 7 ++-- docs/src/gpu.md | 4 +-- docs/src/index.md | 12 ++++--- docs/src/install.md | 12 +++---- docs/src/systems/boundary.md | 6 ++-- docs/src/systems/entropically_damped_sph.md | 10 +++--- .../systems/implicit_incompressible_sph.md | 2 +- docs/src/systems/total_lagrangian_sph.md | 32 +++++++++---------- docs/src/visualization.md | 10 +++--- 11 files changed, 71 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 27a54e37e6..9392432023 100644 --- a/README.md +++ b/README.md @@ -43,24 +43,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/src/development.md b/docs/src/development.md index 09201863c1..384c48a371 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: @@ -24,28 +23,28 @@ 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) Edit the `version` string in the +4. 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`. -5) Go to GitHub and add a comment to the commit that you would like to become the new +5. 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 @@ -53,14 +52,14 @@ To create a new release for TrixiParticles.jl, perform the following steps: ``` @JuliaRegistrator register ``` -6) Wait for the magic to happen! Specifically, JuliaRegistrator will create a new PR to the +6. 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. -7) Once the new release has been created, the new version can be obtained through the Julia +7. Once the new release has been created, the new version can be obtained through the Julia package manager as usual. -8) To make sure people do not mistake the latest state of `main` as the latest release, we +8. 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/getting_started.md b/docs/src/getting_started.md index 08ebfeb066..96cdcc6c02 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 0b14c12c70..ef08cd6834 100644 --- a/docs/src/gpu.md +++ b/docs/src/gpu.md @@ -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 19b4a32b87..d2e7a13acc 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -27,18 +27,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 ad379e6859..951e11da3d 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, [OrdinaryDiffEq.jl](https://github.com/SciML/OrdinaryDiffEq.jl) (used for time integration) @@ -17,7 +17,7 @@ julia> using Pkg julia> Pkg.add(["TrixiParticles", "OrdinaryDiffEq", "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 @@ -39,19 +39,19 @@ The advantage of using a separate `run` directory is that you can add other rela packages (e.g., OrdinaryDiffEq.jl, see above) to the project in that folder while keeping a reproducible environment that is easy to share with others. -## Optional software/packages +## Optional Software/Packages - [OrdinaryDiffEq.jl](https://github.com/SciML/OrdinaryDiffEq.jl) -- Julia package of ordinary differential equation solvers used in the 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/systems/boundary.md b/docs/src/systems/boundary.md index 46463b10ac..f21509bfe7 100644 --- a/docs/src/systems/boundary.md +++ b/docs/src/systems/boundary.md @@ -139,8 +139,8 @@ pressure force, ```math \bm{F}_a^{p} = -m_a \sum_b m_b \left( \frac{p_a}{\rho_a^2} + \frac{p_b}{\rho_b^2} \right) \nabla_a W_{ab}, ``` -to replace the unknown density $\rho_b$ if $b$ is a boundary particle by the reference density -and the unknown pressure $p_b$ if $b$ is a boundary particle by the pressure $p_a$ of the +to replace the unknown density ``\rho_b`` if ``b`` is a boundary particle by the reference density +and the unknown pressure ``p_b`` if ``b`` is a boundary particle by the pressure ``p_a`` of the interacting fluid particle. The force therefore becomes ```math \bm{F}_a^{p} = -m_a \sum_f m_f \left( \frac{p_a}{\rho_a^2} + \frac{p_f}{\rho_f^2} \right) \nabla_a W_{af} @@ -197,7 +197,7 @@ where ``m_a`` and ``m_b`` are the masses of fluid particle ``a`` and boundary pa respectively, ``\bm{r}_{ab} = \bm{r}_a - \bm{r}_b`` is the difference of the coordinates of particles ``a`` and ``b``, ``d`` denotes the boundary particle spacing and ``n`` denotes the number of dimensions (see [Monaghan & Kajtar, 2009](@cite Monaghan2009), Equation (3.1) and [Valizadeh & Monaghan, 2015](@cite Valizadeh2015)). -Note that the repulsive acceleration $\tilde{f}_{ab}$ does not depend on the masses of +Note that the repulsive acceleration ``\tilde{\bm{f}}_{ab}`` does not depend on the masses of the boundary particles. Here, ``\Phi`` denotes the 1D Wendland C4 kernel, normalized to ``1.77`` for ``q=0`` ([Monaghan & Kajtar, 2009](@cite Monaghan2009), Section 4), with ``\Phi(r, h) = w(r/h)`` and diff --git a/docs/src/systems/entropically_damped_sph.md b/docs/src/systems/entropically_damped_sph.md index be1772bcd7..dd9dc8ccac 100644 --- a/docs/src/systems/entropically_damped_sph.md +++ b/docs/src/systems/entropically_damped_sph.md @@ -49,11 +49,11 @@ It is found in the numerical experiments of [Ramachandran (2019)](@cite Ramachan is a good choice for a wide range of Reynolds numbers (0.0125 to 10000). !!! note - > The EDAC formulation keeps the density constant and this eliminates the need for the continuity equation - > or the use of a summation density to find the pressure. However, in SPH discretizations, ``m/\rho`` - > is typically used as a proxy for the particle volume. The density of the fluids can - > therefore be computed using the summation density approach. [Ramachandran2019](@cite) - + The EDAC formulation keeps the density constant and therefore eliminates the need for + the continuity equation or the use of a summation density to find the pressure. + However, in SPH discretizations, ``m/\rho`` is typically used as a proxy for the + particle volume. The density of the fluids can therefore still be computed using the + summation-density approach [Ramachandran2019](@cite). ```@autodocs Modules = [TrixiParticles] diff --git a/docs/src/systems/implicit_incompressible_sph.md b/docs/src/systems/implicit_incompressible_sph.md index 575d7d84a9..3bd3c15262 100644 --- a/docs/src/systems/implicit_incompressible_sph.md +++ b/docs/src/systems/implicit_incompressible_sph.md @@ -165,7 +165,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/total_lagrangian_sph.md b/docs/src/systems/total_lagrangian_sph.md index 56b30ebde0..8bd443ee5a 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/visualization.md b/docs/src/visualization.md index 9dda94bfba..ac58596792 100644 --- a/docs/src/visualization.md +++ b/docs/src/visualization.md @@ -1,6 +1,6 @@ # 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. @@ -18,6 +18,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 +29,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 +46,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 +79,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 From 54502ee0f398ffb62cf698b504171e9a394a3fa1 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Sun, 15 Mar 2026 12:21:39 +0100 Subject: [PATCH 04/59] better overview page --- docs/src/tutorial.md | 56 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/docs/src/tutorial.md b/docs/src/tutorial.md index 1166010291..8d4cc50960 100644 --- a/docs/src/tutorial.md +++ b/docs/src/tutorial.md @@ -1,8 +1,54 @@ # Tutorials -## General -- [Setting up your simulation from scratch](tutorials/tut_setup.md) -- [Modifying or extending components of TrixiParticles.jl within a simulation file](tutorials/tut_custom_kernel.md) +Choose a tutorial based on the task in front of you. -## Preprocessing -- [Particle packing tutorial](tutorials/tut_packing.md) +> New to TrixiParticles.jl? Start with [Setting up your simulation from scratch](tutorials/tut_setup.md). + +## Recommended Path + +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. + +## Tutorials + +### [Setting up your simulation from scratch](tutorials/tut_setup.md) + +```@raw html +Rectangular tank setup used in the first tutorial +``` + +Build a complete weakly compressible SPH dam break setup from particle spacing through semidiscretization, callbacks, and time integration. + +- Focus: initial conditions, systems, semidiscretization, callbacks +- Choose this if: you want the full workflow from a minimal example + +### [Modifying or extending components of TrixiParticles.jl within a simulation file](tutorials/tut_custom_kernel.md) + +```@raw html +Kernel comparison plot from the custom kernel tutorial +``` + +Start from an existing simulation and replace pieces such as the smoothing kernel directly in the file you run. + +- Focus: `trixi_include`, custom kernels, rapid iteration +- Choose this if: you want to prototype changes without rewriting a full setup + +### [Particle packing tutorial](tutorials/tut_packing.md) + +```@raw html +Packed particle configuration for a complex geometry +``` + +Go from a geometry file to a packed particle distribution using signed distance fields together with boundary and interior sampling. + +- Focus: geometry import, signed distance fields, boundary sampling, `ParticlePackingSystem` +- Choose this if: you need body-fitted particles for complex shapes + +See also [Getting started](getting_started.md) and [Examples](examples.md). From 9d9a0404a882de27eb11765b8831b8dee718617a Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Mon, 16 Mar 2026 00:10:52 +0100 Subject: [PATCH 05/59] add --- docs/literate/src/tut_2d_geometry.jl | 155 ++++++++++++++++++ docs/make.jl | 4 + docs/src/tutorial.md | 16 +- .../data/coastline_profile_2d.asc | 20 +++ .../data/curved_pipe_channel_2d.asc | 20 +++ .../data/curved_pipe_outer_2d.asc | 14 ++ 6 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 docs/literate/src/tut_2d_geometry.jl create mode 100644 examples/preprocessing/data/coastline_profile_2d.asc create mode 100644 examples/preprocessing/data/curved_pipe_channel_2d.asc create mode 100644 examples/preprocessing/data/curved_pipe_outer_2d.asc diff --git a/docs/literate/src/tut_2d_geometry.jl b/docs/literate/src/tut_2d_geometry.jl new file mode 100644 index 0000000000..d591d0f6d7 --- /dev/null +++ b/docs/literate/src/tut_2d_geometry.jl @@ -0,0 +1,155 @@ +# # [Setting up a 2D simulation from geometry files](@id tut_2d_geometry) + +# In this tutorial, we build two genuine 2D setups from geometry files: +# 1. a curved pipe, where one geometry file defines the outer wall envelope and a second +# one defines the empty channel cut out of it, +# 2. a dam-break basin with a coastline profile, where one geometry file defines the +# filled coastline wall together with the seawall on the right. +# +# For a real 2D setup, we use 2D geometry formats such as `.asc` or `.dxf`. +# STL files are surface meshes and therefore naturally lead to thin 3D setups instead. + +# First, we import TrixiParticles.jl together with +# [OrdinaryDiffEq.jl](https://github.com/SciML/OrdinaryDiffEq.jl) +# and [Plots.jl](https://docs.juliaplots.org/stable/). +using TrixiParticles +using OrdinaryDiffEq +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 true 2D solid region instead of a hollow shell around 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 a solid L-shaped 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. the `setdiff` operation 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, a single 2D geometry file defines a filled coastline wall: +# the beach profile on top, 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 gives the coastline bed and the right wall as a solid region. +# We add the left wall explicitly as a rectangular particle block and place a +# 1.5x taller rectangular dam-break water column next to 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 + +# To keep the example focused, we continue with the coastline setup. +# From this point on, the simulation setup is the same as in other 2D simulation files. +setup = coast_setup +tspan = (0.0, 0.03) +nothing # hide + +# We define the state equation, smoothing kernel, and viscosity for a +# 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, 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. +boundary_model = BoundaryModelDummyParticles(setup.wall.density, setup.wall.mass, + state_equation=state_equation, + AdamiPressureExtrapolation(), + smoothing_kernel, smoothing_length) +boundary_system = WallBoundarySystem(setup.wall, boundary_model) +nothing # hide + +# ## Semidiscretization + +# With fluid and wall particles defined, we can build the +# [`Semidiscretization`](@ref TrixiParticles.Semidiscretization) exactly as in other tutorials. +semi = Semidiscretization(fluid_system, boundary_system) +ode = semidiscretize(semi, tspan) +nothing # hide + +# ## Time integration + +# The setup is now complete. +# To start the simulation, run for example +# ```julia +# callbacks = CallbackSet(InfoCallback(interval=10)) +# sol = solve(ode, RDPK3SpFSAL35(), save_everystep=false, callback=callbacks) +# ``` +# This is the same final step as in [the basic setup tutorial](@ref tut_setup). +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/make.jl b/docs/make.jl index ddffab849a..4ac1ae3709 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -40,6 +40,8 @@ Literate.markdown(joinpath("docs", "literate", "src", "tut_custom_kernel.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)") @@ -87,6 +89,8 @@ makedocs(sitename="TrixiParticles.jl", "tut_custom_kernel.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/tutorial.md b/docs/src/tutorial.md index 8d4cc50960..b62dbb642c 100644 --- a/docs/src/tutorial.md +++ b/docs/src/tutorial.md @@ -8,7 +8,8 @@ Choose a tutorial based on the task in front of you. 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, turn them into filled wall regions, and combine them with standard 2D fluid blocks. +4. [Particle packing tutorial](tutorials/tut_packing.md): build a body-fitted particle configuration for complex geometries. ## Tutorials @@ -38,6 +39,19 @@ Start from an existing simulation and replace pieces such as the smoothing kerne - Focus: `trixi_include`, custom kernels, rapid iteration - Choose this if: you want to prototype changes without rewriting a full setup +### [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 genuine 2D setups such as a curved pipe and a coastline dam break. + +- Focus: `load_geometry`, `ComplexShape`, `setdiff`, 2D `Polygon`s +- Choose this if: you want a true 2D setup from line-based geometry data + ### [Particle packing tutorial](tutorials/tut_packing.md) ```@raw html diff --git a/examples/preprocessing/data/coastline_profile_2d.asc b/examples/preprocessing/data/coastline_profile_2d.asc new file mode 100644 index 0000000000..7986e07261 --- /dev/null +++ b/examples/preprocessing/data/coastline_profile_2d.asc @@ -0,0 +1,20 @@ +# ASCII +0.18 -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.18 0.03 0 +0.18 -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 From 91ef5c6b9c4bea943502cfe6413a9f4de36050eb Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Fri, 17 Apr 2026 02:03:29 +0200 Subject: [PATCH 06/59] Refactor boundary model initialization to use high-level builders for dummy particles --- docs/literate/src/tut_rigid_body_fsi.jl | 11 ++--- docs/literate/src/tut_setup.jl | 13 ++--- examples/fluid/dam_break_2d.jl | 13 ++--- examples/fluid/dam_break_3d.jl | 9 ++-- examples/fluid/falling_water_column_2d.jl | 9 ++-- examples/fluid/falling_water_spheres_2d.jl | 14 +++--- examples/fluid/hydrostatic_water_column_2d.jl | 10 ++-- examples/fluid/lid_driven_cavity_2d.jl | 24 ++++------ examples/fluid/moving_wall_2d.jl | 9 ++-- .../fluid/periodic_array_of_cylinders_2d.jl | 10 ++-- examples/fluid/periodic_channel_2d.jl | 11 ++--- examples/fluid/pipe_flow_2d.jl | 13 ++--- examples/fluid/poiseuille_flow_2d.jl | 13 ++--- examples/fluid/poiseuille_flow_3d.jl | 13 ++--- examples/fsi/dam_break_gate_2d.jl | 18 +++---- examples/fsi/dam_break_plate_2d.jl | 9 ++-- examples/fsi/falling_rigid_spheres_2d.jl | 9 ++-- .../fsi/falling_rotating_rigid_squares_2d.jl | 9 ++-- examples/fsi/falling_spheres_2d.jl | 9 ++-- examples/fsi/hydrostatic_water_column_2d.jl | 9 ++-- src/schemes/boundary/open_boundary/system.jl | 29 +++++++++-- .../boundary/wall_boundary/dummy_particles.jl | 44 +++++++++++++++++ test/systems/boundary_system.jl | 48 +++++++++++++++++++ test/systems/open_boundary_system.jl | 26 ++++++++-- 24 files changed, 217 insertions(+), 165 deletions(-) diff --git a/docs/literate/src/tut_rigid_body_fsi.jl b/docs/literate/src/tut_rigid_body_fsi.jl index 5f11a3d561..d7bf0365fc 100644 --- a/docs/literate/src/tut_rigid_body_fsi.jl +++ b/docs/literate/src/tut_rigid_body_fsi.jl @@ -164,14 +164,9 @@ 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, - state_equation=state_equation, - boundary_density_calculator, - fluid_smoothing_kernel, - fluid_smoothing_length) - -boundary_system = WallBoundarySystem(tank.boundary, tank_boundary_model) +boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system, + boundary_density_calculator=boundary_density_calculator) +boundary_system = WallBoundarySystem(tank.boundary, boundary_model) nothing # hide function rigid_body_boundary_model(shape) diff --git a/docs/literate/src/tut_setup.jl b/docs/literate/src/tut_setup.jl index 67e54bdcd3..d05e97a2f9 100644 --- a/docs/literate/src/tut_setup.jl +++ b/docs/literate/src/tut_setup.jl @@ -131,14 +131,11 @@ 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, - state_equation=state_equation, - AdamiPressureExtrapolation(), - smoothing_kernel, smoothing_length) +# 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 diff --git a/examples/fluid/dam_break_2d.jl b/examples/fluid/dam_break_2d.jl index 56303b1877..d2a5db8e51 100644 --- a/examples/fluid/dam_break_2d.jl +++ b/examples/fluid/dam_break_2d.jl @@ -79,15 +79,10 @@ boundary_density_calculator = AdamiPressureExtrapolation() viscosity_wall = nothing # For a no-slip boundary condition, define a wall viscosity: # viscosity_wall = viscosity_fluid -boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass, - state_equation=state_equation, - boundary_density_calculator, - smoothing_kernel, smoothing_length, - correction=nothing, - reference_particle_spacing=0, - viscosity=viscosity_wall) - -boundary_system = WallBoundarySystem(tank.boundary, boundary_model, +wall_boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system, + boundary_density_calculator=boundary_density_calculator, + viscosity=viscosity_wall) +boundary_system = WallBoundarySystem(tank.boundary, wall_boundary_model, adhesion_coefficient=0.0) # ========================================================================================== diff --git a/examples/fluid/dam_break_3d.jl b/examples/fluid/dam_break_3d.jl index a073c001ec..6974d7a449 100644 --- a/examples/fluid/dam_break_3d.jl +++ b/examples/fluid/dam_break_3d.jl @@ -56,12 +56,9 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid, fluid_density_calculator, # ========================================================================================== # ==== Boundary boundary_density_calculator = AdamiPressureExtrapolation() -boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass, - state_equation=state_equation, - boundary_density_calculator, - smoothing_kernel, smoothing_length) - -boundary_system = WallBoundarySystem(tank.boundary, boundary_model) +wall_boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system, + boundary_density_calculator=boundary_density_calculator) +boundary_system = WallBoundarySystem(tank.boundary, wall_boundary_model) # ========================================================================================== # ==== Simulation diff --git a/examples/fluid/falling_water_column_2d.jl b/examples/fluid/falling_water_column_2d.jl index f712f88d93..428eef6dea 100644 --- a/examples/fluid/falling_water_column_2d.jl +++ b/examples/fluid/falling_water_column_2d.jl @@ -53,12 +53,9 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid, fluid_density_calculator, # ========================================================================================== # ==== Boundary boundary_density_calculator = AdamiPressureExtrapolation() -boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass, - state_equation=state_equation, - boundary_density_calculator, - smoothing_kernel, smoothing_length) - -boundary_system = WallBoundarySystem(tank.boundary, boundary_model) +wall_boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system, + boundary_density_calculator=boundary_density_calculator) +boundary_system = WallBoundarySystem(tank.boundary, wall_boundary_model) # ========================================================================================== # ==== Simulation diff --git a/examples/fluid/falling_water_spheres_2d.jl b/examples/fluid/falling_water_spheres_2d.jl index 778d404d69..332cfb2bde 100644 --- a/examples/fluid/falling_water_spheres_2d.jl +++ b/examples/fluid/falling_water_spheres_2d.jl @@ -77,14 +77,12 @@ sphere = WeaklyCompressibleSPHSystem(sphere2, fluid_density_calculator, # ==== Boundary boundary_density_calculator = AdamiPressureExtrapolation() wall_viscosity = nu -boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass, - state_equation=state_equation, - boundary_density_calculator, - fluid_smoothing_kernel, fluid_smoothing_length, - viscosity=ViscosityAdami(nu=wall_viscosity), - reference_particle_spacing=fluid_particle_spacing) - -boundary_system = WallBoundarySystem(tank.boundary, boundary_model, +wall_boundary_model = BoundaryModelDummyParticles(tank.boundary; + fluid_system=sphere_surface_tension, + boundary_density_calculator=boundary_density_calculator, + state_equation=state_equation, + viscosity=ViscosityAdami(nu=wall_viscosity)) +boundary_system = WallBoundarySystem(tank.boundary, wall_boundary_model, adhesion_coefficient=1.0) # ========================================================================================== diff --git a/examples/fluid/hydrostatic_water_column_2d.jl b/examples/fluid/hydrostatic_water_column_2d.jl index a62dbab249..dd02bdcda4 100644 --- a/examples/fluid/hydrostatic_water_column_2d.jl +++ b/examples/fluid/hydrostatic_water_column_2d.jl @@ -59,12 +59,10 @@ 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, - state_equation=state_equation, - boundary_density_calculator, - smoothing_kernel, smoothing_length, - viscosity=viscosity_wall) -boundary_system = WallBoundarySystem(tank.boundary, boundary_model, +wall_boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system, + boundary_density_calculator=boundary_density_calculator, + viscosity=viscosity_wall) +boundary_system = WallBoundarySystem(tank.boundary, wall_boundary_model, prescribed_motion=nothing) # ========================================================================================== diff --git a/examples/fluid/lid_driven_cavity_2d.jl b/examples/fluid/lid_driven_cavity_2d.jl index 9849ca06f7..949e908e8f 100644 --- a/examples/fluid/lid_driven_cavity_2d.jl +++ b/examples/fluid/lid_driven_cavity_2d.jl @@ -85,22 +85,14 @@ is_moving(t) = true lid_movement = PrescribedMotion(lid_movement_function, is_moving) -boundary_model_cavity = BoundaryModelDummyParticles(cavity.boundary.density, - cavity.boundary.mass, - AdamiPressureExtrapolation(), - viscosity=viscosity, - state_equation=state_equation, - smoothing_kernel, smoothing_length) - -boundary_model_lid = BoundaryModelDummyParticles(lid.density, lid.mass, - AdamiPressureExtrapolation(), - viscosity=viscosity, - state_equation=state_equation, - smoothing_kernel, smoothing_length) - -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 85663188ea..0fce5d323b 100644 --- a/examples/fluid/moving_wall_2d.jl +++ b/examples/fluid/moving_wall_2d.jl @@ -59,12 +59,9 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid, fluid_density_calculator, # ========================================================================================== # ==== Boundary boundary_density_calculator = AdamiPressureExtrapolation() -boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass, - state_equation=state_equation, - boundary_density_calculator, - smoothing_kernel, smoothing_length) - -boundary_system = WallBoundarySystem(tank.boundary, boundary_model, +wall_boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system, + boundary_density_calculator=boundary_density_calculator) +boundary_system = WallBoundarySystem(tank.boundary, wall_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 b2dad5bca6..7e5d33a5b6 100644 --- a/examples/fluid/periodic_array_of_cylinders_2d.jl +++ b/examples/fluid/periodic_array_of_cylinders_2d.jl @@ -74,13 +74,9 @@ fluid_system = WeaklyCompressibleSPHSystem(fluid, ContinuityDensity(), state_equ # ========================================================================================== # ==== Boundary -boundary_model = BoundaryModelDummyParticles(boundary.density, boundary.mass, - AdamiPressureExtrapolation(), - viscosity=ViscosityAdami(; nu), - smoothing_kernel, smoothing_length, - state_equation=state_equation) - -boundary_system = WallBoundarySystem(boundary, boundary_model) +wall_boundary_model = BoundaryModelDummyParticles(boundary; fluid_system=fluid_system, + viscosity=ViscosityAdami(; nu)) +boundary_system = WallBoundarySystem(boundary, wall_boundary_model) # ========================================================================================== # ==== Simulation diff --git a/examples/fluid/periodic_channel_2d.jl b/examples/fluid/periodic_channel_2d.jl index 5dab8cfc4a..dcaf2225e2 100644 --- a/examples/fluid/periodic_channel_2d.jl +++ b/examples/fluid/periodic_channel_2d.jl @@ -59,13 +59,10 @@ 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, - state_equation=state_equation, - boundary_density_calculator, - smoothing_kernel, smoothing_length, - viscosity=viscosity_wall) - -boundary_system = WallBoundarySystem(tank.boundary, boundary_model) +wall_boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system, + boundary_density_calculator=boundary_density_calculator, + viscosity=viscosity_wall) +boundary_system = WallBoundarySystem(tank.boundary, wall_boundary_model) # ========================================================================================== # ==== Simulation diff --git a/examples/fluid/pipe_flow_2d.jl b/examples/fluid/pipe_flow_2d.jl index d81b72b094..043447aa50 100644 --- a/examples/fluid/pipe_flow_2d.jl +++ b/examples/fluid/pipe_flow_2d.jl @@ -137,20 +137,15 @@ 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(), - state_equation=state_equation, - viscosity=viscosity_boundary, - smoothing_kernel, smoothing_length) - -boundary_system = WallBoundarySystem(wall, boundary_model) +wall_boundary_model = BoundaryModelDummyParticles(wall; fluid_system=fluid_system, + viscosity=viscosity_boundary) +boundary_system = WallBoundarySystem(wall, wall_boundary_model) # ========================================================================================== # ==== Simulation diff --git a/examples/fluid/poiseuille_flow_2d.jl b/examples/fluid/poiseuille_flow_2d.jl index d808b1be71..e901f3fb68 100644 --- a/examples/fluid/poiseuille_flow_2d.jl +++ b/examples/fluid/poiseuille_flow_2d.jl @@ -146,20 +146,15 @@ 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(), - state_equation=state_equation, - viscosity=viscosity, - smoothing_kernel, smoothing_length) - -boundary_system = WallBoundarySystem(wall_boundary, boundary_model) +wall_boundary_model = BoundaryModelDummyParticles(wall_boundary; fluid_system=fluid_system, + viscosity=viscosity) +boundary_system = WallBoundarySystem(wall_boundary, wall_boundary_model) # ========================================================================================== # ==== Simulation diff --git a/examples/fluid/poiseuille_flow_3d.jl b/examples/fluid/poiseuille_flow_3d.jl index c49e7f66bb..9ebfc5841f 100644 --- a/examples/fluid/poiseuille_flow_3d.jl +++ b/examples/fluid/poiseuille_flow_3d.jl @@ -162,18 +162,13 @@ 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(), - state_equation=state_equation, - viscosity=viscosity, - smoothing_kernel, smoothing_length) - -boundary_system = WallBoundarySystem(wall_boundary, boundary_model) +wall_boundary_model = BoundaryModelDummyParticles(wall_boundary; fluid_system=fluid_system, + viscosity=viscosity) +boundary_system = WallBoundarySystem(wall_boundary, wall_boundary_model) # ========================================================================================== # ==== Simulation diff --git a/examples/fsi/dam_break_gate_2d.jl b/examples/fsi/dam_break_gate_2d.jl index 4667b1af42..b5ed85dae8 100644 --- a/examples/fsi/dam_break_gate_2d.jl +++ b/examples/fsi/dam_break_gate_2d.jl @@ -118,18 +118,12 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid, fluid_density_calculator, # ========================================================================================== # ==== Boundary boundary_density_calculator = AdamiPressureExtrapolation() -boundary_model_tank = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass, - state_equation=state_equation, - boundary_density_calculator, - smoothing_kernel, smoothing_length) - -boundary_model_gate = BoundaryModelDummyParticles(gate.density, gate.mass, - state_equation=state_equation, - boundary_density_calculator, - smoothing_kernel, smoothing_length) - -boundary_system_tank = WallBoundarySystem(tank.boundary, boundary_model_tank) -boundary_system_gate = WallBoundarySystem(gate, boundary_model_gate, +tank_boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system, + boundary_density_calculator=boundary_density_calculator) +gate_boundary_model = BoundaryModelDummyParticles(gate; fluid_system=fluid_system, + boundary_density_calculator=boundary_density_calculator) +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 44d86c5317..573229379d 100644 --- a/examples/fsi/dam_break_plate_2d.jl +++ b/examples/fsi/dam_break_plate_2d.jl @@ -88,12 +88,9 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid, fluid_density_calculator, # ========================================================================================== # ==== Boundary boundary_density_calculator = AdamiPressureExtrapolation() -boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass, - state_equation=state_equation, - boundary_density_calculator, - smoothing_kernel, smoothing_length) - -boundary_system = WallBoundarySystem(tank.boundary, boundary_model) +wall_boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system, + boundary_density_calculator=boundary_density_calculator) +boundary_system = WallBoundarySystem(tank.boundary, wall_boundary_model) # ========================================================================================== # ==== Structure diff --git a/examples/fsi/falling_rigid_spheres_2d.jl b/examples/fsi/falling_rigid_spheres_2d.jl index 026e35793e..1f22fa617b 100644 --- a/examples/fsi/falling_rigid_spheres_2d.jl +++ b/examples/fsi/falling_rigid_spheres_2d.jl @@ -67,12 +67,9 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid, fluid_density_calculator, # ========================================================================================== # ==== Boundary boundary_density_calculator = AdamiPressureExtrapolation() -boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass, - state_equation=state_equation, - boundary_density_calculator, - fluid_smoothing_kernel, fluid_smoothing_length) - -boundary_system = WallBoundarySystem(tank.boundary, boundary_model) +wall_boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system, + boundary_density_calculator=boundary_density_calculator) +boundary_system = WallBoundarySystem(tank.boundary, wall_boundary_model) # ========================================================================================== # ==== Rigid Structures diff --git a/examples/fsi/falling_rotating_rigid_squares_2d.jl b/examples/fsi/falling_rotating_rigid_squares_2d.jl index 5eb1160ea0..945b4515ec 100644 --- a/examples/fsi/falling_rotating_rigid_squares_2d.jl +++ b/examples/fsi/falling_rotating_rigid_squares_2d.jl @@ -83,12 +83,9 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid, fluid_density_calculator, # ========================================================================================== # ==== Boundary boundary_density_calculator = AdamiPressureExtrapolation() -boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass, - state_equation=state_equation, - boundary_density_calculator, - fluid_smoothing_kernel, fluid_smoothing_length) - -boundary_system = WallBoundarySystem(tank.boundary, boundary_model) +wall_boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system, + boundary_density_calculator=boundary_density_calculator) +boundary_system = WallBoundarySystem(tank.boundary, wall_boundary_model) # ========================================================================================== # ==== Rigid Structures diff --git a/examples/fsi/falling_spheres_2d.jl b/examples/fsi/falling_spheres_2d.jl index e938cbf6f8..28f43b4ef8 100644 --- a/examples/fsi/falling_spheres_2d.jl +++ b/examples/fsi/falling_spheres_2d.jl @@ -71,12 +71,9 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid, fluid_density_calculator, # ========================================================================================== # ==== Boundary boundary_density_calculator = BernoulliPressureExtrapolation() -boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass, - state_equation=state_equation, - boundary_density_calculator, - fluid_smoothing_kernel, fluid_smoothing_length) - -boundary_system = WallBoundarySystem(tank.boundary, boundary_model) +wall_boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system, + boundary_density_calculator=boundary_density_calculator) +boundary_system = WallBoundarySystem(tank.boundary, wall_boundary_model) # ========================================================================================== # ==== Structure diff --git a/examples/fsi/hydrostatic_water_column_2d.jl b/examples/fsi/hydrostatic_water_column_2d.jl index 22a132fd3e..fd087820dd 100644 --- a/examples/fsi/hydrostatic_water_column_2d.jl +++ b/examples/fsi/hydrostatic_water_column_2d.jl @@ -113,11 +113,10 @@ else damping_coefficient=0.05)) end -boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass, - state_equation=state_equation, - boundary_density_calculator, - smoothing_kernel, smoothing_length_fluid) -boundary_system = WallBoundarySystem(tank.boundary, boundary_model) +wall_boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system, + boundary_density_calculator=boundary_density_calculator, + state_equation=state_equation) +boundary_system = WallBoundarySystem(tank.boundary, wall_boundary_model) boundary_model_structure = BoundaryModelDummyParticles(hydrodynamic_densities, hydrodynamic_masses, state_equation=state_equation, diff --git a/src/schemes/boundary/open_boundary/system.jl b/src/schemes/boundary/open_boundary/system.jl index 75d3ba6074..1d0802f249 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 ? diff --git a/src/schemes/boundary/wall_boundary/dummy_particles.jl b/src/schemes/boundary/wall_boundary/dummy_particles.jl index 6927e0d7e5..dd49f631fc 100644 --- a/src/schemes/boundary/wall_boundary/dummy_particles.jl +++ b/src/schemes/boundary/wall_boundary/dummy_particles.jl @@ -51,6 +51,41 @@ struct BoundaryModelDummyParticles{DC, ELTYPE <: Real, VECTOR, SE, K, V, COR, C} cache :: C 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), + 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), + 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, + 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, @@ -85,6 +120,15 @@ function BoundaryModelDummyParticles(initial_density, hydrodynamic_mass, smoothing_length, viscosity, correction, cache) 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 Base.ndims(boundary_model::BoundaryModelDummyParticles) = ndims(boundary_model.smoothing_kernel) @doc raw""" diff --git a/test/systems/boundary_system.jl b/test/systems/boundary_system.jl index bcf9b82098..99428d52cc 100644 --- a/test/systems/boundary_system.jl +++ b/test/systems/boundary_system.jl @@ -28,6 +28,54 @@ 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, 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, 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/open_boundary_system.jl b/test/systems/open_boundary_system.jl index e9481be303..6b53e855c3 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,7 @@ 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 +42,7 @@ 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 +61,7 @@ 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 +80,7 @@ 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,5 +98,19 @@ └──────────────────────────────────────────────────────────────────────────────────────────────────┘""" @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 end From 3482840125e9bb2de11b665debb86471544c4e52 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Fri, 17 Apr 2026 02:14:32 +0200 Subject: [PATCH 07/59] format --- test/systems/open_boundary_system.jl | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/test/systems/open_boundary_system.jl b/test/systems/open_boundary_system.jl index 6b53e855c3..3a346f9ef9 100644 --- a/test/systems/open_boundary_system.jl +++ b/test/systems/open_boundary_system.jl @@ -19,7 +19,8 @@ open_boundary_layers=4, boundary_type=InFlow()) system = OpenBoundarySystem(inflow; buffer_size=0, boundary_model=BoundaryModelCharacteristicsLastiwka(), - fluid_system=FluidSystemMock2(nothing, nothing, nothing)) + fluid_system=FluidSystemMock2(nothing, nothing, + nothing)) show_compact = "OpenBoundarySystem{2}() with 80 particles" @test repr(system) == show_compact @@ -42,7 +43,8 @@ boundary_type=OutFlow()) system = OpenBoundarySystem(outflow; buffer_size=0, boundary_model=BoundaryModelMirroringTafuni(), - fluid_system=FluidSystemMock2(nothing, nothing, nothing)) + fluid_system=FluidSystemMock2(nothing, nothing, + nothing)) show_compact = "OpenBoundarySystem{2}() with 80 particles" @test repr(system) == show_compact @@ -61,7 +63,8 @@ system = OpenBoundarySystem(outflow, inflow; buffer_size=0, boundary_model=BoundaryModelMirroringTafuni(), - fluid_system=FluidSystemMock2(nothing, nothing, nothing)) + fluid_system=FluidSystemMock2(nothing, nothing, + nothing)) show_compact = "OpenBoundarySystem{2}() with 160 particles" @test repr(system) == show_compact @@ -80,7 +83,8 @@ system = OpenBoundarySystem(outflow, inflow; buffer_size=0, boundary_model=BoundaryModelDynamicalPressureZhang(), - fluid_system=FluidSystemMock2(nothing, nothing, nothing)) + fluid_system=FluidSystemMock2(nothing, nothing, + nothing)) show_compact = "OpenBoundarySystem{2}() with 160 particles" @test repr(system) == show_compact @@ -109,8 +113,8 @@ "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)) + fluid_system=FluidSystemMock2(nothing, + nothing, + nothing)) end end From cca7c02d103321c321d95f2c385a973078a471e9 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Mon, 27 Apr 2026 11:21:35 +0200 Subject: [PATCH 08/59] Clarify GPU support for devices Removed 'Experimental' from GPU support description. --- docs/src/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/index.md b/docs/src/index.md index 19b4a32b87..0a06ed3175 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -20,7 +20,7 @@ Its main features include: - Particle sampling of complex geometries from `.stl`, `.asc`, and `.dxf` files - Output formats: - VTK -- Experimental GPU support for NVIDIA, AMD, and Apple devices +- GPU support for NVIDIA, AMD, and Apple devices ## Examples ```@raw html From 2d10888ab3b4f5357a3d417129be8058ce084dc3 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Mon, 4 May 2026 11:59:32 +0200 Subject: [PATCH 09/59] fix regression --- test/systems/boundary_system.jl | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/systems/boundary_system.jl b/test/systems/boundary_system.jl index 99428d52cc..8ce39b479e 100644 --- a/test/systems/boundary_system.jl +++ b/test/systems/boundary_system.jl @@ -43,7 +43,8 @@ exponent=1) viscosity = ViscosityAdami(nu=1e-6) - fluid_system = WeaklyCompressibleSPHSystem(fluid_ic, ContinuityDensity(), + fluid_system = WeaklyCompressibleSPHSystem(fluid_ic; + density_calculator=ContinuityDensity(), state_equation, smoothing_kernel, smoothing_length, correction=KernelCorrection(), @@ -69,8 +70,8 @@ @test system.adhesion_coefficient == 0.3 @test system.cache.color == 2 - edac_system = EntropicallyDampedSPHSystem(fluid_ic, smoothing_kernel, - smoothing_length, 15.0) + 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 From e926518129776a4f9a524ee875aab80edb97b166 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Mon, 4 May 2026 12:20:00 +0200 Subject: [PATCH 10/59] format --- examples/fsi/dam_break_gate_2d.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/fsi/dam_break_gate_2d.jl b/examples/fsi/dam_break_gate_2d.jl index 7194b26065..9cc9e66271 100644 --- a/examples/fsi/dam_break_gate_2d.jl +++ b/examples/fsi/dam_break_gate_2d.jl @@ -129,7 +129,7 @@ gate_boundary_model = BoundaryModelDummyParticles(gate; fluid_system=fluid_syste boundary_system_tank = WallBoundarySystem(tank.boundary, tank_boundary_model) boundary_system_gate = WallBoundarySystem(gate, gate_boundary_model, - prescribed_motion=gate_movement) + prescribed_motion=gate_movement) # ========================================================================================== # ==== Structure From a17bdd80e947300cf137a3315172b1c2e7cda80e Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Mon, 4 May 2026 13:38:28 +0200 Subject: [PATCH 11/59] missing state equation for IISHP --- src/schemes/fluid/implicit_incompressible_sph/system.jl | 2 ++ test/systems/iisph_system.jl | 1 + 2 files changed, 3 insertions(+) diff --git a/src/schemes/fluid/implicit_incompressible_sph/system.jl b/src/schemes/fluid/implicit_incompressible_sph/system.jl index 6e54ce7b66..e12ffbd283 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 diff --git a/test/systems/iisph_system.jl b/test/systems/iisph_system.jl index 2bcd740552..3d8dc65aa3 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" From cfbe73b845b8d284f16588aaf8e9db753eeb4f4d Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 12 May 2026 09:38:09 +0200 Subject: [PATCH 12/59] Fix custom quantity reductions --- src/general/custom_quantities.jl | 25 ++++---- test/general/custom_quantities.jl | 100 ++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 12 deletions(-) diff --git a/src/general/custom_quantities.jl b/src/general/custom_quantities.jl index cb60127dc2..0d67c59f77 100644 --- a/src/general/custom_quantities.jl +++ b/src/general/custom_quantities.jl @@ -20,11 +20,10 @@ function kinetic_energy(system::AbstractStructureSystem, dv_ode, du_ode, v_ode, u_ode, semi, t) v = wrap_v(v_ode, system, semi) mass = system.mass - energy = zero(eltype(system)) - return sum(each_active_particle(system)) do particle + return sum(each_active_particle(system); init=zero(eltype(system))) do particle v_i = current_velocity(v, system, particle) - energy += mass[particle] * dot(v_i, v_i) / 2 + return mass[particle] * dot(v_i, v_i) / 2 end end @@ -39,7 +38,7 @@ end Returns the total mass of all particles in a system. """ function total_mass(system, dv_ode, du_ode, v_ode, u_ode, semi, t) - return sum(system.mass) + return sum(active_values(system.mass, system)) end function total_mass(system::AbstractBoundarySystem, dv_ode, du_ode, v_ode, u_ode, semi, t) @@ -63,7 +62,7 @@ Returns the maximum pressure over all particles in a system. """ function max_pressure(system::AbstractFluidSystem, dv_ode, du_ode, v_ode, u_ode, semi, t) v = wrap_v(v_ode, system, semi) - return maximum(current_pressure(v, system)) + return maximum(active_values(current_pressure(v, system), system)) end function max_pressure(system, dv_ode, du_ode, v_ode, u_ode, semi, t) @@ -77,7 +76,7 @@ Returns the minimum pressure over all particles in a system. """ function min_pressure(system::AbstractFluidSystem, dv_ode, du_ode, v_ode, u_ode, semi, t) v = wrap_v(v_ode, system, semi) - return minimum(current_pressure(v, system)) + return minimum(active_values(current_pressure(v, system), system)) end function min_pressure(system, dv_ode, du_ode, v_ode, u_ode, semi, t) @@ -91,8 +90,8 @@ Returns the average pressure over all particles in a system. """ function avg_pressure(system::AbstractFluidSystem, dv_ode, du_ode, v_ode, u_ode, semi, t) v = wrap_v(v_ode, system, semi) - sum_ = sum(current_pressure(v, system)) - return sum_ / nparticles(system) + pressure = active_values(current_pressure(v, system), system) + return sum(pressure) / length(pressure) end function avg_pressure(system, dv_ode, du_ode, v_ode, u_ode, semi, t) @@ -106,7 +105,7 @@ Returns the maximum density over all particles in a system. """ function max_density(system::AbstractFluidSystem, dv_ode, du_ode, v_ode, u_ode, semi, t) v = wrap_v(v_ode, system, semi) - return maximum(current_density(v, system)) + return maximum(active_values(current_density(v, system), system)) end function max_density(system, dv_ode, du_ode, v_ode, u_ode, semi, t) @@ -120,7 +119,7 @@ Returns the minimum density over all particles in a system. """ function min_density(system::AbstractFluidSystem, dv_ode, du_ode, v_ode, u_ode, semi, t) v = wrap_v(v_ode, system, semi) - return minimum(current_density(v, system)) + return minimum(active_values(current_density(v, system), system)) end function min_density(system, dv_ode, du_ode, v_ode, u_ode, semi, t) @@ -134,10 +133,12 @@ Returns the average_density over all particles in a system. """ function avg_density(system::AbstractFluidSystem, dv_ode, du_ode, v_ode, u_ode, semi, t) v = wrap_v(v_ode, system, semi) - sum_ = sum(current_density(v, system)) - return sum_ / nparticles(system) + density = active_values(current_density(v, system), system) + return sum(density) / length(density) end function avg_density(system, dv_ode, du_ode, v_ode, u_ode, semi, t) return NaN end + +@inline active_values(values, system) = view(values, each_active_particle(system)) diff --git a/test/general/custom_quantities.jl b/test/general/custom_quantities.jl index 33435c8fd1..14f641a633 100644 --- a/test/general/custom_quantities.jl +++ b/test/general/custom_quantities.jl @@ -116,4 +116,104 @@ @test isnan(avg_density(boundary_system, dv_ode, du_ode, v_ode, u_ode, semi, t)) end end + + @testset "Structure kinetic energy" begin + struct EnergyStructureMock{IC, M} <: TrixiParticles.AbstractStructureSystem{2} + initial_condition::IC + mass::M + end + + Base.eltype(::EnergyStructureMock) = Float64 + TrixiParticles.compact_support(::EnergyStructureMock, neighbor) = 1.0 + function TrixiParticles.write_u0!(u0, system::EnergyStructureMock) + u0 .= system.initial_condition.coordinates + return u0 + end + function TrixiParticles.write_v0!(v0, system::EnergyStructureMock) + v0 .= system.initial_condition.velocity + return v0 + end + + coordinates = [0.0 1.0 2.0 + 0.0 0.0 0.0] + velocity = [1.0 2.0 3.0 + 4.0 5.0 6.0] + mass = [1.0, 2.0, 3.0] + ic = InitialCondition(; coordinates, velocity, mass, density=ones(3)) + system = EnergyStructureMock(ic, mass) + semi = Semidiscretization(system; neighborhood_search=nothing) + ode = semidiscretize(semi, (0.0, 1.0)) + v_ode, u_ode = ode.u0.x + dv_ode, du_ode = similar(v_ode), similar(u_ode) + + expected = sum(axes(velocity, 2)) do particle + return mass[particle] * dot(velocity[:, particle], velocity[:, particle]) / 2 + end + + @test kinetic_energy(system, dv_ode, du_ode, v_ode, u_ode, semi, t) == expected + end + + @testset "Active particle reductions" begin + struct ActiveFluidQuantityMock{IC, M, P, B} <: TrixiParticles.AbstractFluidSystem{2} + initial_condition::IC + mass::M + pressure::P + buffer::B + surface_tension::Nothing + surface_normal_method::Nothing + end + + Base.eltype(::ActiveFluidQuantityMock) = Float64 + TrixiParticles.v_nvariables(::ActiveFluidQuantityMock) = 3 + TrixiParticles.buffer(system::ActiveFluidQuantityMock) = system.buffer + TrixiParticles.compact_support(::ActiveFluidQuantityMock, neighbor) = 1.0 + function TrixiParticles.current_velocity(v, ::ActiveFluidQuantityMock) + return view(v, 1:2, :) + end + function TrixiParticles.current_density(v, ::ContinuityDensity, + ::ActiveFluidQuantityMock) + return view(v, 3, :) + end + function TrixiParticles.current_density(v, system::ActiveFluidQuantityMock) + return TrixiParticles.current_density(v, ContinuityDensity(), system) + end + function TrixiParticles.current_pressure(v, system::ActiveFluidQuantityMock) + return system.pressure + end + function TrixiParticles.write_u0!(u0, system::ActiveFluidQuantityMock) + u0 .= system.initial_condition.coordinates + return u0 + end + function TrixiParticles.write_v0!(v0, system::ActiveFluidQuantityMock) + v0[1:2, :] .= system.initial_condition.velocity + v0[3, :] .= system.initial_condition.density + return v0 + end + + coordinates = [0.0 1.0 2.0 + 0.0 0.0 0.0] + velocity = [1.0 10.0 3.0 + 2.0 20.0 4.0] + mass = [1.0, 2.0, 4.0] + density = [10.0, 50.0, 30.0] + pressure = [100.0, 500.0, 300.0] + ic = InitialCondition(; coordinates, velocity, mass, density, pressure) + buffer = TrixiParticles.SystemBuffer(nparticles(ic), 0) + buffer.active_particle[2] = false + TrixiParticles.update_system_buffer!(buffer) + system = ActiveFluidQuantityMock(ic, mass, pressure, buffer, nothing, nothing) + semi = Semidiscretization(system; neighborhood_search=nothing) + ode = semidiscretize(semi, (0.0, 1.0)) + v_ode, u_ode = ode.u0.x + dv_ode, du_ode = similar(v_ode), similar(u_ode) + + @test total_mass(system, dv_ode, du_ode, v_ode, u_ode, semi, t) == 5.0 + @test max_pressure(system, dv_ode, du_ode, v_ode, u_ode, semi, t) == 300.0 + @test min_pressure(system, dv_ode, du_ode, v_ode, u_ode, semi, t) == 100.0 + @test avg_pressure(system, dv_ode, du_ode, v_ode, u_ode, semi, t) == 200.0 + @test max_density(system, dv_ode, du_ode, v_ode, u_ode, semi, t) == 30.0 + @test min_density(system, dv_ode, du_ode, v_ode, u_ode, semi, t) == 10.0 + @test avg_density(system, dv_ode, du_ode, v_ode, u_ode, semi, t) == 20.0 + @test kinetic_energy(system, dv_ode, du_ode, v_ode, u_ode, semi, t) == 52.5 + end end From 4ad609dd4501294d70b9a5ba28198178fb39c819 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 12 May 2026 13:51:18 +0200 Subject: [PATCH 13/59] Close 2D geometry curves by default --- docs/src/preprocessing/preprocessing.md | 3 ++ examples/preprocessing/complex_shape_2d.jl | 2 +- src/preprocessing/geometries/io.jl | 19 +++++++------ src/preprocessing/geometries/polygon.jl | 27 +++++++++++++----- test/preprocessing/geometries/geometries.jl | 31 ++++++++++++++++++++- test/setups/complex_shape.jl | 4 ++- 6 files changed, 68 insertions(+), 18 deletions(-) diff --git a/docs/src/preprocessing/preprocessing.md b/docs/src/preprocessing/preprocessing.md index 0518574b73..913c4bc52b 100644 --- a/docs/src/preprocessing/preprocessing.md +++ b/docs/src/preprocessing/preprocessing.md @@ -269,6 +269,9 @@ For example: 0.0 1.0 ``` It is the user’s responsibility to ensure the points are ordered correctly. +By default, `load_geometry` closes 2D curves by appending the first point when it is +not already repeated. Use `load_geometry(file; close_curve=false)` for intentional +open curves. This format is easy to generate and inspect manually. ## DXF Format (.dxf) – recommended diff --git a/examples/preprocessing/complex_shape_2d.jl b/examples/preprocessing/complex_shape_2d.jl index fa2b762988..e7a48cefad 100644 --- a/examples/preprocessing/complex_shape_2d.jl +++ b/examples/preprocessing/complex_shape_2d.jl @@ -19,7 +19,7 @@ particle_spacing = 0.05 filename = "inverted_open_curve" file = joinpath("examples", "preprocessing", "data", filename * ".asc") -geometry = load_geometry(file) +geometry = load_geometry(file; close_curve=false) trixi2vtk(geometry) diff --git a/src/preprocessing/geometries/io.jl b/src/preprocessing/geometries/io.jl index bbccc276ec..6c8f2be970 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,19 @@ 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. Set this to `false` for intentional + open curves. """ -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 +40,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..6215deef2e 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. diff --git a/test/preprocessing/geometries/geometries.jl b/test/preprocessing/geometries/geometries.jl index 4f428d266c..de6f03a5bd 100644 --- a/test/preprocessing/geometries/geometries.jl +++ b/test/preprocessing/geometries/geometries.jl @@ -54,12 +54,40 @@ 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 "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 +102,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] diff --git a/test/setups/complex_shape.jl b/test/setups/complex_shape.jl index 2917661225..fd1de43cad 100644 --- a/test/setups/complex_shape.jl +++ b/test/setups/complex_shape.jl @@ -42,6 +42,7 @@ @testset verbose=true "Real World Data" begin files = ["hexagon", "circle", "inverted_open_curve"] + close_curves = [true, true, false] algorithms = [ WindingNumberHormann(), WindingNumberJacobson(; hierarchical_winding=false) @@ -72,7 +73,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=close_curves[j]) shape_sampled = ComplexShape(geometry; particle_spacing=0.05, density=1.0, point_in_geometry_algorithm) From 99df83bdb142f23aee895bc8967b03522e5ad71d Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 12 May 2026 13:54:53 +0200 Subject: [PATCH 14/59] Normalize preprocessing point inputs --- .../particle_packing/signed_distance.jl | 4 +++- .../point_in_poly/point_in_poly.jl | 12 ++++++++++ .../point_in_poly/winding_number_hormann.jl | 1 + .../point_in_poly/winding_number_jacobson.jl | 3 ++- test/preprocessing/packing/signed_distance.jl | 11 +++++++++ .../point_in_poly/winding_number_jacobson.jl | 23 ++++++++++++++++++- 6 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/preprocessing/particle_packing/signed_distance.jl b/src/preprocessing/particle_packing/signed_distance.jl index 01e862365f..bebe6d10e2 100644 --- a/src/preprocessing/particle_packing/signed_distance.jl +++ b/src/preprocessing/particle_packing/signed_distance.jl @@ -62,9 +62,11 @@ function SignedDistanceField(geometry, particle_spacing; min_corner; place_on_shell=true) points = reinterpret(reshape, SVector{NDIMS, eltype(grid)}, grid) + else + points = svector_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/point_in_poly/point_in_poly.jl b/src/preprocessing/point_in_poly/point_in_poly.jl index 70ebaaf90e..b6c6358187 100644 --- a/src/preprocessing/point_in_poly/point_in_poly.jl +++ b/src/preprocessing/point_in_poly/point_in_poly.jl @@ -1,3 +1,15 @@ +function svector_points(points, ::Val{NDIMS}) where {NDIMS} + if points isa AbstractMatrix + if size(points, 1) != NDIMS + throw(ArgumentError("point matrix must have $NDIMS rows")) + end + + return reinterpret(reshape, SVector{NDIMS, eltype(points)}, points) + end + + return points +end + include("hierarchical_winding.jl") include("winding_number_hormann.jl") include("winding_number_jacobson.jl") diff --git a/src/preprocessing/point_in_poly/winding_number_hormann.jl b/src/preprocessing/point_in_poly/winding_number_hormann.jl index a93cf789c4..9e797786f3 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 = svector_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..899f6d1cc9 100644 --- a/src/preprocessing/point_in_poly/winding_number_jacobson.jl +++ b/src/preprocessing/point_in_poly/winding_number_jacobson.jl @@ -69,7 +69,7 @@ struct WindingNumberJacobson{ELTYPE, W} winding :: W function WindingNumberJacobson(; geometry=nothing, winding_number_factor=sqrt(eps()), - hierarchical_winding=true) + hierarchical_winding=false) 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 +104,7 @@ end function (point_in_poly::WindingNumberJacobson)(geometry, points; store_winding_number=false) (; winding_number_factor, winding) = point_in_poly + points = svector_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/test/preprocessing/packing/signed_distance.jl b/test/preprocessing/packing/signed_distance.jl index cb02753b26..591620400b 100644 --- a/test/preprocessing/packing/signed_distance.jl +++ b/test/preprocessing/packing/signed_distance.jl @@ -135,4 +135,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..9a1339118d 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}()" @@ -17,7 +22,8 @@ └──────────────────────────────────────────────────────────────────────────────────────────────────┘""" @test repr("text/plain", winding) == show_box - winding = WindingNumberJacobson(; geometry, winding_number_factor=pi) + winding = WindingNumberJacobson(; geometry, winding_number_factor=pi, + hierarchical_winding=true) show_compact = "WindingNumberJacobson{HierarchicalWinding}()" @test repr(winding) == show_compact @@ -30,4 +36,19 @@ └──────────────────────────────────────────────────────────────────────────────────────────────────┘""" @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]) + points = [0.5 1.5; + 0.5 1.5] + + expected = Bool[true, false] + + inpoly_jacobson, _ = WindingNumberJacobson()(geometry, points) + inpoly_hormann, _ = WindingNumberHormann()(geometry, points) + + @test inpoly_jacobson == expected + @test inpoly_hormann == expected + end end From bc478e65ef6e33ed75645991c66de54fe9ec5905 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 12 May 2026 14:02:19 +0200 Subject: [PATCH 15/59] Rebuild geometry data after face deletion --- src/preprocessing/geometries/polygon.jl | 52 +++++++++++++++++-- src/preprocessing/geometries/triangle_mesh.jl | 25 +++++++-- test/preprocessing/geometries/geometries.jl | 28 ++++++++++ 3 files changed, 98 insertions(+), 7 deletions(-) diff --git a/src/preprocessing/geometries/polygon.jl b/src/preprocessing/geometries/polygon.jl index c56315b729..2a3780274e 100644 --- a/src/preprocessing/geometries/polygon.jl +++ b/src/preprocessing/geometries/polygon.jl @@ -1,5 +1,5 @@ # This is the data format returned by `load(file)` when used with `.asc` files -struct Polygon{NDIMS, ELTYPE} +mutable struct Polygon{NDIMS, ELTYPE} vertices :: Vector{SVector{NDIMS, ELTYPE}} edge_vertices :: Vector{NTuple{2, SVector{NDIMS, ELTYPE}}} vertex_normals :: Vector{NTuple{2, SVector{NDIMS, ELTYPE}}} @@ -97,6 +97,33 @@ struct Polygon{NDIMS, ELTYPE} end end +function rebuild_polygon_from_edges(edge_vertices, vertex_normals, 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]...) + + return (; vertices, edge_vertices, vertex_normals, edge_normals, edge_vertices_ids, + min_corner, max_corner) +end + function Base.show(io::IO, geometry::Polygon) @nospecialize geometry # reduce precompilation time @@ -120,12 +147,31 @@ 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 + edge_vertices = copy(polygon.edge_vertices) + vertex_normals = copy(polygon.vertex_normals) + edge_normals = copy(polygon.edge_normals) deleteat!(edge_vertices, indices) - deleteat!(edge_vertices_ids, indices) + deleteat!(vertex_normals, indices) deleteat!(edge_normals, indices) + if isempty(edge_vertices) + throw(ArgumentError("cannot delete all polygon edges")) + end + + (; vertices, edge_vertices_ids, min_corner, + max_corner) = rebuild_polygon_from_edges(edge_vertices, + vertex_normals, + edge_normals) + + polygon.vertices = vertices + polygon.edge_vertices = edge_vertices + polygon.vertex_normals = vertex_normals + polygon.edge_normals = edge_normals + polygon.edge_vertices_ids = edge_vertices_ids + polygon.min_corner = min_corner + polygon.max_corner = max_corner + return polygon end diff --git a/src/preprocessing/geometries/triangle_mesh.jl b/src/preprocessing/geometries/triangle_mesh.jl index ede02d4682..31cd18502e 100644 --- a/src/preprocessing/geometries/triangle_mesh.jl +++ b/src/preprocessing/geometries/triangle_mesh.jl @@ -1,5 +1,5 @@ # This is the data format returned by `load(file)` when used with `.stl` files -struct TriangleMesh{NDIMS, ELTYPE} +mutable struct TriangleMesh{NDIMS, ELTYPE} vertices :: Vector{SVector{NDIMS, ELTYPE}} face_vertices :: Vector{NTuple{3, SVector{NDIMS, ELTYPE}}} face_vertices_ids :: Vector{NTuple{3, Int}} @@ -172,13 +172,30 @@ 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 + 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) + if isempty(face_vertices) + throw(ArgumentError("cannot delete all triangle mesh faces")) + end + + vertices = collect(Iterators.flatten(face_vertices)) + rebuilt = TriangleMesh(face_vertices, face_normals, vertices) + + mesh.vertices = rebuilt.vertices + mesh.face_vertices = rebuilt.face_vertices + mesh.face_vertices_ids = rebuilt.face_vertices_ids + mesh.face_edges_ids = rebuilt.face_edges_ids + mesh.edge_vertices_ids = rebuilt.edge_vertices_ids + mesh.vertex_normals = rebuilt.vertex_normals + mesh.edge_normals = rebuilt.edge_normals + mesh.face_normals = rebuilt.face_normals + mesh.min_corner = rebuilt.min_corner + mesh.max_corner = rebuilt.max_corner + return mesh end diff --git a/test/preprocessing/geometries/geometries.jl b/test/preprocessing/geometries/geometries.jl index 4f428d266c..3906af0aff 100644 --- a/test/preprocessing/geometries/geometries.jl +++ b/test/preprocessing/geometries/geometries.jl @@ -54,6 +54,34 @@ end end + @testset verbose=true "`deleteat!` Rebuilds Derived Data" begin + triangle = [0.0 1.0 0.5 0.0; + 0.0 0.0 0.7 0.0] + + edge_only = deleteat!(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]...) + + 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]) + + deleteat!(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") From 09347e456ca196e35ab56921ae0abd9f30dd4309 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 12 May 2026 14:04:56 +0200 Subject: [PATCH 16/59] Avoid NaN normals for degenerate triangles --- src/preprocessing/geometries/triangle_mesh.jl | 20 +++++++++---------- test/preprocessing/geometries/geometries.jl | 13 ++++++++++++ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/preprocessing/geometries/triangle_mesh.jl b/src/preprocessing/geometries/triangle_mesh.jl index ede02d4682..1b7dfc557c 100644 --- a/src/preprocessing/geometries/triangle_mesh.jl +++ b/src/preprocessing/geometries/triangle_mesh.jl @@ -129,23 +129,23 @@ 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]) - end - end + edge_normals = normalize_or_zero.(edge_normals) + vertex_normals = normalize_or_zero.(vertex_normals) 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 +function normalize_or_zero(vector) + vector_norm = norm(vector) + iszero(vector_norm) && return vector + + return vector / vector_norm +end + function Base.show(io::IO, geometry::TriangleMesh) @nospecialize geometry # reduce precompilation time diff --git a/test/preprocessing/geometries/geometries.jl b/test/preprocessing/geometries/geometries.jl index 4f428d266c..108bc7f9cd 100644 --- a/test/preprocessing/geometries/geometries.jl +++ b/test/preprocessing/geometries/geometries.jl @@ -157,6 +157,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. From 2fc14ac9c6dc4c5866b6c9c70e08c70926c6c809 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Sun, 17 May 2026 22:38:07 +0200 Subject: [PATCH 17/59] detect unclosed curves --- docs/src/preprocessing/preprocessing.md | 12 ++- examples/preprocessing/complex_shape_2d.jl | 9 +-- src/preprocessing/geometries/geometries.jl | 59 ++++++++++++++ src/preprocessing/geometries/io.jl | 5 +- .../particle_packing/signed_distance.jl | 7 ++ src/setups/complex_shape.jl | 2 + test/preprocessing/geometries/geometries.jl | 81 ++++++++++--------- test/preprocessing/packing/signed_distance.jl | 10 +++ .../point_in_poly/winding_number_jacobson.jl | 13 +++ test/setups/complex_shape.jl | 14 +++- 10 files changed, 159 insertions(+), 53 deletions(-) diff --git a/docs/src/preprocessing/preprocessing.md b/docs/src/preprocessing/preprocessing.md index 913c4bc52b..e3d0a3f3d8 100644 --- a/docs/src/preprocessing/preprocessing.md +++ b/docs/src/preprocessing/preprocessing.md @@ -269,9 +269,15 @@ For example: 0.0 1.0 ``` It is the user’s responsibility to ensure the points are ordered correctly. -By default, `load_geometry` closes 2D curves by appending the first point when it is -not already repeated. Use `load_geometry(file; close_curve=false)` for intentional -open curves. +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`](@ref), +and [`setdiff`](@ref), 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 diff --git a/examples/preprocessing/complex_shape_2d.jl b/examples/preprocessing/complex_shape_2d.jl index e7a48cefad..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,15 +16,14 @@ using Plots particle_spacing = 0.05 -filename = "inverted_open_curve" +filename = "star" file = joinpath("examples", "preprocessing", "data", filename * ".asc") -geometry = load_geometry(file; close_curve=false) +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/src/preprocessing/geometries/geometries.jl b/src/preprocessing/geometries/geometries.jl index 228b21cc22..4fc9fd145e 100644 --- a/src/preprocessing/geometries/geometries.jl +++ b/src/preprocessing/geometries/geometries.jl @@ -4,6 +4,63 @@ 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) + vertices_close = isapprox(first(polygon.vertices), last(polygon.vertices)) + vertices_close || return false + + expected_edges = count(1:(length(polygon.vertices) - 1)) do i + !isapprox(polygon.vertices[i], polygon.vertices[i + 1]) + end + + return nfaces(polygon) == expected_edges +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) + if !isapprox(first(polygon.vertices), last(polygon.vertices)) + return "The first and last polygon vertices are different. " * + "If the vertices already trace a complete 2D boundary, construct or load " * + "the geometry with `close_curve=true`; otherwise provide a closed boundary." + end + + return "The polygon edge list does not form a complete closed curve." +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 +68,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)}, @@ -38,6 +96,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 6c8f2be970..a7c6106827 100644 --- a/src/preprocessing/geometries/io.jl +++ b/src/preprocessing/geometries/io.jl @@ -19,8 +19,9 @@ 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. Set this to `false` for intentional - open curves. + 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, close_curve=true) ELTYPE = element_type diff --git a/src/preprocessing/particle_packing/signed_distance.jl b/src/preprocessing/particle_packing/signed_distance.jl index 01e862365f..79326de2a5 100644 --- a/src/preprocessing/particle_packing/signed_distance.jl +++ b/src/preprocessing/particle_packing/signed_distance.jl @@ -21,6 +21,8 @@ to this surface. - `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 +40,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 diff --git a/src/setups/complex_shape.jl b/src/setups/complex_shape.jl index 6a78b412e2..0f44b9df8a 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 diff --git a/test/preprocessing/geometries/geometries.jl b/test/preprocessing/geometries/geometries.jl index de6f03a5bd..203f6e77e6 100644 --- a/test/preprocessing/geometries/geometries.jl +++ b/test/preprocessing/geometries/geometries.jl @@ -81,6 +81,32 @@ 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 = deleteat!(TrixiParticles.Polygon(open_square), 2) + + @test TrixiParticles.is_closed_geometry(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 "Real World Data" begin data_dir = pkgdir(TrixiParticles, "examples", "preprocessing", "data") validation_dir = pkgdir(TrixiParticles, "test", "preprocessing", "data") @@ -231,47 +257,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/signed_distance.jl b/test/preprocessing/packing/signed_distance.jl index cb02753b26..969d54292f 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") diff --git a/test/preprocessing/point_in_poly/winding_number_jacobson.jl b/test/preprocessing/point_in_poly/winding_number_jacobson.jl index ee2119a62d..a6136cff37 100644 --- a/test/preprocessing/point_in_poly/winding_number_jacobson.jl +++ b/test/preprocessing/point_in_poly/winding_number_jacobson.jl @@ -30,4 +30,17 @@ └──────────────────────────────────────────────────────────────────────────────────────────────────┘""" @test repr("text/plain", winding) == 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) + 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/setups/complex_shape.jl b/test/setups/complex_shape.jl index fd1de43cad..1235e95889 100644 --- a/test/setups/complex_shape.jl +++ b/test/setups/complex_shape.jl @@ -41,8 +41,7 @@ end @testset verbose=true "Real World Data" begin - files = ["hexagon", "circle", "inverted_open_curve"] - close_curves = [true, true, false] + files = ["hexagon", "circle"] algorithms = [ WindingNumberHormann(), WindingNumberJacobson(; hierarchical_winding=false) @@ -74,7 +73,7 @@ coords = vcat((data.var"Points:0")', (data.var"Points:1")') geometry = load_geometry(joinpath(data_dir, files[j] * ".asc"); - close_curve=close_curves[j]) + close_curve=true) shape_sampled = ComplexShape(geometry; particle_spacing=0.05, density=1.0, point_in_geometry_algorithm) @@ -84,6 +83,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")) From 217ca6364c621485d98ae26389f76758b8f22631 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Sun, 17 May 2026 22:55:34 +0200 Subject: [PATCH 18/59] add comments --- .../particle_packing/signed_distance.jl | 4 +++- src/preprocessing/point_in_poly/point_in_poly.jl | 12 ------------ .../point_in_poly/winding_number_hormann.jl | 2 +- .../point_in_poly/winding_number_jacobson.jl | 6 ++++-- src/preprocessing/preprocessing.jl | 13 +++++++++++++ 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/preprocessing/particle_packing/signed_distance.jl b/src/preprocessing/particle_packing/signed_distance.jl index bebe6d10e2..ce3dd25cd3 100644 --- a/src/preprocessing/particle_packing/signed_distance.jl +++ b/src/preprocessing/particle_packing/signed_distance.jl @@ -16,6 +16,8 @@ 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 @@ -63,7 +65,7 @@ function SignedDistanceField(geometry, particle_spacing; points = reinterpret(reshape, SVector{NDIMS, eltype(grid)}, grid) else - points = svector_points(points, Val(NDIMS)) + points = wrap_points(points, Val(NDIMS)) end positions = collect(points) diff --git a/src/preprocessing/point_in_poly/point_in_poly.jl b/src/preprocessing/point_in_poly/point_in_poly.jl index b6c6358187..70ebaaf90e 100644 --- a/src/preprocessing/point_in_poly/point_in_poly.jl +++ b/src/preprocessing/point_in_poly/point_in_poly.jl @@ -1,15 +1,3 @@ -function svector_points(points, ::Val{NDIMS}) where {NDIMS} - if points isa AbstractMatrix - if size(points, 1) != NDIMS - throw(ArgumentError("point matrix must have $NDIMS rows")) - end - - return reinterpret(reshape, SVector{NDIMS, eltype(points)}, points) - end - - return points -end - include("hierarchical_winding.jl") include("winding_number_hormann.jl") include("winding_number_jacobson.jl") diff --git a/src/preprocessing/point_in_poly/winding_number_hormann.jl b/src/preprocessing/point_in_poly/winding_number_hormann.jl index 9e797786f3..9f9b849acb 100644 --- a/src/preprocessing/point_in_poly/winding_number_hormann.jl +++ b/src/preprocessing/point_in_poly/winding_number_hormann.jl @@ -14,7 +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 = svector_points(points, Val(ndims(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 899f6d1cc9..7ae218d473 100644 --- a/src/preprocessing/point_in_poly/winding_number_jacobson.jl +++ b/src/preprocessing/point_in_poly/winding_number_jacobson.jl @@ -58,7 +58,9 @@ Algorithm for inside-outside segmentation of a complex geometry proposed by [Jac - `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 `false` so the algorithm + can be constructed without passing `geometry`. 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" @@ -104,7 +106,7 @@ end function (point_in_poly::WindingNumberJacobson)(geometry, points; store_winding_number=false) (; winding_number_factor, winding) = point_in_poly - points = svector_points(points, Val(ndims(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/preprocessing.jl b/src/preprocessing/preprocessing.jl index c538280bb4..00e306e3cb 100644 --- a/src/preprocessing/preprocessing.jl +++ b/src/preprocessing/preprocessing.jl @@ -1,3 +1,16 @@ +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. + return reinterpret(reshape, SVector{NDIMS, eltype(points)}, points) + end + + return points +end + include("geometries/geometries.jl") include("point_in_poly/point_in_poly.jl") include("particle_packing/particle_packing.jl") From 495543ab643ba1196a0fbece8c9951a172b6f0e4 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Sun, 17 May 2026 23:11:05 +0200 Subject: [PATCH 19/59] fix --- src/preprocessing/geometries/polygon.jl | 28 ++++++++++--------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/src/preprocessing/geometries/polygon.jl b/src/preprocessing/geometries/polygon.jl index 2a3780274e..3192633618 100644 --- a/src/preprocessing/geometries/polygon.jl +++ b/src/preprocessing/geometries/polygon.jl @@ -1,5 +1,5 @@ # This is the data format returned by `load(file)` when used with `.asc` files -mutable struct Polygon{NDIMS, ELTYPE} +struct Polygon{NDIMS, ELTYPE} vertices :: Vector{SVector{NDIMS, ELTYPE}} edge_vertices :: Vector{NTuple{2, SVector{NDIMS, ELTYPE}}} vertex_normals :: Vector{NTuple{2, SVector{NDIMS, ELTYPE}}} @@ -95,6 +95,13 @@ mutable 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 rebuild_polygon_from_edges(edge_vertices, vertex_normals, edge_normals) @@ -120,8 +127,8 @@ function rebuild_polygon_from_edges(edge_vertices, vertex_normals, edge_normals) 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]...) - return (; vertices, edge_vertices, vertex_normals, edge_normals, edge_vertices_ids, - min_corner, max_corner) + 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) @@ -159,20 +166,7 @@ end throw(ArgumentError("cannot delete all polygon edges")) end - (; vertices, edge_vertices_ids, min_corner, - max_corner) = rebuild_polygon_from_edges(edge_vertices, - vertex_normals, - edge_normals) - - polygon.vertices = vertices - polygon.edge_vertices = edge_vertices - polygon.vertex_normals = vertex_normals - polygon.edge_normals = edge_normals - polygon.edge_vertices_ids = edge_vertices_ids - polygon.min_corner = min_corner - polygon.max_corner = max_corner - - return polygon + return rebuild_polygon_from_edges(edge_vertices, vertex_normals, edge_normals) end @inline nfaces(mesh::Polygon) = length(mesh.edge_normals) From f47d55fbf70a37f31f525a6d6dbe91398ab2ae15 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Sun, 17 May 2026 23:18:54 +0200 Subject: [PATCH 20/59] fix --- src/preprocessing/geometries/triangle_mesh.jl | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/preprocessing/geometries/triangle_mesh.jl b/src/preprocessing/geometries/triangle_mesh.jl index 31cd18502e..f35b95dcd1 100644 --- a/src/preprocessing/geometries/triangle_mesh.jl +++ b/src/preprocessing/geometries/triangle_mesh.jl @@ -1,5 +1,5 @@ # This is the data format returned by `load(file)` when used with `.stl` files -mutable struct TriangleMesh{NDIMS, ELTYPE} +struct TriangleMesh{NDIMS, ELTYPE} vertices :: Vector{SVector{NDIMS, ELTYPE}} face_vertices :: Vector{NTuple{3, SVector{NDIMS, ELTYPE}}} face_vertices_ids :: Vector{NTuple{3, Int}} @@ -183,20 +183,7 @@ end end vertices = collect(Iterators.flatten(face_vertices)) - rebuilt = TriangleMesh(face_vertices, face_normals, vertices) - - mesh.vertices = rebuilt.vertices - mesh.face_vertices = rebuilt.face_vertices - mesh.face_vertices_ids = rebuilt.face_vertices_ids - mesh.face_edges_ids = rebuilt.face_edges_ids - mesh.edge_vertices_ids = rebuilt.edge_vertices_ids - mesh.vertex_normals = rebuilt.vertex_normals - mesh.edge_normals = rebuilt.edge_normals - mesh.face_normals = rebuilt.face_normals - mesh.min_corner = rebuilt.min_corner - mesh.max_corner = rebuilt.max_corner - - return mesh + return TriangleMesh(face_vertices, face_normals, vertices) end @inline nfaces(mesh::TriangleMesh) = length(mesh.face_normals) From c382bce08cddc9da01de0ac7d4cd6d4895f772cf Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Sun, 17 May 2026 23:41:54 +0200 Subject: [PATCH 21/59] cleanup --- src/preprocessing/geometries/triangle_mesh.jl | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/preprocessing/geometries/triangle_mesh.jl b/src/preprocessing/geometries/triangle_mesh.jl index 1b7dfc557c..276dd54167 100644 --- a/src/preprocessing/geometries/triangle_mesh.jl +++ b/src/preprocessing/geometries/triangle_mesh.jl @@ -129,8 +129,12 @@ 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]...) - edge_normals = normalize_or_zero.(edge_normals) - vertex_normals = normalize_or_zero.(vertex_normals) + 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, @@ -139,13 +143,6 @@ struct TriangleMesh{NDIMS, ELTYPE} end end -function normalize_or_zero(vector) - vector_norm = norm(vector) - iszero(vector_norm) && return vector - - return vector / vector_norm -end - function Base.show(io::IO, geometry::TriangleMesh) @nospecialize geometry # reduce precompilation time From 8433ea1160b3a1a9633a7a8a6c5483265c0de1fc Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Mon, 18 May 2026 00:10:22 +0200 Subject: [PATCH 22/59] Fix rectangular shape input handling --- src/TrixiParticles.jl | 2 +- src/setups/rectangular_shape.jl | 15 ++++++++--- test/setups/rectangular_shape.jl | 45 ++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/TrixiParticles.jl b/src/TrixiParticles.jl index bdf70324cd..6926342f46 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, u_modified!, get_tmp_cache, set_proposed_dt!, ODESolution, ODEProblem, terminate!, add_tstop! diff --git a/src/setups/rectangular_shape.jl b/src/setups/rectangular_shape.jl index c9043a35e6..544bc0173c 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,15 +105,19 @@ 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)")) @@ -233,6 +237,11 @@ function initialize_pressure!(pressure, particle_spacing, acceleration, density_ # Dimension in which the acceleration is acting accel_dim = findfirst(a -> abs(a) > eps(), 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]) diff --git a/test/setups/rectangular_shape.jl b/test/setups/rectangular_shape.jl index 4e2419487f..65dfe6d0b6 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,14 @@ @test shape.pressure ≈ 4.71 * 1000.0 * vec(reverse(pressure')) end 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 +221,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 From 0b678dd96d5470ba57e0cf5080635e22a45d192f Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Mon, 18 May 2026 00:10:32 +0200 Subject: [PATCH 23/59] Fix rectangular tank validation and overlap --- src/setups/rectangular_tank.jl | 33 ++++++++++++++++++++++++++++----- test/setups/rectangular_tank.jl | 14 ++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/setups/rectangular_tank.jl b/src/setups/rectangular_tank.jl index 2b59bbc8b0..6907b0ab24 100644 --- a/src/setups/rectangular_tank.jl +++ b/src/setups/rectangular_tank.jl @@ -117,6 +117,14 @@ struct RectangularTank{NDIMS, NDIMSt2, ELTYPE <: Real, F, B} throw(ArgumentError("`fluid_density` needs to be positive and larger than $(eps()).")) end + if n_layers < 1 + throw(ArgumentError("`n_layers` needs to be positive")) + end + + 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 @@ -270,14 +278,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." @@ -292,21 +306,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." diff --git a/test/setups/rectangular_tank.jl b/test/setups/rectangular_tank.jl index c8dc286013..7a7b50ef25 100644 --- a/test/setups/rectangular_tank.jl +++ b/test/setups/rectangular_tank.jl @@ -109,6 +109,20 @@ (water_width, water_height, 0.5), (tank_width, tank_height), water_density, spacing_ratio=3) + + @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) + + 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)) end end From ca4fe1279a0a6b9992743905579b9187e6e94a72 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Mon, 18 May 2026 00:10:36 +0200 Subject: [PATCH 24/59] Fix complex boundary sampling bounds --- src/setups/complex_shape.jl | 6 +++--- test/setups/complex_shape.jl | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/setups/complex_shape.jl b/src/setups/complex_shape.jl index 6a78b412e2..6e5b36b25f 100644 --- a/src/setups/complex_shape.jl +++ b/src/setups/complex_shape.jl @@ -91,7 +91,7 @@ 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. @@ -133,8 +133,8 @@ 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 + keep_indices = (distance_to_boundary .<= distances .<= boundary_thickness) boundary_coordinates = stack(positions[keep_indices]) return InitialCondition(; coordinates=boundary_coordinates, density=boundary_density, diff --git a/test/setups/complex_shape.jl b/test/setups/complex_shape.jl index 2917661225..21b19ebf5b 100644 --- a/test/setups/complex_shape.jl +++ b/test/setups/complex_shape.jl @@ -2,6 +2,39 @@ 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]) + + 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 = [ From af3e52a8bffb9bacb4b3eee9be7959fed7984f0a Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Mon, 18 May 2026 00:10:39 +0200 Subject: [PATCH 25/59] Validate shape cutout and extrusion inputs --- src/setups/extrude_geometry.jl | 21 +++++++++++++++++++++ src/setups/sphere_shape.jl | 13 ++++++++++++- test/setups/extrude_geometry.jl | 18 ++++++++++++++++++ test/setups/sphere_shape.jl | 9 +++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/setups/extrude_geometry.jl b/src/setups/extrude_geometry.jl index 460583c38e..0a37fbcd64 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 norm(direction) < eps() + 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/sphere_shape.jl b/src/setups/sphere_shape.jl index f74f4a470d..35e1087ccd 100644 --- a/src/setups/sphere_shape.jl +++ b/src/setups/sphere_shape.jl @@ -114,9 +114,20 @@ function SphereShape(particle_spacing, radius, center_position, density; cutout_min_ = collect(cutout_min) cutout_max_ = collect(cutout_max) + 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/test/setups/extrude_geometry.jl b/test/setups/extrude_geometry.jl index 4146ab6dd6..f639173f16 100644 --- a/test/setups/extrude_geometry.jl +++ b/test/setups/extrude_geometry.jl @@ -47,6 +47,24 @@ @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_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/sphere_shape.jl b/test/setups/sphere_shape.jl index c8b87d76db..20d86b3314 100644 --- a/test/setups/sphere_shape.jl +++ b/test/setups/sphere_shape.jl @@ -79,6 +79,15 @@ 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)) + end + @testset verbose=true "SphereShape 3D" begin shape_names = [ "1-particle VoxelSphere", From 700ebcda525d66908f0c7cc048126d7bd411006c Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Mon, 18 May 2026 00:54:34 +0200 Subject: [PATCH 26/59] Fix open-boundary characteristic zone handling --- .../method_of_characteristics.jl | 3 + src/schemes/boundary/open_boundary/system.jl | 6 +- .../open_boundary/characteristic_variables.jl | 63 +++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) 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 3e4754d4ad..bfc2d9b462 100644 --- a/src/schemes/boundary/open_boundary/system.jl +++ b/src/schemes/boundary/open_boundary/system.jl @@ -699,9 +699,9 @@ function check_configuration(system::OpenBoundarySystem, systems, 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/test/schemes/boundary/open_boundary/characteristic_variables.jl b/test/schemes/boundary/open_boundary/characteristic_variables.jl index 9d2e9fa03e..512453a792 100644 --- a/test/schemes/boundary/open_boundary/characteristic_variables.jl +++ b/test/schemes/boundary/open_boundary/characteristic_variables.jl @@ -18,6 +18,69 @@ # 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()) + + @test_throws ArgumentError 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]] From 2ffebc90ee256b7cd426d18bc34e172d2ffdd80b Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Mon, 18 May 2026 00:58:58 +0200 Subject: [PATCH 27/59] Validate 3D open-boundary face geometry --- .../boundary/open_boundary/boundary_zones.jl | 5 +++++ .../boundary/open_boundary/boundary_zone.jl | 20 +++++++++++++++++++ .../open_boundary/characteristic_variables.jl | 2 +- .../open_boundary/dynamical_pressure.jl | 2 +- .../boundary/open_boundary/mirroring.jl | 2 +- 5 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/schemes/boundary/open_boundary/boundary_zones.jl b/src/schemes/boundary/open_boundary/boundary_zones.jl index 422a0f2e26..0d8b5cc228 100644 --- a/src/schemes/boundary/open_boundary/boundary_zones.jl +++ b/src/schemes/boundary/open_boundary/boundary_zones.jl @@ -468,6 +468,11 @@ function spanning_vectors(face_vertices::NTuple{3}, zone_width) throw(ArgumentError("the vectors `AB` and `AC` must not be collinear")) end + if !isapprox(dot(edge1, edge2), 0.0; + atol=sqrt(eps()) * norm(edge1) * norm(edge2)) + 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/test/schemes/boundary/open_boundary/boundary_zone.jl b/test/schemes/boundary/open_boundary/boundary_zone.jl index 8542fd554d..3233446e06 100644 --- a/test/schemes/boundary/open_boundary/boundary_zone.jl +++ b/test/schemes/boundary/open_boundary/boundary_zone.jl @@ -367,6 +367,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..10966930c8 100644 --- a/test/schemes/boundary/open_boundary/characteristic_variables.jl +++ b/test/schemes/boundary/open_boundary/characteristic_variables.jl @@ -144,7 +144,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) From 6927e67f0248e62433e170826191c6460dc6a114 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 26 May 2026 11:07:37 +0200 Subject: [PATCH 28/59] format --- test/setups/rectangular_shape.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/setups/rectangular_shape.jl b/test/setups/rectangular_shape.jl index 65dfe6d0b6..31ca14a287 100644 --- a/test/setups/rectangular_shape.jl +++ b/test/setups/rectangular_shape.jl @@ -72,8 +72,8 @@ @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)) + density=1000.0, + acceleration=(0.0, -9.81, 0.0)) end end From 14192e42a3db32de78a2edca88d1a14a856b421d Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 26 May 2026 12:07:09 +0200 Subject: [PATCH 29/59] review --- src/setups/rectangular_shape.jl | 100 ++++++++++++++++++++++++++++--- test/setups/rectangular_shape.jl | 12 ++++ 2 files changed, 104 insertions(+), 8 deletions(-) diff --git a/src/setups/rectangular_shape.jl b/src/setups/rectangular_shape.jl index 544bc0173c..c54ba305ee 100644 --- a/src/setups/rectangular_shape.jl +++ b/src/setups/rectangular_shape.jl @@ -124,19 +124,32 @@ function RectangularShape(particle_spacing, n_particles_per_dimension, min_coord end if state_equation === nothing - density_fun = pressure -> density + if density === nothing + throw(ArgumentError("`density` must be specified when not using " * + "`acceleration` and `state_equation` " * + "(hydrostatic pressure gradient)")) + 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 @@ -227,16 +240,19 @@ 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 - # Dimension in which the acceleration is acting - accel_dim = findfirst(a -> abs(a) > eps(), acceleration) + 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 = acceleration_dimension(acceleration) if accel_dim === nothing fill!(pressure, zero(eltype(pressure))) return pressure @@ -274,3 +290,71 @@ 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/test/setups/rectangular_shape.jl b/test/setups/rectangular_shape.jl index 31ca14a287..cd8500f160 100644 --- a/test/setups/rectangular_shape.jl +++ b/test/setups/rectangular_shape.jl @@ -151,6 +151,18 @@ 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)) From 5721f184ff29effbb2614cd3dac5f9e430b21906 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 26 May 2026 12:52:55 +0200 Subject: [PATCH 30/59] review --- docs/literate/src/tut_packing.jl | 8 +++++--- src/setups/rectangular_tank.jl | 7 ++++--- test/setups/rectangular_tank.jl | 11 +++++++++++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/literate/src/tut_packing.jl b/docs/literate/src/tut_packing.jl index d5b9610283..a8e02d7b6d 100644 --- a/docs/literate/src/tut_packing.jl +++ b/docs/literate/src/tut_packing.jl @@ -251,10 +251,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/src/setups/rectangular_tank.jl b/src/setups/rectangular_tank.jl index 6907b0ab24..29d2094b41 100644 --- a/src/setups/rectangular_tank.jl +++ b/src/setups/rectangular_tank.jl @@ -117,9 +117,10 @@ struct RectangularTank{NDIMS, NDIMSt2, ELTYPE <: Real, F, B} throw(ArgumentError("`fluid_density` needs to be positive and larger than $(eps()).")) end - if n_layers < 1 - throw(ArgumentError("`n_layers` needs to be positive")) + 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()).")) @@ -170,7 +171,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. diff --git a/test/setups/rectangular_tank.jl b/test/setups/rectangular_tank.jl index 7a7b50ef25..08b5f7d0ef 100644 --- a/test/setups/rectangular_tank.jl +++ b/test/setups/rectangular_tank.jl @@ -120,9 +120,20 @@ (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 From fed6d7080ae671500c322f4b7dae94e0d98e8013 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 26 May 2026 13:05:30 +0200 Subject: [PATCH 31/59] review --- src/setups/rectangular_tank.jl | 8 ++++++++ test/setups/rectangular_tank.jl | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/setups/rectangular_tank.jl b/src/setups/rectangular_tank.jl index 29d2094b41..919c0c3354 100644 --- a/src/setups/rectangular_tank.jl +++ b/src/setups/rectangular_tank.jl @@ -117,6 +117,10 @@ 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 @@ -130,6 +134,10 @@ struct RectangularTank{NDIMS, NDIMSt2, ELTYPE <: Real, F, B} 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) diff --git a/test/setups/rectangular_tank.jl b/test/setups/rectangular_tank.jl index 08b5f7d0ef..de304aa0c2 100644 --- a/test/setups/rectangular_tank.jl +++ b/test/setups/rectangular_tank.jl @@ -110,6 +110,18 @@ (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), From d40be9223687fdb6de8216171cb695f461f9251f Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 26 May 2026 13:32:22 +0200 Subject: [PATCH 32/59] review --- docs/literate/src/tut_packing.jl | 6 ++-- docs/src/preprocessing/preprocessing.md | 2 +- examples/preprocessing/packing_2d.jl | 3 +- src/preprocessing/particle_packing/system.jl | 36 ++++++++++++++++---- src/setups/complex_shape.jl | 18 ++++++++-- test/setups/complex_shape.jl | 5 +++ test/systems/packing_system.jl | 16 +++++++++ 7 files changed, 72 insertions(+), 14 deletions(-) diff --git a/docs/literate/src/tut_packing.jl b/docs/literate/src/tut_packing.jl index d5b9610283..d4b55990ed 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. @@ -211,7 +211,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) diff --git a/docs/src/preprocessing/preprocessing.md b/docs/src/preprocessing/preprocessing.md index 0518574b73..e08b46c7c7 100644 --- a/docs/src/preprocessing/preprocessing.md +++ b/docs/src/preprocessing/preprocessing.md @@ -325,7 +325,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/examples/preprocessing/packing_2d.jl b/examples/preprocessing/packing_2d.jl index 02951323ef..cba529cc5d 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/src/preprocessing/particle_packing/system.jl b/src/preprocessing/particle_packing/system.jl index bc72e155b3..ef2fe87cd4 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/setups/complex_shape.jl b/src/setups/complex_shape.jl index 6e5b36b25f..481f80a74c 100644 --- a/src/setups/complex_shape.jl +++ b/src/setups/complex_shape.jl @@ -93,6 +93,8 @@ of the geometry. one particle spacing from the surface of the geometry. 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 +113,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 │ @@ -134,9 +136,21 @@ function sample_boundary(signed_distance_field; # Only keep the required part of the signed distance field 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/test/setups/complex_shape.jl b/test/setups/complex_shape.jl index 21b19ebf5b..c2422e23cd 100644 --- a/test/setups/complex_shape.jl +++ b/test/setups/complex_shape.jl @@ -24,6 +24,11 @@ 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, diff --git a/test/systems/packing_system.jl b/test/systems/packing_system.jl index 1eb02e9c44..f7b5e31cf4 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) From e1b580ecba6bfdefaed4348df591258153f5ec75 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 26 May 2026 13:45:13 +0200 Subject: [PATCH 33/59] review --- src/setups/extrude_geometry.jl | 2 +- src/setups/sphere_shape.jl | 5 ++++- test/setups/extrude_geometry.jl | 4 ++++ test/setups/sphere_shape.jl | 3 +++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/setups/extrude_geometry.jl b/src/setups/extrude_geometry.jl index 0a37fbcd64..43455678bb 100644 --- a/src/setups/extrude_geometry.jl +++ b/src/setups/extrude_geometry.jl @@ -87,7 +87,7 @@ 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 norm(direction) < eps() + if all(iszero, direction) throw(ArgumentError("`direction` needs to be non-zero")) end diff --git a/src/setups/sphere_shape.jl b/src/setups/sphere_shape.jl index 35e1087ccd..bcb418ad53 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,6 +115,8 @@ 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() diff --git a/test/setups/extrude_geometry.jl b/test/setups/extrude_geometry.jl index f639173f16..b61b14549b 100644 --- a/test/setups/extrude_geometry.jl +++ b/test/setups/extrude_geometry.jl @@ -56,6 +56,10 @@ 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, diff --git a/test/setups/sphere_shape.jl b/test/setups/sphere_shape.jl index 20d86b3314..d94abed713 100644 --- a/test/setups/sphere_shape.jl +++ b/test/setups/sphere_shape.jl @@ -86,6 +86,9 @@ @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 From 4ed8dec241ee3c641769d7aacaf6b3843882cb15 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 26 May 2026 14:04:23 +0200 Subject: [PATCH 34/59] review --- .../boundary/open_boundary/boundary_zones.jl | 28 ++++++++++++------- .../boundary/open_boundary/boundary_zone.jl | 18 ++++++++++++ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/schemes/boundary/open_boundary/boundary_zones.jl b/src/schemes/boundary/open_boundary/boundary_zones.jl index 0d8b5cc228..097e9bb158 100644 --- a/src/schemes/boundary/open_boundary/boundary_zones.jl +++ b/src/schemes/boundary/open_boundary/boundary_zones.jl @@ -384,6 +384,15 @@ 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 +417,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,13 +465,19 @@ 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 - if !isapprox(dot(edge1, edge2), 0.0; - atol=sqrt(eps()) * norm(edge1) * norm(edge2)) + 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 diff --git a/test/schemes/boundary/open_boundary/boundary_zone.jl b/test/schemes/boundary/open_boundary/boundary_zone.jl index 3233446e06..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] From 537edd7a07e1361132228f6e361058801c704423 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 26 May 2026 14:14:07 +0200 Subject: [PATCH 35/59] review --- .../boundary/open_boundary/characteristic_variables.jl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/schemes/boundary/open_boundary/characteristic_variables.jl b/test/schemes/boundary/open_boundary/characteristic_variables.jl index 512453a792..af52909734 100644 --- a/test/schemes/boundary/open_boundary/characteristic_variables.jl +++ b/test/schemes/boundary/open_boundary/characteristic_variables.jl @@ -31,7 +31,10 @@ boundary_system = OpenBoundarySystem(bidirectional; fluid_system, buffer_size=0, boundary_model=BoundaryModelCharacteristicsLastiwka()) - @test_throws ArgumentError Semidiscretization(fluid_system, boundary_system) + 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 From 2c132c89958d95828ca5b585312779b95371460f Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 26 May 2026 14:44:07 +0200 Subject: [PATCH 36/59] Fix EDAC restart pressure row --- .../fluid/entropically_damped_sph/system.jl | 2 +- test/systems/edac_system.jl | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/schemes/fluid/entropically_damped_sph/system.jl b/src/schemes/fluid/entropically_damped_sph/system.jl index 2b085d3ebb..8f894f8fb5 100644 --- a/src/schemes/fluid/entropically_damped_sph/system.jl +++ b/src/schemes/fluid/entropically_damped_sph/system.jl @@ -381,6 +381,6 @@ function restart_with!(system::EntropicallyDampedSPHSystem, v, u) for particle in each_integrated_particle(system) system.initial_condition.coordinates[:, particle] .= u[:, particle] system.initial_condition.velocity[:, particle] .= v[1:ndims(system), particle] - system.initial_condition.pressure[particle] = v[end, particle] + system.initial_condition.pressure[particle] = v[ndims(system) + 1, particle] end end diff --git a/test/systems/edac_system.jl b/test/systems/edac_system.jl index ce04cd6774..cb197b22bf 100644 --- a/test/systems/edac_system.jl +++ b/test/systems/edac_system.jl @@ -244,4 +244,35 @@ nparticles(system)) end end + + @trixi_testset "restart_with! with ContinuityDensity" begin + coordinates = [0.5 2.0 + 1.0 2.0] + velocity = 2 * coordinates + mass = [1.25, 1.5] + density = [990.0, 1000.0] + pressure = [5.0, 7.8] + smoothing_kernel = Val(:smoothing_kernel) + TrixiParticles.ndims(::Val{:smoothing_kernel}) = 2 + smoothing_length = 0.362 + sound_speed = 10.0 + + initial_condition = InitialCondition(; coordinates, velocity, mass, density, + pressure) + system = EntropicallyDampedSPHSystem(initial_condition; smoothing_kernel, + smoothing_length, sound_speed, + density_calculator=ContinuityDensity()) + + u_new = coordinates .+ 1 + velocity_new = velocity .+ 2 + pressure_new = [11.0, 13.0] + density_new = [980.0, 970.0] + v_new = vcat(velocity_new, pressure_new', density_new') + + TrixiParticles.restart_with!(system, v_new, u_new) + + @test system.initial_condition.coordinates == u_new + @test system.initial_condition.velocity == velocity_new + @test system.initial_condition.pressure == pressure_new + end end From d7d71b5ba182f0c45175cdd50f2a4a48ee8086aa Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 26 May 2026 14:45:23 +0200 Subject: [PATCH 37/59] Validate fluid surface tension consistency --- src/schemes/fluid/fluid.jl | 10 ++++++---- test/general/semidiscretization.jl | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/schemes/fluid/fluid.jl b/src/schemes/fluid/fluid.jl index eb6338f1cc..6c921a775d 100644 --- a/src/schemes/fluid/fluid.jl +++ b/src/schemes/fluid/fluid.jl @@ -254,11 +254,13 @@ end 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/test/general/semidiscretization.jl b/test/general/semidiscretization.jl index 4b466ad66a..aef14fed49 100644 --- a/test/general/semidiscretization.jl +++ b/test/general/semidiscretization.jl @@ -141,6 +141,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 + surface_normal_method + 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 "`show`" begin From 30c7ba499a1b428338fbed5de55db11d9edfb072 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 26 May 2026 14:46:37 +0200 Subject: [PATCH 38/59] Fix IISPH density error accounting --- src/schemes/fluid/implicit_incompressible_sph/system.jl | 6 ++++-- test/systems/iisph_system.jl | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/schemes/fluid/implicit_incompressible_sph/system.jl b/src/schemes/fluid/implicit_incompressible_sph/system.jl index 6e54ce7b66..7d3975e24c 100644 --- a/src/schemes/fluid/implicit_incompressible_sph/system.jl +++ b/src/schemes/fluid/implicit_incompressible_sph/system.jl @@ -572,11 +572,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 diff --git a/test/systems/iisph_system.jl b/test/systems/iisph_system.jl index 2bcd740552..b5a88c16f9 100644 --- a/test/systems/iisph_system.jl +++ b/test/systems/iisph_system.jl @@ -439,12 +439,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 +454,9 @@ 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 "Source term and iteration limits" begin From 691219ad28425eb9d8755459128f312378f8a9b4 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 26 May 2026 14:48:07 +0200 Subject: [PATCH 39/59] Fix IISPH neighbor coordinate wrapping --- .../implicit_incompressible_sph/system.jl | 3 +- test/systems/iisph_system.jl | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/schemes/fluid/implicit_incompressible_sph/system.jl b/src/schemes/fluid/implicit_incompressible_sph/system.jl index 6e54ce7b66..022d7cbae8 100644 --- a/src/schemes/fluid/implicit_incompressible_sph/system.jl +++ b/src/schemes/fluid/implicit_incompressible_sph/system.jl @@ -485,7 +485,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, diff --git a/test/systems/iisph_system.jl b/test/systems/iisph_system.jl index 2bcd740552..4279027bed 100644 --- a/test/systems/iisph_system.jl +++ b/test/systems/iisph_system.jl @@ -459,6 +459,47 @@ @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 mass = [1.0, 1.0] density = [1000.0, 950.0] From bb26a6065957f7ab8eeaca0904621266ce334f0d Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 26 May 2026 14:49:29 +0200 Subject: [PATCH 40/59] Reject IISPH EDAC mixing --- .../implicit_incompressible_sph/system.jl | 8 +++--- test/systems/iisph_system.jl | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/schemes/fluid/implicit_incompressible_sph/system.jl b/src/schemes/fluid/implicit_incompressible_sph/system.jl index 6e54ce7b66..0f8fe88466 100644 --- a/src/schemes/fluid/implicit_incompressible_sph/system.jl +++ b/src/schemes/fluid/implicit_incompressible_sph/system.jl @@ -734,9 +734,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/test/systems/iisph_system.jl b/test/systems/iisph_system.jl index 2bcd740552..0303cc9e90 100644 --- a/test/systems/iisph_system.jl +++ b/test/systems/iisph_system.jl @@ -489,4 +489,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 From 25428e9793c7b139ba77b3625790898eb8764b3c Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 26 May 2026 14:54:21 +0200 Subject: [PATCH 41/59] Update EDAC correction caches --- .../fluid/entropically_damped_sph/system.jl | 43 ++++++++++++++++ test/systems/edac_system.jl | 51 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/src/schemes/fluid/entropically_damped_sph/system.jl b/src/schemes/fluid/entropically_damped_sph/system.jl index 2b085d3ebb..c155c88507 100644 --- a/src/schemes/fluid/entropically_damped_sph/system.jl +++ b/src/schemes/fluid/entropically_damped_sph/system.jl @@ -299,11 +299,54 @@ function update_quantities!(system::EntropicallyDampedSPHSystem, v, u, end function update_pressure!(system::EntropicallyDampedSPHSystem, v, u, v_ode, u_ode, semi, t) + (; correction, density_calculator) = system + + # 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, + density_calculator) + 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, corr::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, correction, 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) +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 diff --git a/test/systems/edac_system.jl b/test/systems/edac_system.jl index ce04cd6774..ab5804f512 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}() From a16e0a4328359c1b7816ae7886dd9152a70a92ad Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Wed, 27 May 2026 14:05:07 +0200 Subject: [PATCH 42/59] fix tests --- docs/src/preprocessing/preprocessing.md | 6 +++--- src/TrixiParticles.jl | 2 +- src/preprocessing/geometries/polygon.jl | 7 ++++++- src/preprocessing/geometries/triangle_mesh.jl | 2 +- test/preprocessing/geometries/geometries.jl | 6 +++--- test/preprocessing/packing/nhs_faces.jl | 5 +++-- 6 files changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/src/preprocessing/preprocessing.md b/docs/src/preprocessing/preprocessing.md index 0518574b73..570c400279 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() diff --git a/src/TrixiParticles.jl b/src/TrixiParticles.jl index 215a2b3bf1..c606bfd08e 100644 --- a/src/TrixiParticles.jl +++ b/src/TrixiParticles.jl @@ -98,7 +98,7 @@ export trixi2vtk, vtk2trixi 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/preprocessing/geometries/polygon.jl b/src/preprocessing/geometries/polygon.jl index 3192633618..6c0407a739 100644 --- a/src/preprocessing/geometries/polygon.jl +++ b/src/preprocessing/geometries/polygon.jl @@ -153,7 +153,12 @@ end @inline Base.eltype(::Polygon{NDIMS, ELTYPE}) where {NDIMS, ELTYPE} = ELTYPE -@inline function Base.deleteat!(polygon::Polygon, indices) +""" + 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) vertex_normals = copy(polygon.vertex_normals) edge_normals = copy(polygon.edge_normals) diff --git a/src/preprocessing/geometries/triangle_mesh.jl b/src/preprocessing/geometries/triangle_mesh.jl index f35b95dcd1..13512786b6 100644 --- a/src/preprocessing/geometries/triangle_mesh.jl +++ b/src/preprocessing/geometries/triangle_mesh.jl @@ -171,7 +171,7 @@ end @inline face_normal(triangle, geometry::TriangleMesh) = geometry.face_normals[triangle] -@inline function Base.deleteat!(mesh::TriangleMesh, indices) +@inline function delete_faces(mesh::TriangleMesh, indices) face_vertices = copy(mesh.face_vertices) face_normals = copy(mesh.face_normals) diff --git a/test/preprocessing/geometries/geometries.jl b/test/preprocessing/geometries/geometries.jl index 3906af0aff..77cc5b4003 100644 --- a/test/preprocessing/geometries/geometries.jl +++ b/test/preprocessing/geometries/geometries.jl @@ -54,11 +54,11 @@ end end - @testset verbose=true "`deleteat!` Rebuilds Derived Data" begin + @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 = deleteat!(TrixiParticles.Polygon(triangle), [1, 2]) + edge_only = TrixiParticles.delete_faces(TrixiParticles.Polygon(triangle), [1, 2]) @test TrixiParticles.nfaces(edge_only) == 1 @test length(edge_only.vertices) == 2 @@ -74,7 +74,7 @@ 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]) - deleteat!(mesh, 1) + mesh = TrixiParticles.delete_faces(mesh, 1) @test TrixiParticles.nfaces(mesh) == 1 @test length(mesh.vertices) == 3 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)] From 6317c251fddc684d52850fdcbfe1d2f6e534bf12 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Wed, 27 May 2026 14:16:14 +0200 Subject: [PATCH 43/59] format --- src/setups/rectangular_shape.jl | 3 ++- test/setups/rectangular_shape.jl | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/setups/rectangular_shape.jl b/src/setups/rectangular_shape.jl index c54ba305ee..10e8e7e88a 100644 --- a/src/setups/rectangular_shape.jl +++ b/src/setups/rectangular_shape.jl @@ -341,7 +341,8 @@ function initialize_pressure_with_coordinate_density!(pressure, particle_spacing 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], + coords = SVector{NDIMS, eltype(coordinates)}(ntuple(dim -> coordinates[dim, + particle], Val(NDIMS))) density = density_fun(coords) diff --git a/test/setups/rectangular_shape.jl b/test/setups/rectangular_shape.jl index cd8500f160..aae2e8a290 100644 --- a/test/setups/rectangular_shape.jl +++ b/test/setups/rectangular_shape.jl @@ -159,7 +159,7 @@ @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] + 50.875] @test shape.mass ≈ particle_spacing^2 * shape.density end From bed33ff3280b6d19ce2e3db3f56637f7f8d494c5 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 7 Jul 2026 01:18:44 +0200 Subject: [PATCH 44/59] fix --- docs/literate/src/tut_2d_geometry.jl | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/literate/src/tut_2d_geometry.jl b/docs/literate/src/tut_2d_geometry.jl index d591d0f6d7..044e45ea63 100644 --- a/docs/literate/src/tut_2d_geometry.jl +++ b/docs/literate/src/tut_2d_geometry.jl @@ -10,10 +10,11 @@ # STL files are surface meshes and therefore naturally lead to thin 3D setups instead. # 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 OrdinaryDiffEq +using OrdinaryDiffEqLowStorageRK using Plots # ## Resolution @@ -113,7 +114,8 @@ viscosity = ArtificialViscosityMonaghan(alpha=0.02, beta=0.0) fluid_density_calculator = ContinuityDensity() density_diffusion = DensityDiffusionMolteniColagrossi(delta=0.1) -fluid_system = WeaklyCompressibleSPHSystem(setup.fluid, fluid_density_calculator, +fluid_system = WeaklyCompressibleSPHSystem(setup.fluid; + density_calculator=fluid_density_calculator, state_equation, smoothing_kernel, smoothing_length, viscosity=viscosity, density_diffusion=density_diffusion, From 6583c156b4891ee072ba0afbcb051de1c4980c23 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 7 Jul 2026 01:21:54 +0200 Subject: [PATCH 45/59] fix geom --- examples/preprocessing/data/coastline_profile_2d.asc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/preprocessing/data/coastline_profile_2d.asc b/examples/preprocessing/data/coastline_profile_2d.asc index 7986e07261..c250c87be4 100644 --- a/examples/preprocessing/data/coastline_profile_2d.asc +++ b/examples/preprocessing/data/coastline_profile_2d.asc @@ -1,5 +1,5 @@ # ASCII -0.18 -0.12 0 +0.15 -0.12 0 2.68 -0.12 0 2.68 1.08 0 2.62 0.66 0 @@ -16,5 +16,5 @@ 1.05 0.02 0 0.82 0.05 0 0.55 0.03 0 -0.18 0.03 0 -0.18 -0.12 0 +0.15 0.03 0 +0.15 -0.12 0 From 4f8d3e3c4f02e00c9ab8e4cafc5d78c1d0682130 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 7 Jul 2026 10:25:44 +0200 Subject: [PATCH 46/59] format --- docs/make.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/make.jl b/docs/make.jl index ab81a3f005..237e4978ba 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -97,7 +97,7 @@ makedocs(sitename="TrixiParticles.jl", ], "Preprocessing" => [ "Setting up a 2D simulation from geometry files" => joinpath("tutorials", - "tut_2d_geometry.md"), + "tut_2d_geometry.md"), "Particle packing tutorial" => joinpath("tutorials", "tut_packing.md") ] From 9bcf0ec0869a896bd19d616681e034c1fdec42f6 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 7 Jul 2026 10:53:44 +0200 Subject: [PATCH 47/59] fix docs --- docs/literate/src/tut_packing.jl | 3 ++- docs/literate/src/tut_setup.jl | 3 ++- docs/src/general/neighborhood_search.md | 3 ++- docs/src/gpu.md | 2 +- src/callbacks/mechanical_work_calculator.jl | 3 ++- src/general/semidiscretization.jl | 12 ++++++++---- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/literate/src/tut_packing.jl b/docs/literate/src/tut_packing.jl index d5b9610283..feedd5486c 100644 --- a/docs/literate/src/tut_packing.jl +++ b/docs/literate/src/tut_packing.jl @@ -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. diff --git a/docs/literate/src/tut_setup.jl b/docs/literate/src/tut_setup.jl index b546c5eb89..30f2d71ac6 100644 --- a/docs/literate/src/tut_setup.jl +++ b/docs/literate/src/tut_setup.jl @@ -144,7 +144,8 @@ 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/src/general/neighborhood_search.md b/docs/src/general/neighborhood_search.md index 93019eaffc..f2e0d47d2d 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/gpu.md b/docs/src/gpu.md index a7bdd6c3af..5e19a0a285 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)) diff --git a/src/callbacks/mechanical_work_calculator.jl b/src/callbacks/mechanical_work_calculator.jl index f20672af09..07895673fd 100644 --- a/src/callbacks/mechanical_work_calculator.jl +++ b/src/callbacks/mechanical_work_calculator.jl @@ -28,7 +28,8 @@ The accumulated value can be retrieved via [`calculated_mechanical_work`](@ref). # Arguments - `system`: The [`TotalLagrangianSPHSystem`](@ref) whose particles should be monitored. -- `semi`: The [`Semidiscretization`](@ref) that contains `system`. +- `semi`: The [`Semidiscretization`](@ref TrixiParticles.Semidiscretization) + that contains `system`. # Keywords - `interval=1`: Interval (in number of time steps) at which to compute the instantaneous power. diff --git a/src/general/semidiscretization.jl b/src/general/semidiscretization.jl index 959ed70deb..10fca415e3 100644 --- a/src/general/semidiscretization.jl +++ b/src/general/semidiscretization.jl @@ -189,14 +189,17 @@ 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 - `restart_with`: Can be used to restart the simulation from VTK solution files (see [`SolutionSavingCallback`](@ref)). This can be either `nothing` (default, no restart) or a `Tuple` of filenames, - one for each system in the [`Semidiscretization`](@ref). - The order of the filenames must match the order of the systems in the [`Semidiscretization`](@ref). + one for each system in the + [`Semidiscretization`](@ref TrixiParticles.Semidiscretization). + The order of the filenames must match the order of the systems in the + [`Semidiscretization`](@ref TrixiParticles.Semidiscretization). Note that `semidiscretize` replaces the initial time (`tspan[1]`) with the timestamp read from the VTK files. If the user-provided `tspan[1]` does not match the restart time, it is adjusted and an info message is logged. If multiple files are provided, their @@ -356,7 +359,8 @@ end Set the initial coordinates and velocities of all systems in `semi` to the final values in the solution `sol`. [`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 From ae4b14ba7c09bab948c6533c9a52bcee87a49fda Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 7 Jul 2026 15:25:51 +0200 Subject: [PATCH 48/59] fixes --- docs/src/systems/boundary.md | 7 ++++-- docs/src/systems/fluid.md | 17 ++++++-------- .../systems/implicit_incompressible_sph.md | 2 +- docs/src/systems/weakly_compressible_sph.md | 22 ++++++++++++------- 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/docs/src/systems/boundary.md b/docs/src/systems/boundary.md index 46463b10ac..3792b7c604 100644 --- a/docs/src/systems/boundary.md +++ b/docs/src/systems/boundary.md @@ -106,14 +106,17 @@ where the sum is over all fluid particles, ``\rho_f`` and ``p_f`` denote the den ``` #### 2. [`BernoulliPressureExtrapolation`](@ref) -Identical to the pressure ``p_b`` calculated via [`AdamiPressureExtrapolation`](@ref), but it adds an additional dynamic pressure term. For moving wall boundaries, the implementation uses +Identical to the pressure ``p_b`` calculated via [`AdamiPressureExtrapolation`](@ref), +but it adds an additional dynamic pressure term: ```math p_b = \frac{\sum_f (p_f + p_{f,\mathrm{dyn}} + \rho_f (\bm{g} - \bm{a}_b) \cdot \bm{r}_{bf}) W(\Vert r_{bf} \Vert, h)}{\sum_f W(\Vert r_{bf} \Vert, h)}, ``` with ```math p_{f,\mathrm{dyn}} = \frac{1}{2} \, \text{factor} \, \rho_f -\frac{\left((\bm{v}_b - \bm{v}_f) \cdot \bm{r}_{bf}\right)^2}{\Vert \bm{r}_{bf} \Vert}, +\left( +\frac{(\bm{v}_b - \bm{v}_f) \cdot \bm{r}_{bf}}{\Vert \bm{r}_{bf} \Vert} +\right)^2, ``` where ``\bm{v}_f`` is the fluid velocity and ``\bm{v}_b`` is the boundary velocity. This adjustment provides a higher boundary pressure for bodies moving with a relative velocity to the fluid to prevent penetration. diff --git a/docs/src/systems/fluid.md b/docs/src/systems/fluid.md index a22bf11e9e..283aa628cb 100644 --- a/docs/src/systems/fluid.md +++ b/docs/src/systems/fluid.md @@ -61,22 +61,19 @@ by Balsara ([Balsara1995](@cite)) or Morris ([Morris1997](@cite)). ##### Mathematical Formulation -The force exerted by particle ``b`` on particle ``a`` due to artificial viscosity is given by +The implemented acceleration contribution from particle ``b`` to particle ``a`` is ```math -\bm{F}_{ab}^{\text{AV}} = - m_a m_b \Pi_{ab} \nabla_a W_{ab}. +\left.\frac{\mathrm{d}\bm{v}_a}{\mathrm{d}t}\right|_{ab}^{\text{AV}} = +\begin{cases} + m_b \frac{\alpha c \mu_{ab} + \beta \mu_{ab}^2}{\bar{\rho}_{ab}} + \nabla_a W_{ab}, & \text{if } \bm{v}_{ab} \cdot \bm{r}_{ab} < 0, \\ + 0, & \text{otherwise}. +\end{cases} ``` where: -- ``\Pi_{ab}`` is the artificial viscosity term defined as: - ```math - \Pi_{ab} = - \begin{cases} - -\frac{\alpha c \mu_{ab} + \beta \mu_{ab}^2}{\bar{\rho}_{ab}} & \text{if } \mathbf{v}_{ab} \cdot \mathbf{r}_{ab} < 0, \\ - 0 & \text{otherwise} - \end{cases} - ``` - ``\alpha`` and ``\beta`` are viscosity parameters, - ``c`` is the local speed of sound, - ``\bar{\rho}_{ab}`` is the arithmetic mean of the densities of particles ``a`` and ``b``. diff --git a/docs/src/systems/implicit_incompressible_sph.md b/docs/src/systems/implicit_incompressible_sph.md index 575d7d84a9..9fd184b553 100644 --- a/docs/src/systems/implicit_incompressible_sph.md +++ b/docs/src/systems/implicit_incompressible_sph.md @@ -387,7 +387,7 @@ However, the off-diagonal term ``\sum_{j \neq i} a_{ij} p_j`` in the relaxed Ja changes slightly. For pressure boundaries it takes the form ```math -\sum_{j \neq i} a_{ij} p_j = \sum_f m_f \left( \sum_k d_{ik} p_k - d_{ff} p_f - \sum_{k \neq f} d_{fk} p_k \right) \cdot \nabla W_{if} +\sum_{j \neq i} a_{ij} p_j = \sum_f m_f \left( \sum_k d_{ik} p_k - d_{ff} p_f - \sum_{k \neq i} d_{fk} p_k \right) \cdot \nabla W_{if} + \sum_b m_b \left( \sum_k d_{ik} p_k \right) \cdot \nabla W_{ib}, ``` where ``k`` represents all neighboring particles of ``i`` (both fluid and boundary). diff --git a/docs/src/systems/weakly_compressible_sph.md b/docs/src/systems/weakly_compressible_sph.md index 7044ef9efa..110bb2d64d 100644 --- a/docs/src/systems/weakly_compressible_sph.md +++ b/docs/src/systems/weakly_compressible_sph.md @@ -52,10 +52,11 @@ pressure field. It is highly recommended to use density diffusion when using WCS ### Formulation All density diffusion terms extend the continuity equation (see [`ContinuityDensity`](@ref)) -by an additional term. In the literature, this is typically written for a fixed smoothing -length ``h`` as +by an additional term. With the continuity formulation used in TrixiParticles.jl, +this is written for a fixed smoothing length ``h`` as ```math -\frac{\mathrm{d}\rho_a}{\mathrm{d}t} = \sum_{b} m_b v_{ab} \cdot \nabla W_{ab} +\frac{\mathrm{d}\rho_a}{\mathrm{d}t} = + \sum_{b} m_b \frac{\rho_a}{\rho_b} v_{ab} \cdot \nabla W_{ab} + \delta h c \sum_{b} V_b \psi_{ab} \cdot \nabla W_{ab}, ``` where ``V_b = m_b / \rho_b`` is the volume of particle ``b`` and ``\psi_{ab}`` depends on @@ -66,7 +67,8 @@ difference of the coordinates, ``v_{ab} = v_a - v_b`` of the velocities of parti ``a`` and ``b``. When particle-wise smoothing lengths are used, the corresponding pairwise form is ```math -\frac{\mathrm{d}\rho_a}{\mathrm{d}t} = \sum_{b} m_b v_{ab} \cdot \nabla W_{ab} +\frac{\mathrm{d}\rho_a}{\mathrm{d}t} = + \sum_{b} m_b \frac{\rho_a}{\rho_b} v_{ab} \cdot \nabla W_{ab} + \delta c \sum_{b} \bar{h}_{ab} V_b \psi_{ab} \cdot \nabla W_{ab}, ``` with ``\bar{h}_{ab} = \frac{1}{2}(h_a + h_b)``. TrixiParticles.jl uses this pairwise @@ -138,6 +140,9 @@ in such simulations. ### Mathematical formulation We use the following formulation by [Sun et al. (2018)](@cite Sun2018). +The relation ``\text{CFL} \cdot \text{Ma} = \Delta t \, v_\text{max} / h`` +is stated there on page 29, immediately above Equation 9, and gives the +dimensional form below. After each time step, a correction term ``\delta \bm{r}_a`` is added to the position ``\bm{r}_a`` of particle ``a``, which is given by ```math @@ -162,15 +167,16 @@ In TrixiParticles.jl, the same correction is applied through a shifting velocity ``` with ```math -\delta \bm{v}_a = - v_\text{max} \frac{(2h)^2}{2\Delta x} +\delta \bm{v}_a = - v_* \frac{(2h)^2}{2\Delta x} \sum_b \left( 1 + \frac{2}{10} \left( \frac{W_{ab}}{W(\Delta x)} \right)^4 \right) \frac{m_b}{\rho_a + \rho_b} \nabla_a W_{ab}. ``` +Here, ``v_*`` is the velocity scale configured by the shifting technique. It is either +``v_\text{factor}\max_a \Vert \bm{v}_a \Vert`` when `v_max_factor` is used, or +``v_\text{factor} c`` when `sound_speed_factor` is used. This corresponds to the same PST idea, but with the commonly used constants fixed to ``R = 0.2`` and ``n = 4``. The prefactor is written in a form that keeps the magnitude of -the shifting correction consistent when the smoothing-length factor changes. In particular, -``\text{CFL} \cdot \text{Ma}`` is replaced by ``\Delta t \, v_\text{max} / h``, as explained -by [Sun et al. (2018)](@cite Sun2018) on page 29, immediately above Equation 9. +the shifting correction consistent when the smoothing-length factor changes. The ``\delta``-SPH method (WCSPH with density diffusion) together with this formulation of PST is commonly referred to as ``\delta^+``-SPH. From 7e6967b2a768addc3855ba12d4a4353095caa8e0 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 7 Jul 2026 16:03:22 +0200 Subject: [PATCH 49/59] Clarify SPH docs wording --- docs/src/systems/boundary.md | 19 +++--- docs/src/systems/fluid.md | 25 ++------ .../systems/implicit_incompressible_sph.md | 5 +- docs/src/systems/weakly_compressible_sph.md | 62 +++++-------------- 4 files changed, 27 insertions(+), 84 deletions(-) diff --git a/docs/src/systems/boundary.md b/docs/src/systems/boundary.md index 271e2803c7..fac4144a83 100644 --- a/docs/src/systems/boundary.md +++ b/docs/src/systems/boundary.md @@ -16,11 +16,6 @@ Pages = [joinpath("schemes", "boundary", "prescribed_motion.jl")] # [Boundary Models](@id boundary_models) -!!! note - The pairwise interaction terms below are written in force form, following the SPH literature. - TrixiParticles.jl applies the corresponding accelerations internally. Where the implemented - boundary discretization differs from the literature formula, both forms are stated explicitly. - ## Dummy Particles Boundaries modeled as dummy particles, which are treated like fluid particles, @@ -46,8 +41,8 @@ In the literature, this kind of boundary particles is referred to as The key detail of this boundary condition and the only difference between the boundary models in these references is the way the density and pressure of boundary particles is computed. -For the standard summation-density pressure force, the force on fluid particle ``a`` -due to boundary particle ``b`` is +Since boundary particles are treated like fluid particles, the pressure force on fluid particle +``a`` due to boundary particle ``b`` is ```math \bm{f}_{ab}^{p} = -m_a m_b \left( \frac{p_a}{\rho_a^2} + \frac{p_b}{\rho_b^2} \right) @@ -107,7 +102,7 @@ where the sum is over all fluid particles, ``\rho_f`` and ``p_f`` denote the den #### 2. [`BernoulliPressureExtrapolation`](@ref) Identical to the pressure ``p_b`` calculated via [`AdamiPressureExtrapolation`](@ref), -but it adds an additional dynamic pressure term: +but with an added dynamic pressure term: ```math p_b = \frac{\sum_f (p_f + p_{f,\mathrm{dyn}} + \rho_f (\bm{g} - \bm{a}_b) \cdot \bm{r}_{bf}) W(\Vert r_{bf} \Vert, h)}{\sum_f W(\Vert r_{bf} \Vert, h)}, ``` @@ -119,8 +114,8 @@ p_{f,\mathrm{dyn}} = \frac{1}{2} \, \text{factor} \, \rho_f \right)^2, ``` where ``\bm{v}_f`` is the fluid velocity and ``\bm{v}_b`` is the boundary velocity. -This adjustment provides a higher boundary pressure for bodies moving with a relative velocity to the fluid to prevent penetration. -This modification is original and not derived from any literature source. +This implementation-specific term raises the boundary pressure based on the normal +component of the relative boundary-fluid velocity and is not taken from a literature formula. ```@docs BernoulliPressureExtrapolation @@ -182,8 +177,8 @@ condition is applied. ## Repulsive Particles -Boundaries modeled as boundary particles which exert repulsive interactions on the fluid particles ([Monaghan, Kajtar, 2009](@cite Monaghan2009)). -The literature force on fluid particle ``a`` due to boundary particle ``b`` is +Boundaries modeled as boundary particles which exert forces on the fluid particles ([Monaghan, Kajtar, 2009](@cite Monaghan2009)). +The force on fluid particle ``a`` due to boundary particle ``b`` is ```math \bm{f}_{ab} = m_a \left(\tilde{\bm{f}}_{ab} - m_b \Pi_{ab} \nabla_{r_a} W(\Vert r_a - r_b \Vert, h)\right) diff --git a/docs/src/systems/fluid.md b/docs/src/systems/fluid.md index 283aa628cb..cf187ad48a 100644 --- a/docs/src/systems/fluid.md +++ b/docs/src/systems/fluid.md @@ -4,12 +4,6 @@ Currently available fluid methods are the [weakly compressible SPH method](@ref [entropically damped artificial compressibility for SPH](@ref edac). This page lists models and techniques that apply to both of these methods. -!!! note - The formulas on this page follow the force notation commonly used in the SPH literature. - TrixiParticles.jl usually evaluates the corresponding acceleration contributions internally. - Whenever the implemented discretization differs from the literature formula, both forms are - stated explicitly. - ## [Viscosity](@id viscosity_sph) Viscosity is a critical physical property governing momentum diffusion within a fluid. @@ -61,7 +55,7 @@ by Balsara ([Balsara1995](@cite)) or Morris ([Morris1997](@cite)). ##### Mathematical Formulation -The implemented acceleration contribution from particle ``b`` to particle ``a`` is +The acceleration contribution from particle ``b`` to particle ``a`` is ```math \left.\frac{\mathrm{d}\bm{v}_a}{\mathrm{d}t}\right|_{ab}^{\text{AV}} = @@ -249,12 +243,7 @@ where: - ``\rho_b`` is the density of particle ``b``, - ``\nabla_a W_{ab}`` is the gradient of the smoothing kernel ``W_{ab}`` with respect to particle ``a``. -Implementation note: for the single-fluid surface-normal calculation in TrixiParticles.jl, -this reduces to -```math -\bm{n}_a = \sum_b m_b \frac{1}{\rho_b} \nabla_a W_{ab}, -``` -i.e. effectively ``c_b = 1`` for neighboring fluid particles. +For single-fluid surface-normal calculations, ``c_b = 1`` for neighboring fluid particles. #### Normalization of surface normals @@ -348,15 +337,11 @@ difference in surface normals: ``` where ``\bm{n}_a`` and ``\bm{n}_b`` are the surface normals of the interacting particles. -Implementation note: TrixiParticles.jl uses +In acceleration form, TrixiParticles.jl uses the local smoothing length: ```math \left.\frac{\mathrm{d}\bm{v}_a}{\mathrm{d} t}\right|_{ab}^{\text{curvature}} = -\sigma h (\bm{n}_a - \bm{n}_b), ``` -which uses the same normal-difference direction but scales the magnitude with the -local smoothing length ``h``. For constant ``h``, this factor can be absorbed into -the coefficient ``\sigma``; for variable smoothing lengths, it makes the curvature -contribution depend on the local kernel support. #### Wall adhesion force @@ -433,13 +418,11 @@ This divergence can be computed numerically in the SPH framework as = m_a \sum_b \frac{m_b}{\rho_a \rho_b} (\bm{S}_a + \bm{S}_b) \nabla_a W_{ab}. ``` -Implementation note: TrixiParticles.jl evaluates the corresponding acceleration and uses -the stabilized stress tensor +TrixiParticles.jl stores ``\sigma`` outside the tensor and uses the stabilized tensor ```math \bm{S}_a^{\text{impl}} = \delta_{s,a} (I - \hat{\bm{n}}_a \otimes \hat{\bm{n}}_a) - \delta_{s,\max} I, ``` -with the factor ``\sigma`` applied outside the pairwise sum. #### Advantages and limitations diff --git a/docs/src/systems/implicit_incompressible_sph.md b/docs/src/systems/implicit_incompressible_sph.md index 9fd184b553..3f0ffbd2a2 100644 --- a/docs/src/systems/implicit_incompressible_sph.md +++ b/docs/src/systems/implicit_incompressible_sph.md @@ -5,10 +5,7 @@ is a method that achieves incompressibility by solving the pressure Poisson equa The resulting linear system is iteratively solved with the relaxed Jacobi method. Unlike the [weakly compressible SPH method](@ref wcsph), incompressible methods determine pressure by enforcing the incompressibility constraint rather than using an equation of -state. In the derivation below, we keep the force notation of -[Ihmsen et al. (2013)](@cite Ihmsen2013), which is also the standard presentation in the -IISPH literature. Internally, TrixiParticles.jl applies the equivalent pressure -accelerations ``\bm{F}_i^p / m_i``; this changes only the presentation, not the algebra. +state. ```@autodocs Modules = [TrixiParticles] diff --git a/docs/src/systems/weakly_compressible_sph.md b/docs/src/systems/weakly_compressible_sph.md index 110bb2d64d..86edf2590c 100644 --- a/docs/src/systems/weakly_compressible_sph.md +++ b/docs/src/systems/weakly_compressible_sph.md @@ -52,28 +52,19 @@ pressure field. It is highly recommended to use density diffusion when using WCS ### Formulation All density diffusion terms extend the continuity equation (see [`ContinuityDensity`](@ref)) -by an additional term. With the continuity formulation used in TrixiParticles.jl, -this is written for a fixed smoothing length ``h`` as +by an additional term: ```math \frac{\mathrm{d}\rho_a}{\mathrm{d}t} = \sum_{b} m_b \frac{\rho_a}{\rho_b} v_{ab} \cdot \nabla W_{ab} - + \delta h c \sum_{b} V_b \psi_{ab} \cdot \nabla W_{ab}, + + \delta c \sum_{b} \bar{h}_{ab} V_b \psi_{ab} \cdot \nabla W_{ab}, ``` -where ``V_b = m_b / \rho_b`` is the volume of particle ``b`` and ``\psi_{ab}`` depends on +where ``\bar{h}_{ab} = \frac{1}{2}(h_a + h_b)`` is the averaged smoothing length, +``V_b = m_b / \rho_b`` is the volume of particle ``b`` and ``\psi_{ab}`` depends on the density diffusion method (see [`AbstractDensityDiffusion`](@ref TrixiParticles.AbstractDensityDiffusion) for available terms). Also, ``\rho_a`` denotes the density of particle ``a`` and ``r_{ab} = r_a - r_b`` is the difference of the coordinates, ``v_{ab} = v_a - v_b`` of the velocities of particles -``a`` and ``b``. When particle-wise smoothing lengths are used, the corresponding -pairwise form is -```math -\frac{\mathrm{d}\rho_a}{\mathrm{d}t} = - \sum_{b} m_b \frac{\rho_a}{\rho_b} v_{ab} \cdot \nabla W_{ab} - + \delta c \sum_{b} \bar{h}_{ab} V_b \psi_{ab} \cdot \nabla W_{ab}, -``` -with ``\bar{h}_{ab} = \frac{1}{2}(h_a + h_b)``. TrixiParticles.jl uses this pairwise -average, which is the natural extension of the standard formula to variable smoothing -lengths. For fixed smoothing length, both expressions coincide. +``a`` and ``b``. For fixed smoothing length, ``\bar{h}_{ab} = h``. ### Numerical Results @@ -161,7 +152,7 @@ where: - ``m_b`` is the mass of particle ``b``, - ``\rho_a, \rho_b`` is the density of particles ``a`` and ``b``, respectively. -In TrixiParticles.jl, the same correction is applied through a shifting velocity +TrixiParticles.jl applies this correction through a shifting velocity ```math \delta \bm{r}_a = \Delta t \, \delta \bm{v}_a, ``` @@ -174,9 +165,7 @@ with Here, ``v_*`` is the velocity scale configured by the shifting technique. It is either ``v_\text{factor}\max_a \Vert \bm{v}_a \Vert`` when `v_max_factor` is used, or ``v_\text{factor} c`` when `sound_speed_factor` is used. -This corresponds to the same PST idea, but with the commonly used constants fixed to -``R = 0.2`` and ``n = 4``. The prefactor is written in a form that keeps the magnitude of -the shifting correction consistent when the smoothing-length factor changes. +The constants are fixed to ``R = 0.2`` and ``n = 4``. The ``\delta``-SPH method (WCSPH with density diffusion) together with this formulation of PST is commonly referred to as ``\delta^+``-SPH. @@ -217,34 +206,18 @@ This means that a non-vanishing contribution appears only when the particles are so ``p_{\text{background}}`` acts as a prefactor that regularizes the trajectories and promotes more uniform particle distributions. -In TrixiParticles.jl, this background-pressure contribution is evaluated through the -currently selected pressure-acceleration formulation instead of hard-coding the -specific ``(V_a^2 + V_b^2)`` discretization. This keeps the TVF term consistent with the -pressure discretization used in the rest of the scheme and automatically adapts it when a -different pressure-acceleration formulation is chosen. For the default -continuity-density formulation, -```math -\left.\frac{\mathrm{d}\bm{v}_a}{\mathrm{d} t}\right|_{p} -= - \sum_b m_b \frac{p_a + p_b}{\rho_a \rho_b} \nabla_a W_{ab}, -``` -setting ``p_a = p_b = 1`` gives the discrete background-pressure operator -```math -- \sum_b \frac{2m_b}{\rho_a \rho_b} \nabla_a W_{ab}. -``` -In the TVF update this operator is multiplied by the background pressure and the time-step -factor from the transport-velocity correction. TrixiParticles.jl then removes the explicit -dependence on ``\Delta t`` by using the CFL estimate employed by [Adami et al. (2013)](@cite Adami2013), +TrixiParticles.jl evaluates this term with the selected pressure-acceleration operator. +For the default [`ContinuityDensity`](@ref) pressure acceleration and the CFL estimate +used by [Adami et al. (2013)](@cite Adami2013), ```math \Delta t \leq \frac{1}{4} \frac{h}{c_s}, ``` -as an equality, so that the resulting shifting-velocity contribution becomes +used as an equality, this gives ```math \delta \bm{v}_a = - \frac{p_{\text{background}}}{8} \frac{h}{c_s} \sum_b \frac{2m_b}{\rho_a \rho_b} \nabla_a W_{ab}, ``` -where ``h`` is the smoothing length and ``c_s`` is the speed of sound. This explains -both why the implemented pairwise factor differs from ``V_a^2 + V_b^2`` and why the -factor ``h / c_s`` appears in the final expression. +where ``h`` is the smoothing length and ``c_s`` is the speed of sound. The inviscid momentum equation with an additional convection term for a particle moving with ``\tilde{v}`` is @@ -269,22 +242,17 @@ Here, ``\tilde{p}_{ab}`` is the density-weighted pressure with ``\rho_a``, ``\rho_b`` and ``p_a``, ``p_b`` denoting the densities and pressures of particles ``a`` and ``b``, respectively. -As for the background-pressure term above, TrixiParticles.jl evaluates the additional -convection term through the selected pressure-acceleration formulation instead of -hard-coding the ``(V_a^2 + V_b^2)`` form. For the default continuity-density -formulation, this gives +TrixiParticles.jl evaluates this additional term with the selected pressure-acceleration +operator. For the default [`ContinuityDensity`](@ref) pressure acceleration, this gives ```math \left.\frac{\tilde{\mathrm{d}} v_a}{\mathrm{d}t}\right|_{\bm{A}} = - \sum_b \frac{m_b}{\rho_a \rho_b} \left(\bm{A}_a + \bm{A}_b \right) \cdot \nabla_a W_{ab}. ``` -Here, ``\bm{A}_a`` and ``\bm{A}_b`` are the convection tensors of particles ``a`` and ``b``, -with, for example, +Here, for example, ```math \bm{A}_a = \rho_a \bm{v}_a \left(\tilde{\bm{v}}_a - \bm{v}_a\right)^T. ``` -Thus, the implemented form is again the operator-consistent version of the literature -discretization for the pressure-acceleration formulation used by the scheme. To apply the TVF, use the keyword argument `shifting_technique` in the constructor of a system that supports it. From c79d8851149709dc8f9d80c2f2b0d3fc5c231351 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Tue, 7 Jul 2026 17:24:12 +0200 Subject: [PATCH 50/59] fixes --- docs/src/systems/boundary.md | 21 ++++++++++++--------- docs/src/systems/fluid.md | 33 +++++++++++++++------------------ src/callbacks/stepsize.jl | 4 ++-- src/schemes/fluid/viscosity.jl | 6 +++--- 4 files changed, 32 insertions(+), 32 deletions(-) diff --git a/docs/src/systems/boundary.md b/docs/src/systems/boundary.md index fac4144a83..3c65ddf19c 100644 --- a/docs/src/systems/boundary.md +++ b/docs/src/systems/boundary.md @@ -41,8 +41,10 @@ In the literature, this kind of boundary particles is referred to as The key detail of this boundary condition and the only difference between the boundary models in these references is the way the density and pressure of boundary particles is computed. -Since boundary particles are treated like fluid particles, the pressure force on fluid particle -``a`` due to boundary particle ``b`` is +Since boundary particles are treated like fluid particles, their density and pressure enter +the pressure-acceleration operator selected by the interacting fluid system. For the +summation-density pressure operator, the pressure force on fluid particle ``a`` due to +boundary particle ``b`` is ```math \bm{f}_{ab}^{p} = -m_a m_b \left( \frac{p_a}{\rho_a^2} + \frac{p_b}{\rho_b^2} \right) @@ -133,7 +135,8 @@ reference pressure (the corresponding pressure to the reference density by the s #### 6. [`PressureMirroring`](@ref) Instead of calculating density and pressure for each boundary particle, we modify the -pressure force, +boundary pressure used in the pressure-acceleration operator. For the summation-density +pressure operator, this corresponds to modifying the pressure force ```math \bm{F}_a^{p} = -m_a \sum_b m_b \left( \frac{p_a}{\rho_a^2} + \frac{p_b}{\rho_b^2} \right) \nabla_a W_{ab}, ``` @@ -188,15 +191,15 @@ with \tilde{\bm{f}}_{ab} = \frac{K}{\beta^{n-1}} \frac{\bm{r}_{ab}} {\Vert \bm{r}_{ab} \Vert (\Vert \bm{r}_{ab} \Vert - d)} -\Phi(\Vert \bm{r}_{ab} \Vert, h)\, -\frac{2 m_b}{m_a + m_b}, +\Phi(\Vert \bm{r}_{ab} \Vert, h), ``` where ``m_a`` and ``m_b`` are the masses of fluid particle ``a`` and boundary particle ``b`` respectively, ``\bm{r}_{ab} = \bm{r}_a - \bm{r}_b`` is the difference of the coordinates of particles ``a`` and ``b``, ``d`` denotes the boundary particle spacing and ``n`` denotes the number of dimensions (see [Monaghan & Kajtar, 2009](@cite Monaghan2009), Equation (3.1) and [Valizadeh & Monaghan, 2015](@cite Valizadeh2015)). -Note that the repulsive acceleration $\tilde{f}_{ab}$ does not depend on the masses of -the boundary particles. +The implemented repulsive acceleration ``\tilde{\bm{f}}_{ab}`` does not depend on the particle masses. +The denominator ``\Vert \bm{r}_{ab} \Vert - d`` is clipped from below by ``d/100`` in the +implementation to avoid the singularity at ``\Vert \bm{r}_{ab} \Vert = d``. Here, ``\Phi`` denotes the 1D Wendland C4 kernel, normalized to ``1.77`` for ``q=0`` ([Monaghan & Kajtar, 2009](@cite Monaghan2009), Section 4), with ``\Phi(r, h) = w(r/h)`` and ```math @@ -219,8 +222,8 @@ In [Monaghan & Kajtar (2009)](@cite Monaghan2009), a value of ``gD`` is used for where ``g`` is the gravitational acceleration and ``D`` is the depth of the fluid. The viscosity ``\Pi_{ab}`` is calculated according to the viscosity used in the -simulation, where the density of the boundary particle if needed is assumed to be -identical to the density of the fluid particle. +simulation. When a boundary density is needed, it is computed from the boundary +hydrodynamic mass and boundary particle spacing as ``m_b / d^n``. ### No-slip condition diff --git a/docs/src/systems/fluid.md b/docs/src/systems/fluid.md index cf187ad48a..59f93c0766 100644 --- a/docs/src/systems/fluid.md +++ b/docs/src/systems/fluid.md @@ -298,11 +298,12 @@ In the following table some values are shown for reference. The values marked wi ### [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: +The [Akinci](@cite Akinci2013) model divides surface tension into distinct force components, +which TrixiParticles.jl applies as acceleration contributions. -#### Cohesion force +#### Cohesion contribution -The cohesion force captures the attraction between particles at the fluid interface, creating the effect of surface tension. +The cohesion contribution captures the attraction between particles at the fluid interface, creating the effect of surface tension. It is defined by the distance between particles and the support radius ``h_c``, using a kernel-based formulation. **Key features:** @@ -310,10 +311,11 @@ It is defined by the distance between particles and the support radius ``h_c``, - Particles within half the support radius experience a repulsive force to prevent clustering. - Particles beyond half the radius but within the support radius experience an attractive force to simulate cohesion. -The pairwise cohesion force is +In the acceleration form used by TrixiParticles.jl, the pairwise cohesion contribution is ```math -\bm{F}_{\text{cohesion}} = -\sigma m_b C(r) \frac{\bm{r}}{\Vert \bm{r} \Vert}, +\left.\frac{\mathrm{d}\bm{v}_a}{\mathrm{d} t}\right|_{ab}^{\text{cohesion}} += -\sigma m_b C(r) \frac{\bm{r}}{\Vert \bm{r} \Vert}, ``` where ``C(r)``, the cohesion kernel, is defined as: @@ -327,29 +329,24 @@ C(r)=\frac{32}{\pi h_c^9} \end{cases} ``` -#### Surface area minimization force +#### Surface area minimization contribution -The surface area minimization force models curvature reduction and acts on the -difference in surface normals: - -```math -\bm{F}_{\text{curvature}} = -\sigma (\bm{n}_a - \bm{n}_b), -``` - -where ``\bm{n}_a`` and ``\bm{n}_b`` are the surface normals of the interacting particles. -In acceleration form, TrixiParticles.jl uses the local smoothing length: +The surface area minimization contribution models curvature reduction and acts on the +difference in surface normals. TrixiParticles.jl uses the local smoothing length: ```math \left.\frac{\mathrm{d}\bm{v}_a}{\mathrm{d} t}\right|_{ab}^{\text{curvature}} = -\sigma h (\bm{n}_a - \bm{n}_b), ``` +where ``\bm{n}_a`` and ``\bm{n}_b`` are the surface normals of the interacting particles. -#### Wall adhesion force +#### Wall adhesion contribution -This force models the interaction between fluid and solid boundaries, simulating adhesion effects at walls. +This contribution models the interaction between fluid and solid boundaries, simulating adhesion effects at walls. It uses a custom kernel with a peak at 0.75 times the support radius: ```math -\bm{F}_{\text{adhesion}} = -\beta m_b A(r) \frac{\bm{r}}{\Vert \bm{r} \Vert}, +\left.\frac{\mathrm{d}\bm{v}_a}{\mathrm{d} t}\right|_{ab}^{\text{adhesion}} += -\beta m_b A(r) \frac{\bm{r}}{\Vert \bm{r} \Vert}, ``` where ``A(r)`` is the adhesion kernel: diff --git a/src/callbacks/stepsize.jl b/src/callbacks/stepsize.jl index c34c3d666b..73c0cfe164 100644 --- a/src/callbacks/stepsize.jl +++ b/src/callbacks/stepsize.jl @@ -16,11 +16,11 @@ The step size is therefore only applied once at the beginning of the simulation. The step size ``\Delta t`` is chosen as the minimum ```math - \Delta t = \min(\Delta t_\eta, \Delta t_a, \Delta t_c), + \Delta t = \min(\Delta t_\nu, \Delta t_a, \Delta t_c), ``` where ```math - \Delta t_\eta = 0.125 \, h^2 / \eta, \quad \Delta t_a = 0.25 \sqrt{h / \lVert g \rVert}, + \Delta t_\nu = 0.125 \, h^2 / \nu, \quad \Delta t_a = 0.25 \sqrt{h / \lVert g \rVert}, \quad \Delta t_c = \text{CFL} \, h / c, ``` with ``\nu = \alpha h c / (2n + 4)``, where ``\alpha`` is the parameter of the viscosity diff --git a/src/schemes/fluid/viscosity.jl b/src/schemes/fluid/viscosity.jl index b39ccb0ef4..b81d637239 100644 --- a/src/schemes/fluid/viscosity.jl +++ b/src/schemes/fluid/viscosity.jl @@ -285,7 +285,7 @@ end end @doc raw""" - ViscosityAdamiSGS(; nu, C_S=0.1, epsilon=0.01) + ViscosityAdamiSGS(; nu, C_S=0.1, epsilon=0.001) Viscosity model that extends the standard [Adami formulation](@ref ViscosityAdami) by incorporating a subgrid-scale (SGS) eddy viscosity via a Smagorinsky-type [Smagorinsky (1963)](@cite Smagorinsky1963) closure. @@ -325,7 +325,7 @@ This model is appropriate for turbulent flows where unresolved scales contribute # Keywords - `nu`: Standard kinematic viscosity. - `C_S`: Smagorinsky constant. -- `epsilon=0.01`: Parameter to prevent singularities +- `epsilon=0.001`: Parameter to prevent singularities """ struct ViscosityAdamiSGS{ELTYPE} nu :: ELTYPE # Kinematic viscosity [e.g., 1e-6 m²/s] @@ -447,7 +447,7 @@ This model is appropriate for turbulent flows where unresolved scales contribute # Keywords - `nu`: Standard kinematic viscosity. - `C_S`: Smagorinsky constant. -- `epsilon=0.01`: Parameter to prevent singularities +- `epsilon=0.001`: Parameter to prevent singularities """ struct ViscosityMorrisSGS{ELTYPE} nu :: ELTYPE # Kinematic viscosity [e.g., 1e-6 m²/s] From 44009e75ccdeae427849945d1ad42d85c20ba645 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Thu, 30 Jul 2026 16:18:41 +0200 Subject: [PATCH 51/59] Fix merged PR integration regressions --- docs/src/preprocessing/preprocessing.md | 5 +++-- src/callbacks/density_reinit.jl | 3 ++- test/schemes/boundary/dummy_particles/dummy_particles.jl | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/src/preprocessing/preprocessing.md b/docs/src/preprocessing/preprocessing.md index 47d4b736ec..beb56b3211 100644 --- a/docs/src/preprocessing/preprocessing.md +++ b/docs/src/preprocessing/preprocessing.md @@ -274,8 +274,8 @@ when it is not already repeated. This is only a convenience for complete, ordere 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`](@ref), -and [`setdiff`](@ref), require closed geometries. Boundary packing with +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. @@ -318,6 +318,7 @@ Pages = [joinpath("preprocessing", "geometries", "triangle_mesh.jl")] ``` ```@docs +TrixiParticles.is_closed_geometry delete_faces ``` diff --git a/src/callbacks/density_reinit.jl b/src/callbacks/density_reinit.jl index 430f3b5644..14f850dec9 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. diff --git a/test/schemes/boundary/dummy_particles/dummy_particles.jl b/test/schemes/boundary/dummy_particles/dummy_particles.jl index 8317bd144a..3c046dfe69 100644 --- a/test/schemes/boundary/dummy_particles/dummy_particles.jl +++ b/test/schemes/boundary/dummy_particles/dummy_particles.jl @@ -458,8 +458,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. From 7e1da1976484989dde994f577329feb0d340861f Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Mon, 3 Aug 2026 17:03:19 +0200 Subject: [PATCH 52/59] format --- .../point_in_poly/winding_number_jacobson.jl | 2 +- src/schemes/boundary/open_boundary/boundary_zones.jl | 5 +++-- test/general/semidiscretization.jl | 4 ++-- test/preprocessing/geometries/geometries.jl | 2 +- .../boundary/open_boundary/characteristic_variables.jl | 4 ++-- test/setups/sphere_shape.jl | 8 ++++---- 6 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/preprocessing/point_in_poly/winding_number_jacobson.jl b/src/preprocessing/point_in_poly/winding_number_jacobson.jl index 0f7385ac41..14a0d62cae 100644 --- a/src/preprocessing/point_in_poly/winding_number_jacobson.jl +++ b/src/preprocessing/point_in_poly/winding_number_jacobson.jl @@ -71,7 +71,7 @@ struct WindingNumberJacobson{ELTYPE, W} winding :: W function WindingNumberJacobson(; geometry=nothing, winding_number_factor=sqrt(eps()), - hierarchical_winding=!isnothing(geometry)) + 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 diff --git a/src/schemes/boundary/open_boundary/boundary_zones.jl b/src/schemes/boundary/open_boundary/boundary_zones.jl index 9d29d23483..175eee289b 100644 --- a/src/schemes/boundary/open_boundary/boundary_zones.jl +++ b/src/schemes/boundary/open_boundary/boundary_zones.jl @@ -385,8 +385,9 @@ function set_up_boundary_zone(boundary_face, face_normal, density, particle_spac end # Validate boundary geometry before sampling particles. - unit_spanning_set, _ = calculate_spanning_vectors(boundary_face, - one(eltype(face_normal))) + 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) diff --git a/test/general/semidiscretization.jl b/test/general/semidiscretization.jl index aef14fed49..d8cd080a1a 100644 --- a/test/general/semidiscretization.jl +++ b/test/general/semidiscretization.jl @@ -144,8 +144,8 @@ @testset verbose=true "Fluid Surface Tension Consistency" begin struct FluidSurfaceMock <: TrixiParticles.AbstractFluidSystem{2} - surface_tension - surface_normal_method + surface_tension::Any + surface_normal_method::Any end system_with_surface = FluidSurfaceMock(SurfaceTensionMorris(), diff --git a/test/preprocessing/geometries/geometries.jl b/test/preprocessing/geometries/geometries.jl index f64d415dd7..e01099dbe0 100644 --- a/test/preprocessing/geometries/geometries.jl +++ b/test/preprocessing/geometries/geometries.jl @@ -121,7 +121,7 @@ @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]) + edge_only.edge_normals[1]) A = SVector(0.0, 0.0, 0.0) B = SVector(1.0, 0.0, 0.0) diff --git a/test/schemes/boundary/open_boundary/characteristic_variables.jl b/test/schemes/boundary/open_boundary/characteristic_variables.jl index 1282917f0c..7e21b0efc4 100644 --- a/test/schemes/boundary/open_boundary/characteristic_variables.jl +++ b/test/schemes/boundary/open_boundary/characteristic_variables.jl @@ -78,10 +78,10 @@ @test any(!isapprox(characteristic, 0.0) for characteristic in boundary_system.cache.characteristics[:, - zone_1_particles]) + zone_1_particles]) @test all(isapprox(characteristic, 0.0) for characteristic in boundary_system.cache.characteristics[:, - zone_2_particles]) + zone_2_particles]) end # Face vertices of open boundary diff --git a/test/setups/sphere_shape.jl b/test/setups/sphere_shape.jl index d94abed713..42fc3e7b70 100644 --- a/test/setups/sphere_shape.jl +++ b/test/setups/sphere_shape.jl @@ -81,11 +81,11 @@ @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)) + 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)) + 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)) From e6dc5a10c9231058202515ad10c449b55b62a055 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Sun, 9 Aug 2026 00:59:12 +0200 Subject: [PATCH 53/59] Add C-CSF free-surface core --- NEWS.md | 18 +- docs/src/refs.bib | 11 + docs/src/systems/fluid.md | 30 +- src/TrixiParticles.jl | 5 +- src/io/io.jl | 5 + .../fluid/entropically_damped_sph/rhs.jl | 13 + .../fluid/entropically_damped_sph/system.jl | 3 +- src/schemes/fluid/surface_normal_sph.jl | 265 +++++++++++++++++- src/schemes/fluid/surface_tension.jl | 34 ++- .../fluid/weakly_compressible_sph/rhs.jl | 5 + .../fluid/weakly_compressible_sph/system.jl | 3 +- test/schemes/fluid/surface_normal_sph.jl | 88 ++++++ test/schemes/fluid/surface_tension.jl | 97 +++++++ 13 files changed, 541 insertions(+), 36 deletions(-) diff --git a/NEWS.md b/NEWS.md index 8abe050384..fb6e447ad2 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,11 +4,19 @@ 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.3 - -### Features - -- Added the computation of boundary normals for `RectangularTank`s and `SphereShape`s. +## Version 0.5.3 + +### API Changes + +- Corrected `SurfaceTensionMorris` to apply its local CSF acceleration once per particle and + retain the required one-phase surface delta. Previous coefficients compensated implicitly + for a dimensionally incomplete force repeated once per fluid neighbor and must be recalibrated. + +### Features + +- Added `CorrectedCSFSurfaceNormal`, an explicit free-surface implementation of the C-CSF + interface geometry from Vergnaud et al. (2022) for `SurfaceTensionMorris`. +- Added the computation of boundary normals for `RectangularTank`s and `SphereShape`s. - Added `flush` keyword argument to `InfoCallback` to flush `stdout` after each output, useful for monitoring progress in real-time on clusters or batch systems (#1246). - Added the number of split integration time steps to the `InfoCallback` output diff --git a/docs/src/refs.bib b/docs/src/refs.bib index c479e59210..11b15055e0 100644 --- a/docs/src/refs.bib +++ b/docs/src/refs.bib @@ -847,6 +847,17 @@ @Article{Valizadeh2015 publisher = {Elsevier BV}, } +@Article{Vergnaud2022, + author = {Vergnaud, A. and Oger, G. and Le Touz{\'e}, D. and DeLeffe, M. and Chiron, L.}, + title = {{C-CSF}: Accurate, robust and efficient surface tension and contact angle models for single-phase flows using {SPH}}, + journal = {Computer Methods in Applied Mechanics and Engineering}, + year = {2022}, + volume = {389}, + pages = {114292}, + doi = {10.1016/j.cma.2021.114292}, + publisher = {Elsevier BV}, +} + @Article{Wang2024, author = {Zhentong Wang and Bo Zhang and Oskar J. Haidn and Xiangyu Hu}, title = {A fourth-order kernel for improving numerical accuracy and stability in Eulerian SPH for fluids and total Lagrangian SPH for solids}, diff --git a/docs/src/systems/fluid.md b/docs/src/systems/fluid.md index 59f93c0766..11275ea493 100644 --- a/docs/src/systems/fluid.md +++ b/docs/src/systems/fluid.md @@ -367,17 +367,33 @@ The method described by [Morris](@cite Morris2000) estimates curvature by combin The computed curvature is then used to determine forces acting perpendicular to the interface. While this method provides accurate surface tension forces, it does not explicitly conserve momentum. -In the Morris model, surface tension is computed based on local interface curvature ``\kappa`` and the unit surface normal ``\hat{\bm{n}}``. -By estimating ``\hat{\bm{n}}`` and ``\kappa`` at each particle near the interface, the -surface tension force for particle ``a`` can be written as +In the Morris model, surface tension is computed from local interface curvature ``\kappa``, the +unit surface normal ``\hat{\bm{n}}``, and the surface delta ``\delta_s``. The acceleration is a +particle-local source evaluated once per right-hand side evaluation: ```math -\bm{F}_{a}^{\sigma} -= - m_a \sigma \frac{\kappa_a}{\rho_a}\hat{\bm{n}}_a. +\frac{\mathrm d\bm v_a}{\mathrm dt}\bigg|_\sigma += -\frac{\sigma}{\rho_a}\kappa_a\delta_{s,a}\hat{\bm n}_a. +``` + +The factors have dimensions ``[\sigma]=kg/s^2``, ``[\kappa]=1/m``, +``[\delta_s]=1/m``, and ``[\rho]=kg/m^3``, giving acceleration in ``m/s^2``. This formulation +does not explicitly conserve momentum, and accurately estimating curvature still requires +adequate resolution. + +[`CorrectedCSFSurfaceNormal`](@ref) selects the corrected continuous-surface-force (C-CSF) +interface geometry of [Vergnaud et al.](@cite Vergnaud2022) for [`SurfaceTensionMorris`](@ref). It +computes the outward normal from the renormalized gradient of the minimum eigenvalue of the +first-order kernel moment. Curvature uses a renormalized divergence and the published thin-jet +angular filter; the surface delta uses the published Shepard correction. + +```julia +surface_tension = SurfaceTensionMorris(surface_tension_coefficient=0.072) +surface_normal_method = CorrectedCSFSurfaceNormal() ``` -This formulation focuses directly on geometric properties of the interface, making it relatively straightforward to implement when a reliable interface detection -(e.g., a color function) is available. However, accurately estimating ``\kappa`` and ``n`` may require fine resolutions. +This explicit opt-in supports one fluid system and free-surface geometry. Boundary-integral and +contact-angle terms are not included. --- diff --git a/src/TrixiParticles.jl b/src/TrixiParticles.jl index 39d1a88e71..d66c22d898 100644 --- a/src/TrixiParticles.jl +++ b/src/TrixiParticles.jl @@ -16,7 +16,8 @@ using ForwardDiff: ForwardDiff using GPUArraysCore: AbstractGPUArray using JSON: JSON using KernelAbstractions: KernelAbstractions, @kernel, @index -using LinearAlgebra: norm, normalize, cross, dot, I, tr, inv, pinv, det +using LinearAlgebra: norm, normalize, cross, dot, I, tr, inv, pinv, det, eigvals, + Symmetric using Polyester: Polyester, @batch using Printf: @printf, @sprintf using ReadVTK: ReadVTK @@ -114,7 +115,7 @@ export interpolate_line, interpolate_points, interpolate_plane_3d, interpolate_p interpolate_plane_2d_vtk export SurfaceTensionAkinci, CohesionForceAkinci, SurfaceTensionMorris, SurfaceTensionMomentumMorris -export ColorfieldSurfaceNormal +export ColorfieldSurfaceNormal, CorrectedCSFSurfaceNormal export SymplecticPositionVerlet export coordinates_eltype diff --git a/src/io/io.jl b/src/io/io.jl index 692dfd8528..a1dd151e2d 100644 --- a/src/io/io.jl +++ b/src/io/io.jl @@ -322,6 +322,11 @@ function add_system_data!(system_data, surface_normal_method::ColorfieldSurfaceN system_data["surface_normal_method"]["ideal_density_threshold"] = surface_normal_method.ideal_density_threshold end +function add_system_data!(system_data, surface_normal_method::CorrectedCSFSurfaceNormal) + system_data["surface_normal_method"] = Dict{String, Any}() + system_data["surface_normal_method"]["model"] = type2string(surface_normal_method) +end + function add_system_data!(system_data, boundary_zone::BoundaryZone, indice) zone_name = "boundary_zone_" * string(indice) system_data[zone_name] = Dict{String, Any}() diff --git a/src/schemes/fluid/entropically_damped_sph/rhs.jl b/src/schemes/fluid/entropically_damped_sph/rhs.jl index 2ea1e4dc4d..380d3fed68 100644 --- a/src/schemes/fluid/entropically_damped_sph/rhs.jl +++ b/src/schemes/fluid/entropically_damped_sph/rhs.jl @@ -20,6 +20,19 @@ function interact!(dv, v_particle_system, u_particle_system, h = initial_smoothing_length(particle_system) almostzero = sqrt(eps(h^2)) + if particle_system === neighbor_system + @threaded semi for particle in each_integrated_particle(particle_system) + rho_a = @inbounds current_density(v_particle_system, particle_system, + particle) + v_a = @inbounds current_velocity(v_particle_system, particle_system, particle) + acceleration = surface_tension_acceleration(surface_tension_a, particle_system, + particle, rho_a, v_a) + for i in 1:ndims(particle_system) + @inbounds dv[i, particle] += acceleration[i] + end + end + end + # Loop over all pairs of particles and neighbors within the kernel cutoff foreach_point_neighbor(particle_system, neighbor_system, system_coords, neighbor_coords, semi; diff --git a/src/schemes/fluid/entropically_damped_sph/system.jl b/src/schemes/fluid/entropically_damped_sph/system.jl index 3720b57ba1..aee6e1ee16 100644 --- a/src/schemes/fluid/entropically_damped_sph/system.jl +++ b/src/schemes/fluid/entropically_damped_sph/system.jl @@ -122,9 +122,10 @@ function EntropicallyDampedSPHSystem(initial_condition; smoothing_kernel, smooth if surface_tension !== nothing && surface_normal_method === nothing surface_normal_method = ColorfieldSurfaceNormal() end + validate_corrected_csf(surface_normal_method, surface_tension) 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 or a surface tension model")) end if correction isa ShepardKernelCorrection && diff --git a/src/schemes/fluid/surface_normal_sph.jl b/src/schemes/fluid/surface_normal_sph.jl index 4db94ea763..ef331cdcdc 100644 --- a/src/schemes/fluid/surface_normal_sph.jl +++ b/src/schemes/fluid/surface_normal_sph.jl @@ -15,6 +15,28 @@ struct ColorfieldSurfaceNormal{ELTYPE} ideal_density_threshold::ELTYPE end +@doc raw""" + CorrectedCSFSurfaceNormal() + +Interface geometry for the corrected continuous-surface-force (C-CSF) method of Vergnaud +et al. (2022). The outward unit normal is computed from the renormalized gradient of the +smallest eigenvalue of the first-order kernel moment. Curvature uses the corresponding +renormalized divergence with the published thin-jet angular filter, and the surface delta +uses the published Shepard correction. + +This explicit opt-in implements the single-fluid free-surface core (equations 15--25) with +[`SurfaceTensionMorris`](@ref). Boundary-integral and contact-angle terms are not included. +""" +struct CorrectedCSFSurfaceNormal end + +@inline validate_corrected_csf(surface_normal_method, surface_tension) = nothing + +function validate_corrected_csf(::CorrectedCSFSurfaceNormal, surface_tension) + surface_tension isa SurfaceTensionMorris || + throw(ArgumentError("`CorrectedCSFSurfaceNormal` requires `SurfaceTensionMorris`")) + return nothing +end + function ColorfieldSurfaceNormal(; boundary_contact_threshold=0.1, interface_threshold=0.01, ideal_density_threshold=0.0) return ColorfieldSurfaceNormal(boundary_contact_threshold, interface_threshold, @@ -33,6 +55,20 @@ function create_cache_surface_normal(::ColorfieldSurfaceNormal, ELTYPE, NDIMS, n return (; surface_normal, neighbor_count, colorfield, correction_factor) end +function create_cache_surface_normal(::CorrectedCSFSurfaceNormal, ELTYPE, NDIMS, nparticles) + surface_normal = Array{ELTYPE, 2}(undef, NDIMS, nparticles) + neighbor_count = Array{ELTYPE, 1}(undef, nparticles) + correction_factor = Array{ELTYPE, 1}(undef, nparticles) + ccsf_correction_matrix = Array{ELTYPE, 3}(undef, NDIMS, NDIMS, nparticles) + ccsf_minimum_eigenvalue = Array{ELTYPE, 1}(undef, nparticles) + ccsf_lambda_gradient = Array{ELTYPE, 2}(undef, NDIMS, nparticles) + ccsf_color_gradient = Array{ELTYPE, 2}(undef, NDIMS, nparticles) + ccsf_shepard_sum = Array{ELTYPE, 1}(undef, nparticles) + return (; surface_normal, neighbor_count, correction_factor, + ccsf_correction_matrix, ccsf_minimum_eigenvalue, + ccsf_lambda_gradient, ccsf_color_gradient, ccsf_shepard_sum) +end + @inline function surface_normal(particle_system::AbstractFluidSystem, particle) (; cache) = particle_system return extract_svector(cache.surface_normal, particle_system, particle) @@ -147,8 +183,7 @@ end # See Morris 2000 "Simulating surface tension with smoothed particle hydrodynamics" function remove_invalid_normals!(system::AbstractFluidSystem, - surface_tension::Union{SurfaceTensionMorris, - SurfaceTensionMomentumMorris}, + surface_tension::SurfaceTensionMomentumMorris, surface_normal_method::ColorfieldSurfaceNormal) (; cache, smoothing_kernel) = system (; ideal_density_threshold, interface_threshold) = surface_normal_method @@ -188,6 +223,50 @@ function remove_invalid_normals!(system::AbstractFluidSystem, return system end +function remove_invalid_normals!(system::AbstractFluidSystem, + ::SurfaceTensionMorris, + surface_normal_method::ColorfieldSurfaceNormal) + (; cache, smoothing_kernel) = system + (; ideal_density_threshold, interface_threshold) = surface_normal_method + support_radius = compact_support(smoothing_kernel, initial_smoothing_length(system)) + normal_condition2 = (interface_threshold / support_radius)^2 + + for particle in each_integrated_particle(system) + cache.delta_s[particle] = zero(eltype(system)) + cache.interface_activity[particle] = zero(eltype(system)) + + if ideal_density_threshold > 0 && + ideal_density_threshold * + ideal_neighbor_count(Val(ndims(system)), cache.reference_particle_spacing, + support_radius) < cache.neighbor_count[particle] + cache.surface_normal[1:ndims(system), particle] .= 0 + continue + end + + particle_surface_normal = surface_normal(system, particle) + norm2 = dot(particle_surface_normal, particle_surface_normal) + if norm2 > normal_condition2 + normal_norm = sqrt(norm2) + cache.delta_s[particle] = 2 * normal_norm + cache.interface_activity[particle] = one(eltype(system)) + cache.surface_normal[1:ndims(system), + particle] = particle_surface_normal / normal_norm + else + cache.surface_normal[1:ndims(system), particle] .= 0 + end + end + + return system +end + +@inline reset_surface_interface_data!(system, surface_tension) = system + +@inline function reset_surface_interface_data!(system, ::SurfaceTensionMorris) + set_zero!(system.cache.interface_activity) + set_zero!(system.cache.delta_s) + return system +end + function compute_surface_normal!(system, surface_normal_method, v, u, v_ode, u_ode, semi, t) return system end @@ -200,6 +279,7 @@ function compute_surface_normal!(system::AbstractFluidSystem, # Reset surface normal set_zero!(cache.surface_normal) set_zero!(cache.neighbor_count) + reset_surface_interface_data!(system, surface_tension) # TODO: if color values are set only different systems need to be called @trixi_timeit timer() "compute surface normal" foreach_system(semi) do neighbor_system @@ -215,6 +295,128 @@ function compute_surface_normal!(system::AbstractFluidSystem, return system end +@inline function ccsf_store_matrix!(matrix_cache, system, particle, matrix) + for column in 1:ndims(system), row in 1:ndims(system) + @inbounds matrix_cache[row, column, particle] = matrix[row, column] + end + return matrix_cache +end + +@inline function ccsf_minimum_eigenvalue(matrix) + # `eigmin` falls back to an allocating dense eigensolver for static matrices. + symmetric_matrix = (matrix + transpose(matrix)) / 2 + return minimum(eigvals(Symmetric(symmetric_matrix))) +end + +@inline function ccsf_corrected_divergence(normal_difference, renormalization, + kernel_direction) + return dot(renormalization * normal_difference, kernel_direction) +end + +@inline function ccsf_lambda_difference(lambda_i, lambda_j) + return lambda_i >= oftype(lambda_i, 0.7) ? lambda_j - lambda_i : lambda_j +end + +function compute_surface_normal!(system::AbstractFluidSystem, + ::CorrectedCSFSurfaceNormal, + v, u, v_ode, u_ode, semi, t) + system.surface_tension isa SurfaceTensionMorris || + throw(ArgumentError("`CorrectedCSFSurfaceNormal` requires `SurfaceTensionMorris`")) + cache = system.cache + matrix_cache = cache.ccsf_correction_matrix + lambda = cache.ccsf_minimum_eigenvalue + lambda_gradient = cache.ccsf_lambda_gradient + color_gradient = cache.ccsf_color_gradient + shepard_sum = cache.ccsf_shepard_sum + coordinates = current_coordinates(u, system) + + set_zero!(cache.surface_normal) + set_zero!(cache.neighbor_count) + set_zero!(matrix_cache) + set_zero!(lambda) + set_zero!(lambda_gradient) + set_zero!(color_gradient) + set_zero!(shepard_sum) + + @trixi_timeit timer() "compute C-CSF moments" begin + foreach_point_neighbor(system, system, coordinates, coordinates, semi; + points=each_integrated_particle(system)) do particle, + neighbor, + pos_diff, + distance + m_b = hydrodynamic_mass(system, neighbor) + rho_b = current_density(v, system, neighbor) + volume_b = m_b / rho_b + grad_kernel = smoothing_kernel_grad(system, pos_diff, distance, particle) + kernel = smoothing_kernel(system, distance, particle) + + moment = -volume_b * grad_kernel * permutedims(pos_diff) + for column in 1:ndims(system), row in 1:ndims(system) + @inbounds matrix_cache[row, column, particle] += moment[row, column] + end + for dimension in 1:ndims(system) + @inbounds color_gradient[dimension, + particle] += volume_b * grad_kernel[dimension] + end + @inbounds shepard_sum[particle] += volume_b * kernel + @inbounds cache.neighbor_count[particle] += 1 + end + end + + @threaded semi for particle in each_integrated_particle(system) + inverse_renormalization = extract_smatrix(matrix_cache, system, particle) + @inbounds lambda[particle] = ccsf_minimum_eigenvalue(inverse_renormalization) + renormalization = abs(det(inverse_renormalization)) < 1.0f-9 ? + one(inverse_renormalization) : inv(inverse_renormalization) + ccsf_store_matrix!(matrix_cache, system, particle, renormalization) + end + + @trixi_timeit timer() "compute C-CSF normal" begin + foreach_point_neighbor(system, system, coordinates, coordinates, semi; + points=each_integrated_particle(system)) do particle, + neighbor, + pos_diff, + distance + rho_b = current_density(v, system, neighbor) + volume_b = hydrodynamic_mass(system, neighbor) / rho_b + grad_kernel = smoothing_kernel_grad(system, pos_diff, distance, particle) + renormalization = extract_smatrix(matrix_cache, system, particle) + lambda_a = @inbounds lambda[particle] + lambda_b = @inbounds lambda[neighbor] + coefficient = ccsf_lambda_difference(lambda_a, lambda_b) + contribution = coefficient * volume_b * renormalization * grad_kernel + for dimension in 1:ndims(system) + @inbounds lambda_gradient[dimension, particle] += contribution[dimension] + end + end + end + + set_zero!(cache.interface_activity) + set_zero!(cache.delta_s) + for particle in each_integrated_particle(system) + gradient = extract_svector(lambda_gradient, system, particle) + gradient_norm = norm(gradient) + lambda_i = @inbounds lambda[particle] + threshold = oftype(lambda_i, 0.1) * lambda_i / + smoothing_length(system, particle) + if gradient_norm > threshold + normal = -gradient / gradient_norm + for dimension in 1:ndims(system) + @inbounds cache.surface_normal[dimension, particle] = normal[dimension] + end + @inbounds cache.interface_activity[particle] = one(lambda_i) + end + + raw_gradient = extract_svector(color_gradient, system, particle) + shepard = @inbounds shepard_sum[particle] + correction = shepard > eps(shepard) ? + max(one(shepard), inv(2shepard)) : one(shepard) + @inbounds cache.delta_s[particle] = 2correction * norm(raw_gradient) + end + + return system +end + function calc_curvature!(system, neighbor_system, u_system, v, v_neighbor_system, u_neighbor_system, semi, surface_normal_method, neighbor_surface_normal_method) @@ -231,8 +433,6 @@ function calc_curvature!(system::AbstractFluidSystem, neighbor_system::AbstractF system_coords = current_coordinates(u_system, system) neighbor_system_coords = current_coordinates(u_neighbor_system, neighbor_system) - set_zero!(correction_factor) - foreach_point_neighbor(system, neighbor_system, system_coords, neighbor_system_coords, semi) do particle, neighbor, pos_diff, distance @@ -255,14 +455,51 @@ function calc_curvature!(system::AbstractFluidSystem, neighbor_system::AbstractF end end - # Eq. 23 - for particle in each_integrated_particle(system) - curvature[particle] /= (correction_factor[particle] + eps()) - end + return system +end + +function calc_curvature!(system::AbstractFluidSystem, + neighbor_system::AbstractFluidSystem, + u_system, v, v_neighbor_system, u_neighbor_system, semi, + ::CorrectedCSFSurfaceNormal, + ::CorrectedCSFSurfaceNormal) + system === neighbor_system || + throw(ArgumentError("`CorrectedCSFSurfaceNormal` currently supports one fluid system")) + cache = system.cache + coordinates = current_coordinates(u_system, system) + cosine_threshold = -inv(convert(eltype(system), ndims(system))) + + foreach_point_neighbor(system, system, coordinates, coordinates, semi; + points=each_integrated_particle(system)) do particle, neighbor, + pos_diff, distance + n_a = surface_normal(system, particle) + n_b = surface_normal(system, neighbor) + dot(n_a, n_a) > eps(eltype(n_a)) || return + dot(n_b, n_b) > eps(eltype(n_b)) || return + dot(n_a, n_b) >= cosine_threshold || return + rho_b = current_density(v, system, neighbor) + volume_b = hydrodynamic_mass(system, neighbor) / rho_b + grad_kernel = smoothing_kernel_grad(system, pos_diff, distance, particle) + renormalization = extract_smatrix(cache.ccsf_correction_matrix, system, particle) + @inbounds cache.curvature[particle] += volume_b * + ccsf_corrected_divergence(n_b - n_a, + renormalization, + grad_kernel) + end return system end +@inline function finalize_surface_curvature(curvature_numerator, denominator, + surface_normal_method) + return curvature_numerator / (denominator + eps()) +end + +@inline function finalize_surface_curvature(curvature_numerator, denominator, + ::CorrectedCSFSurfaceNormal) + return curvature_numerator +end + function compute_curvature!(system, surface_tension, v, u, v_ode, u_ode, semi, t) return system end @@ -271,17 +508,25 @@ function compute_curvature!(system::AbstractFluidSystem, surface_tension::SurfaceTensionMorris, v, u, v_ode, u_ode, semi, t) (; cache, surface_tension) = system + normal_method = surface_normal_method(system) - # Reset surface curvature + # Reset once so contributions from multiple fluid systems accumulate consistently. set_zero!(cache.curvature) + set_zero!(cache.correction_factor) @trixi_timeit timer() "compute surface curvature" foreach_system(semi) do neighbor_system u_neighbor_system = wrap_u(u_ode, neighbor_system, semi) v_neighbor_system = wrap_v(v_ode, neighbor_system, semi) calc_curvature!(system, neighbor_system, u, v, v_neighbor_system, - u_neighbor_system, semi, surface_normal_method(system), + u_neighbor_system, semi, normal_method, surface_normal_method(neighbor_system)) end + + for particle in each_integrated_particle(system) + denominator = cache.correction_factor[particle] + cache.curvature[particle] = finalize_surface_curvature(cache.curvature[particle], + denominator, normal_method) + end return system end diff --git a/src/schemes/fluid/surface_tension.jl b/src/schemes/fluid/surface_tension.jl index 5656e95e12..3e0f4b7a31 100644 --- a/src/schemes/fluid/surface_tension.jl +++ b/src/schemes/fluid/surface_tension.jl @@ -51,6 +51,10 @@ It calculates surface tension forces based on the curvature of the fluid interfa using particle normals and their divergence, making it suitable for simulating phenomena like droplet formation and capillary wave dynamics. +The one-phase color-gradient magnitude is retained as a surface delta. The local +continuum-surface-force acceleration is evaluated once per particle as +``-sigma * kappa * delta_s * n_hat / rho``. + See [`surface_tension`](@ref) for more details. @@ -72,7 +76,9 @@ end function create_cache_surface_tension(::SurfaceTensionMorris, ELTYPE, NDIMS, nparticles) curvature = Array{ELTYPE, 1}(undef, nparticles) - return (; curvature) + delta_s = Array{ELTYPE, 1}(undef, nparticles) + interface_activity = Array{ELTYPE, 1}(undef, nparticles) + return (; curvature, delta_s, interface_activity) end @doc raw""" @@ -229,18 +235,26 @@ end particle, neighbor, pos_diff, distance, rho_a, rho_b, grad_kernel, surface_tension_correction) - (; surface_tension_coefficient) = surface_tension_a - - # No surface tension with oneself. See `src/general/smoothing_kernels.jl` for more details. - distance^2 < eps(initial_smoothing_length(particle_system)^2) && return dv_particle + # Morris CSF is a particle-local continuum force. It is added once outside the + # neighbor loop by `surface_tension_acceleration`. + return dv_particle +end - n_a = surface_normal(particle_system, particle) - curvature_a = curvature(particle_system, particle) +@inline function surface_tension_acceleration(surface_tension, particle_system, particle, + rho_a, vector_template) + return zero(vector_template) +end - dv_particle[] -= surface_tension_correction * surface_tension_coefficient / rho_a * - curvature_a * n_a +@inline function surface_tension_acceleration(surface_tension::SurfaceTensionMorris, + particle_system, particle, rho_a, + vector_template) + delta_s = @inbounds particle_system.cache.delta_s[particle] + iszero(delta_s) && return zero(vector_template) - return dv_particle + normal = surface_normal(particle_system, particle) + curvature_a = curvature(particle_system, particle) + return -surface_tension.surface_tension_coefficient / rho_a * curvature_a * delta_s * + normal end function compute_stress_tensors!(system, surface_tension, v, u, v_ode, u_ode, semi, t) diff --git a/src/schemes/fluid/weakly_compressible_sph/rhs.jl b/src/schemes/fluid/weakly_compressible_sph/rhs.jl index 836063538b..8217711dec 100644 --- a/src/schemes/fluid/weakly_compressible_sph/rhs.jl +++ b/src/schemes/fluid/weakly_compressible_sph/rhs.jl @@ -40,6 +40,11 @@ function interact!(dv, v_particle_system, u_particle_system, # inside the closure in the `foreach_neighbor` loop. dv_particle = Ref(zero(v_a)) drho_particle = Ref(zero(rho_a)) + if particle_system === neighbor_system + dv_particle[] += surface_tension_acceleration(surface_tension_a, + particle_system, particle, + rho_a, v_a) + end # Loop over all neighbors within the kernel cutoff @inbounds foreach_neighbor(system_coords, neighbor_system_coords, diff --git a/src/schemes/fluid/weakly_compressible_sph/system.jl b/src/schemes/fluid/weakly_compressible_sph/system.jl index eea0607d7d..bc3a09f81b 100644 --- a/src/schemes/fluid/weakly_compressible_sph/system.jl +++ b/src/schemes/fluid/weakly_compressible_sph/system.jl @@ -133,9 +133,10 @@ function WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel, if surface_tension !== nothing && surface_normal_method === nothing surface_normal_method = ColorfieldSurfaceNormal() end + validate_corrected_csf(surface_normal_method, surface_tension) 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 or a surface tension model")) end pressure_acceleration = choose_pressure_acceleration_formulation(pressure_acceleration, diff --git a/test/schemes/fluid/surface_normal_sph.jl b/test/schemes/fluid/surface_normal_sph.jl index 5eb8a81704..727a71e506 100644 --- a/test/schemes/fluid/surface_normal_sph.jl +++ b/test/schemes/fluid/surface_normal_sph.jl @@ -143,6 +143,94 @@ function compute_curvature!(system, semi, ode) v, u, v0_ode, u0_ode, semi, 0.0) end +@testset "Corrected C-CSF interface geometry" begin + particle_spacing = 0.05 + radius = 0.5 + reference_density = 1000.0 + smoothing_kernel = WendlandC2Kernel{2}() + smoothing_length = 1.4particle_spacing + fluid = SphereShape(particle_spacing, radius, (0.0, 0.0), reference_density; + sphere_type=RoundSphere()) + state_equation = StateEquationCole(; sound_speed=10.0, reference_density, + exponent=7) + surface_tension = SurfaceTensionMorris(; surface_tension_coefficient=1.0) + system = WeaklyCompressibleSPHSystem(fluid; smoothing_kernel, smoothing_length, + density_calculator=ContinuityDensity(), + state_equation, surface_tension, + surface_normal_method=CorrectedCSFSurfaceNormal(), + reference_particle_spacing=particle_spacing) + semi = Semidiscretization(system) + ode = semidiscretize(semi, (0.0, 0.01)) + TrixiParticles.update_systems_and_nhs(ode.u0.x..., semi, 0.0) + + cache = system.cache + active = findall(>(0), cache.interface_activity) + @test !isempty(active) + @test all(isfinite, cache.ccsf_minimum_eigenvalue) + @test 0.4 < minimum(cache.ccsf_minimum_eigenvalue) < 0.6 + @test maximum(cache.ccsf_minimum_eigenvalue) > 0.99 + @test all(isfinite, cache.surface_normal) + @test all(isfinite, cache.curvature) + @test all(>=(0), cache.delta_s) + @test all(active) do particle + dot(TrixiParticles.surface_normal(system, particle), + fluid.coordinates[:, particle]) > 0 + end + + weighted_curvature = sum(cache.curvature[active] .* cache.delta_s[active]) / + sum(cache.delta_s[active]) + @test isapprox(weighted_curvature, inv(radius); rtol=0.15) + + system_data = Dict{String, Any}() + TrixiParticles.add_system_data!(system_data, system.surface_normal_method) + @test system_data["surface_normal_method"]["model"] == + "CorrectedCSFSurfaceNormal" + + @test_throws ArgumentError WeaklyCompressibleSPHSystem(fluid; smoothing_kernel, + smoothing_length, + density_calculator=ContinuityDensity(), + state_equation, + surface_tension=SurfaceTensionMomentumMorris(), + surface_normal_method=CorrectedCSFSurfaceNormal(), + reference_particle_spacing=particle_spacing) +end + +@testset "Corrected C-CSF 3D curvature" begin + particle_spacing = 0.05 + radius = 0.5 + reference_density = 1000.0 + smoothing_kernel = WendlandC2Kernel{3}() + smoothing_length = 1.4particle_spacing + fluid = SphereShape(particle_spacing, radius, (0.0, 0.0, 0.0), reference_density; + sphere_type=RoundSphere()) + system = WeaklyCompressibleSPHSystem(fluid; smoothing_kernel, smoothing_length, + density_calculator=ContinuityDensity(), + state_equation=StateEquationCole(; + sound_speed=10.0, + reference_density, + exponent=7), + surface_tension=SurfaceTensionMorris(; + surface_tension_coefficient=1.0), + surface_normal_method=CorrectedCSFSurfaceNormal(), + reference_particle_spacing=particle_spacing) + semi = Semidiscretization(system) + ode = semidiscretize(semi, (0.0, 0.01)) + TrixiParticles.update_systems_and_nhs(ode.u0.x..., semi, 0.0) + + cache = system.cache + active = findall(>(0), cache.interface_activity) + @test !isempty(active) + @test all(active) do particle + dot(TrixiParticles.surface_normal(system, particle), + fluid.coordinates[:, particle]) > 0 + end + @test minimum(cache.curvature[active]) > 0 + + weighted_curvature = sum(cache.curvature[active] .* cache.delta_s[active]) / + sum(cache.delta_s[active]) + @test isapprox(weighted_curvature, 2 / radius; rtol=0.15) +end + @testset verbose=true "Rigid Dummy Boundary Matches Wall Boundary" begin NDIMS = 2 particle_spacing = 0.2 diff --git a/test/schemes/fluid/surface_tension.jl b/test/schemes/fluid/surface_tension.jl index 7fe8abbd97..33eb52ff03 100644 --- a/test/schemes/fluid/surface_tension.jl +++ b/test/schemes/fluid/surface_tension.jl @@ -90,6 +90,103 @@ @test isapprox(zero[2], 0.0, atol=6e-15) end + @testset "Morris CSF local force" begin + function build_morris_system(solver, particle_count) + coordinates = zeros(2, particle_count) + coordinates[1, :] .= range(0.0; step=0.25, length=particle_count) + initial_condition = InitialCondition(; coordinates, + velocity=zeros(2, particle_count), + mass=ones(particle_count), + density=ones(particle_count), + particle_spacing=0.25) + smoothing_kernel = WendlandC2Kernel{2}() + surface_tension = SurfaceTensionMorris(; surface_tension_coefficient=0.7) + normal_method = ColorfieldSurfaceNormal(; interface_threshold=0.1) + if solver == :wcsph + return WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel, + smoothing_length=0.5, + density_calculator=ContinuityDensity(), + state_equation=StateEquationCole(; + sound_speed=10.0, + reference_density=1.0, + exponent=1), + surface_tension, + surface_normal_method=normal_method, + reference_particle_spacing=0.25) + end + return EntropicallyDampedSPHSystem(initial_condition; smoothing_kernel, + smoothing_length=0.5, sound_speed=10.0, + density_calculator=ContinuityDensity(), + surface_tension, + surface_normal_method=normal_method, + reference_particle_spacing=0.25) + end + + function morris_rhs_effect(system) + semi = Semidiscretization(system) + ode = semidiscretize(semi, (0.0, 0.01)) + v_ode, u_ode = ode.u0.x + TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, 0.0) + system.cache.surface_normal[1, :] .= 1.0 + system.cache.surface_normal[2, :] .= 0.0 + system.cache.curvature .= 3.0 + system.cache.delta_s .= 2.0 + system.cache.interface_activity .= 1.0 + + return GC.@preserve v_ode u_ode begin + v = TrixiParticles.wrap_v(v_ode, system, semi) + u = TrixiParticles.wrap_u(u_ode, system, semi) + rho_a = TrixiParticles.current_density(v, system, 1) + expected = TrixiParticles.surface_tension_acceleration(system.surface_tension, + system, 1, rho_a, + SVector(0.0, 0.0)) + with_surface_tension = zeros(eltype(v), size(v)) + TrixiParticles.interact!(with_surface_tension, v, u, v, u, + system, system, semi) + system.cache.delta_s .= 0 + without_surface_tension = zeros(eltype(v), size(v)) + TrixiParticles.interact!(without_surface_tension, v, u, v, u, + system, system, semi) + return (with_surface_tension - without_surface_tension)[1:2, :], + expected + end + end + + effects = [] + for solver in (:wcsph, :edac), particle_count in (2, 4) + effect, + expected = morris_rhs_effect(build_morris_system(solver, particle_count)) + @test all(particle -> effect[:, particle] ≈ expected, axes(effect, 2)) + push!(effects, effect[:, 1]) + end + @test all(effect -> effect ≈ first(effects), effects) + + system = build_morris_system(:wcsph, 2) + system.cache.surface_normal .= [2.0 1.0; 0.0 1.0] + TrixiParticles.remove_invalid_normals!(system, system.surface_tension, + system.surface_normal_method) + @test system.cache.delta_s ≈ [4.0, 2sqrt(2)] + @test system.cache.interface_activity == [1.0, 1.0] + @test system.cache.surface_normal[:, 1] ≈ [1.0, 0.0] + @test system.cache.surface_normal[:, 2] ≈ [1 / sqrt(2), 1 / sqrt(2)] + + system.cache.surface_normal[1, :] .= 1.0 + system.cache.surface_normal[2, :] .= 0.0 + system.cache.curvature .= 3.0 + system.cache.delta_s .= 2.0 + acceleration = TrixiParticles.surface_tension_acceleration(system.surface_tension, + system, 1, 1.0, + SVector(0.0, 0.0)) + @test acceleration ≈ SVector(-4.2, 0.0) + system.cache.curvature[1] /= 2 + system.cache.delta_s[1] /= 2 + scaled_acceleration = TrixiParticles.surface_tension_acceleration(system.surface_tension, + system, 1, 1.0, + SVector(0.0, + 0.0)) + @test scaled_acceleration ≈ acceleration / 4 + end + @testset "compute_stress_tensors! (MomentumMorris)" begin # 1. Define Minimal Initial Condition with 2 Particles in 2D coords = [0.0 1.0; From de8f84f97ea56fe9ff3196239acd409413bd60a1 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Sun, 9 Aug 2026 01:54:45 +0200 Subject: [PATCH 54/59] Add smooth Morris interface activity --- NEWS.md | 6 + docs/src/systems/fluid.md | 40 ++++ src/io/io.jl | 3 + src/schemes/fluid/surface_normal_sph.jl | 203 ++++++++++++++++++--- src/schemes/fluid/surface_tension.jl | 6 +- src/schemes/structure/rigid_body/system.jl | 5 +- test/schemes/fluid/surface_normal_sph.jl | 24 ++- test/schemes/fluid/surface_tension.jl | 124 +++++++++++++ 8 files changed, 373 insertions(+), 38 deletions(-) diff --git a/NEWS.md b/NEWS.md index fb6e447ad2..4a2cb0d941 100644 --- a/NEWS.md +++ b/NEWS.md @@ -11,9 +11,15 @@ used in the Julia ecosystem. Notable changes will be documented in this file for - Corrected `SurfaceTensionMorris` to apply its local CSF acceleration once per particle and retain the required one-phase surface delta. Previous coefficients compensated implicitly for a dimensionally incomplete force repeated once per fluid neighbor and must be recalibrated. +- For `SurfaceTensionMorris` with `ColorfieldSurfaceNormal`, `ideal_density_threshold` now denotes + a fraction of the continuous complete-support kernel moment instead of an integer neighbor-count + fraction. The default zero still disables interior filtering. ### Features +- Added C1 interface activation for Morris CSF with `ColorfieldSurfaceNormal`. Color-gradient and + continuous support-moment indicators taper the physical surface delta without another neighbor + pass. - Added `CorrectedCSFSurfaceNormal`, an explicit free-surface implementation of the C-CSF interface geometry from Vergnaud et al. (2022) for `SurfaceTensionMorris`. - Added the computation of boundary normals for `RectangularTank`s and `SphereShape`s. diff --git a/docs/src/systems/fluid.md b/docs/src/systems/fluid.md index 11275ea493..49d4349cf6 100644 --- a/docs/src/systems/fluid.md +++ b/docs/src/systems/fluid.md @@ -381,6 +381,46 @@ The factors have dimensions ``[\sigma]=kg/s^2``, ``[\kappa]=1/m``, does not explicitly conserve momentum, and accurately estimating curvature still requires adequate resolution. +#### Smooth colorfield interface activity + +With `ColorfieldSurfaceNormal`, Morris CSF uses a C1 interface activity ``\lambda_a``. Let +``h_c`` be the compact-support radius, ``\gamma_a=h_c\Vert\bm g_a\Vert``, ``\epsilon_n`` be +`interface_threshold`, and ``\alpha`` be `interface_taper_start` (default `0.8`). With + +```math +S(x)=\begin{cases} +0,&x\le0,\\ +3x^2-2x^3,&0 0`, the +support activity is ``\lambda_{q,a}=1-S((q_a-\tau)/\Delta q)``, where ``\Delta q`` is +`support_taper_width` (default `0.025`). The final activity and one-phase surface delta are + +```math +\lambda_a=\lambda_{g,a}\lambda_{q,a},\qquad +\delta_{s,a}=2\Vert\bm g_a\Vert\lambda_a. +``` + +Setting `ideal_density_threshold=0` disables support filtering. For Morris CSF with +`ColorfieldSurfaceNormal`, this keyword represents a continuous fraction of complete kernel +support instead of an integer neighbor-count fraction. Dummy boundary particles complete +``q_a`` near walls without carrying capillary stress. These controls do not alter the separate +C-CSF geometry described below. + [`CorrectedCSFSurfaceNormal`](@ref) selects the corrected continuous-surface-force (C-CSF) interface geometry of [Vergnaud et al.](@cite Vergnaud2022) for [`SurfaceTensionMorris`](@ref). It computes the outward normal from the renormalized gradient of the minimum eigenvalue of the diff --git a/src/io/io.jl b/src/io/io.jl index a1dd151e2d..188f5f617a 100644 --- a/src/io/io.jl +++ b/src/io/io.jl @@ -319,7 +319,10 @@ function add_system_data!(system_data, surface_normal_method::ColorfieldSurfaceN system_data["surface_normal_method"] = Dict{String, Any}() system_data["surface_normal_method"]["model"] = type2string(surface_normal_method) system_data["surface_normal_method"]["boundary_contact_threshold"] = surface_normal_method.boundary_contact_threshold + system_data["surface_normal_method"]["interface_threshold"] = surface_normal_method.interface_threshold system_data["surface_normal_method"]["ideal_density_threshold"] = surface_normal_method.ideal_density_threshold + system_data["surface_normal_method"]["interface_taper_start"] = surface_normal_method.interface_taper_start + system_data["surface_normal_method"]["support_taper_width"] = surface_normal_method.support_taper_width end function add_system_data!(system_data, surface_normal_method::CorrectedCSFSurfaceNormal) diff --git a/src/schemes/fluid/surface_normal_sph.jl b/src/schemes/fluid/surface_normal_sph.jl index ef331cdcdc..7334f58c75 100644 --- a/src/schemes/fluid/surface_normal_sph.jl +++ b/src/schemes/fluid/surface_normal_sph.jl @@ -1,18 +1,28 @@ @doc raw""" ColorfieldSurfaceNormal(; boundary_contact_threshold=0.1, interface_threshold=0.01, - ideal_density_threshold=0.0) + ideal_density_threshold=0.0, interface_taper_start=0.8, + support_taper_width=0.025) Color field based computation of the interface normals. # Keywords - `boundary_contact_threshold=0.1`: If this threshold is reached the fluid is assumed to be in contact with the boundary. - `interface_threshold=0.01`: Threshold for normals to be removed as being invalid. -- `ideal_density_threshold=0.0`: Assume particles are inside if they are above this threshold, which is relative to the `ideal_neighbor_count`. +- `ideal_density_threshold=0.0`: For Morris CSF, assume particles are inside when their + continuous kernel-support moment is above this fraction of + complete support. Zero disables this filter. Other models + retain their existing neighbor-count interpretation. +- `interface_taper_start=0.8`: Start Morris CSF interface activation at this fraction of + `interface_threshold`. +- `support_taper_width=0.025`: Width of the Morris CSF support-moment transition above + `ideal_density_threshold`. """ struct ColorfieldSurfaceNormal{ELTYPE} boundary_contact_threshold::ELTYPE interface_threshold::ELTYPE ideal_density_threshold::ELTYPE + interface_taper_start::ELTYPE + support_taper_width::ELTYPE end @doc raw""" @@ -37,12 +47,94 @@ function validate_corrected_csf(::CorrectedCSFSurfaceNormal, surface_tension) return nothing end -function ColorfieldSurfaceNormal(; boundary_contact_threshold=0.1, interface_threshold=0.01, - ideal_density_threshold=0.0) - return ColorfieldSurfaceNormal(boundary_contact_threshold, interface_threshold, +function ColorfieldSurfaceNormal(boundary_contact_threshold, interface_threshold, + ideal_density_threshold) + return ColorfieldSurfaceNormal(; boundary_contact_threshold, interface_threshold, ideal_density_threshold) end +function ColorfieldSurfaceNormal(; boundary_contact_threshold=0.1, interface_threshold=0.01, + ideal_density_threshold=0.0, interface_taper_start=0.8, + support_taper_width=0.025) + if !(boundary_contact_threshold isa Real) || isnan(boundary_contact_threshold) || + boundary_contact_threshold < 0 + throw(ArgumentError("`boundary_contact_threshold` must be non-negative and not NaN")) + end + if !(interface_threshold isa Real) || !isfinite(interface_threshold) || + interface_threshold < 0 + throw(ArgumentError("`interface_threshold` must be finite and non-negative")) + end + if !(ideal_density_threshold isa Real) || !isfinite(ideal_density_threshold) || + ideal_density_threshold < 0 + throw(ArgumentError("`ideal_density_threshold` must be finite and non-negative")) + end + if !(interface_taper_start isa Real) || !isfinite(interface_taper_start) || + !(0 <= interface_taper_start < 1) + throw(ArgumentError("`interface_taper_start` must be finite and in [0, 1)")) + end + if !(support_taper_width isa Real) || !isfinite(support_taper_width) || + support_taper_width <= 0 + throw(ArgumentError("`support_taper_width` must be finite and positive")) + end + + thresholds = promote(boundary_contact_threshold, interface_threshold, + ideal_density_threshold) + ELTYPE = typeof(first(thresholds)) + if ELTYPE <: Integer + thresholds = float.(thresholds) + ELTYPE = typeof(first(thresholds)) + end + + taper_start = convert(ELTYPE, interface_taper_start) + taper_width = convert(ELTYPE, support_taper_width) + return ColorfieldSurfaceNormal(thresholds..., taper_start, taper_width) +end + +@inline function cubic_smoothstep(value) + value <= zero(value) && return zero(value) + value >= one(value) && return one(value) + return value^2 * (3 - 2value) +end + +@inline function gradient_interface_activity(normal_norm, support_radius, + surface_normal_method::ColorfieldSurfaceNormal) + threshold = surface_normal_method.interface_threshold + dimensionless_norm = support_radius * normal_norm + if iszero(threshold) + return iszero(dimensionless_norm) ? zero(dimensionless_norm) : + one(dimensionless_norm) + end + + lower_bound = surface_normal_method.interface_taper_start * threshold + transition_coordinate = (dimensionless_norm - lower_bound) / + (threshold - lower_bound) + return cubic_smoothstep(transition_coordinate) +end + +@inline function support_interface_activity(support_moment, + surface_normal_method::ColorfieldSurfaceNormal) + threshold = surface_normal_method.ideal_density_threshold + iszero(threshold) && return one(support_moment) + + transition_coordinate = (support_moment - threshold) / + surface_normal_method.support_taper_width + return one(support_moment) - cubic_smoothstep(transition_coordinate) +end + +@inline function surface_interface_activity(system, particle) + return surface_interface_activity(surface_tension_model(system), system, particle) +end + +@inline function surface_interface_activity(::SurfaceTensionMorris, system, particle) + return @inbounds system.cache.interface_activity[particle] +end + +@inline function surface_interface_activity(surface_tension, system, particle) + normal = surface_normal(system, particle) + return dot(normal, normal) > eps(eltype(normal)) ? one(eltype(normal)) : + zero(eltype(normal)) +end + function create_cache_surface_normal(surface_normal_method, ELTYPE, NDIMS, nparticles) return (;) end @@ -103,6 +195,8 @@ function calc_normal!(system::AbstractFluidSystem, neighbor_system::AbstractFlui for i in 1:ndims(system) cache.surface_normal[i, particle] += m_b / density_neighbor * grad_kernel[i] end + accumulate_surface_support_moment!(system, surface_tension_model(system), particle, + m_b / density_neighbor, pos_diff, grad_kernel) cache.neighbor_count[particle] += 1 end @@ -110,11 +204,45 @@ function calc_normal!(system::AbstractFluidSystem, neighbor_system::AbstractFlui return system end +@inline function accumulate_surface_support_moment!(system, surface_tension, particle, + volume, pos_diff, grad_kernel) + return system +end + +@inline function accumulate_surface_support_moment!(system, ::SurfaceTensionMorris, + particle, volume, pos_diff, + grad_kernel) + value = -volume * dot(pos_diff, grad_kernel) / ndims(system) + @inbounds system.cache.support_moment[particle] += value + return system +end + +@inline function accumulate_boundary_surface_support_moment!(system, surface_tension, + neighbor_system, + v_neighbor_system, particle, + neighbor, pos_diff, distance) + return system +end + +@inline function accumulate_boundary_surface_support_moment!(system, + ::SurfaceTensionMorris, + neighbor_system, + v_neighbor_system, particle, + neighbor, pos_diff, distance) + m_b = hydrodynamic_mass(neighbor_system, neighbor) + density_neighbor = current_density(v_neighbor_system, neighbor_system, neighbor) + grad_kernel = smoothing_kernel_grad(system, pos_diff, distance, particle) + value = -m_b / density_neighbor * dot(pos_diff, grad_kernel) / ndims(system) + @inbounds system.cache.support_moment[particle] += value + return system +end + # Section 2.2 in Akinci et al. 2013 "Versatile Surface Tension and Adhesion for SPH Fluids" # Note: This is the simplest form of normal approximation commonly used in SPH and comes # with serious deficits in accuracy especially at corners, small neighborhoods and boundaries function calc_boundary_normal!(system::AbstractFluidSystem, neighbor_system, u_system, v, - u_neighbor_system, semi, surface_normal_method) + v_neighbor_system, u_neighbor_system, semi, + surface_normal_method) (; cache) = system (; colorfield, initial_colorfield) = neighbor_system.boundary_model.cache (; boundary_contact_threshold) = surface_normal_method @@ -143,6 +271,10 @@ function calc_boundary_normal!(system::AbstractFluidSystem, neighbor_system, u_s foreach_point_neighbor(system, neighbor_system, system_coords, neighbor_system_coords, semi) do particle, neighbor, pos_diff, distance + accumulate_boundary_surface_support_moment!(system, surface_tension_model(system), + neighbor_system, v_neighbor_system, + particle, neighbor, pos_diff, distance) + # We assume that we are in contact with the boundary if the color of the boundary particle # is larger than the threshold if colorfield[neighbor] / maximum_colorfield > boundary_contact_threshold @@ -162,8 +294,8 @@ end function calc_normal!(system::AbstractFluidSystem, neighbor_system::AbstractBoundarySystem, u_system, v, v_neighbor_system, u_neighbor_system, semi, surface_normal_method, neighbor_surface_normal_method) - return calc_boundary_normal!(system, neighbor_system, u_system, v, u_neighbor_system, - semi, surface_normal_method) + return calc_boundary_normal!(system, neighbor_system, u_system, v, v_neighbor_system, + u_neighbor_system, semi, surface_normal_method) end function remove_invalid_normals!(system::AbstractFluidSystem, surface_tension, @@ -227,33 +359,36 @@ function remove_invalid_normals!(system::AbstractFluidSystem, ::SurfaceTensionMorris, surface_normal_method::ColorfieldSurfaceNormal) (; cache, smoothing_kernel) = system - (; ideal_density_threshold, interface_threshold) = surface_normal_method support_radius = compact_support(smoothing_kernel, initial_smoothing_length(system)) - normal_condition2 = (interface_threshold / support_radius)^2 for particle in each_integrated_particle(system) cache.delta_s[particle] = zero(eltype(system)) cache.interface_activity[particle] = zero(eltype(system)) - if ideal_density_threshold > 0 && - ideal_density_threshold * - ideal_neighbor_count(Val(ndims(system)), cache.reference_particle_spacing, - support_radius) < cache.neighbor_count[particle] + particle_surface_normal = surface_normal(system, particle) + norm2 = dot(particle_surface_normal, particle_surface_normal) + if !(norm2 > eps(norm2)) cache.surface_normal[1:ndims(system), particle] .= 0 continue end - particle_surface_normal = surface_normal(system, particle) - norm2 = dot(particle_surface_normal, particle_surface_normal) - if norm2 > normal_condition2 - normal_norm = sqrt(norm2) - cache.delta_s[particle] = 2 * normal_norm - cache.interface_activity[particle] = one(eltype(system)) - cache.surface_normal[1:ndims(system), - particle] = particle_surface_normal / normal_norm - else + normal_norm = sqrt(norm2) + gradient_activity = gradient_interface_activity(normal_norm, support_radius, + surface_normal_method) + support_moment = cache.support_moment[particle] + support_activity = support_interface_activity(support_moment, + surface_normal_method) + activity = gradient_activity * support_activity + if !(activity > zero(activity)) cache.surface_normal[1:ndims(system), particle] .= 0 + continue end + + cache.interface_activity[particle] = activity + # A one-phase free surface samples one half of the kernel-smoothed interface. + cache.delta_s[particle] = 2 * normal_norm * activity + cache.surface_normal[1:ndims(system), + particle] = particle_surface_normal / normal_norm end return system @@ -262,6 +397,7 @@ end @inline reset_surface_interface_data!(system, surface_tension) = system @inline function reset_surface_interface_data!(system, ::SurfaceTensionMorris) + set_zero!(system.cache.support_moment) set_zero!(system.cache.interface_activity) set_zero!(system.cache.delta_s) return system @@ -337,6 +473,7 @@ function compute_surface_normal!(system::AbstractFluidSystem, set_zero!(lambda_gradient) set_zero!(color_gradient) set_zero!(shepard_sum) + set_zero!(cache.support_moment) @trixi_timeit timer() "compute C-CSF moments" begin foreach_point_neighbor(system, system, coordinates, coordinates, semi; @@ -412,6 +549,7 @@ function compute_surface_normal!(system::AbstractFluidSystem, correction = shepard > eps(shepard) ? max(one(shepard), inv(2shepard)) : one(shepard) @inbounds cache.delta_s[particle] = 2correction * norm(raw_gradient) + @inbounds cache.support_moment[particle] = lambda_i end return system @@ -441,17 +579,19 @@ function calc_curvature!(system::AbstractFluidSystem, neighbor_system::AbstractF n_a = surface_normal(system, particle) n_b = surface_normal(neighbor_system, neighbor) v_b = m_b / rho_b + activity_a = surface_interface_activity(system, particle) + activity_b = surface_interface_activity(neighbor_system, neighbor) - # Eq. 22: we can test against `eps()` here since the surface normals that are invalid have been removed - if dot(n_a, n_a) > eps() && dot(n_b, n_b) > eps() + if activity_a > zero(activity_a) && activity_b > zero(activity_b) w = smoothing_kernel(system, distance, particle) grad_kernel = smoothing_kernel_grad(system, pos_diff, distance, particle) + weighted_volume = v_b * activity_b for i in 1:ndims(system) - curvature[particle] += v_b * (n_b[i] - n_a[i]) * grad_kernel[i] + curvature[particle] += weighted_volume * (n_b[i] - n_a[i]) * + grad_kernel[i] end - # Eq. 24 - correction_factor[particle] += v_b * w + correction_factor[particle] += weighted_volume * w end end @@ -490,9 +630,14 @@ function calc_curvature!(system::AbstractFluidSystem, return system end +@inline function normalized_surface_curvature(curvature_numerator, denominator) + denominator > sqrt(eps(typeof(denominator))) || return zero(curvature_numerator) + return curvature_numerator / denominator +end + @inline function finalize_surface_curvature(curvature_numerator, denominator, surface_normal_method) - return curvature_numerator / (denominator + eps()) + return normalized_surface_curvature(curvature_numerator, denominator) end @inline function finalize_surface_curvature(curvature_numerator, denominator, diff --git a/src/schemes/fluid/surface_tension.jl b/src/schemes/fluid/surface_tension.jl index 3e0f4b7a31..a19947e805 100644 --- a/src/schemes/fluid/surface_tension.jl +++ b/src/schemes/fluid/surface_tension.jl @@ -53,7 +53,8 @@ phenomena like droplet formation and capillary wave dynamics. The one-phase color-gradient magnitude is retained as a surface delta. The local continuum-surface-force acceleration is evaluated once per particle as -``-sigma * kappa * delta_s * n_hat / rho``. +``-sigma * kappa * delta_s * n_hat / rho``. A smooth interface activity avoids discrete normal +and curvature-stencil switches when using [`ColorfieldSurfaceNormal`](@ref). See [`surface_tension`](@ref) for more details. @@ -78,7 +79,8 @@ function create_cache_surface_tension(::SurfaceTensionMorris, ELTYPE, NDIMS, npa curvature = Array{ELTYPE, 1}(undef, nparticles) delta_s = Array{ELTYPE, 1}(undef, nparticles) interface_activity = Array{ELTYPE, 1}(undef, nparticles) - return (; curvature, delta_s, interface_activity) + support_moment = Array{ELTYPE, 1}(undef, nparticles) + return (; curvature, delta_s, interface_activity, support_moment) end @doc raw""" diff --git a/src/schemes/structure/rigid_body/system.jl b/src/schemes/structure/rigid_body/system.jl index 033ea9f1da..604d601b8b 100644 --- a/src/schemes/structure/rigid_body/system.jl +++ b/src/schemes/structure/rigid_body/system.jl @@ -281,8 +281,9 @@ function calc_normal!(system::AbstractFluidSystem, surface_normal_method, neighbor_surface_normal_method) haskey(neighbor_system.boundary_model.cache, :initial_colorfield) || return system - return calc_boundary_normal!(system, neighbor_system, u_system, v, u_neighbor_system, - semi, surface_normal_method) + return calc_boundary_normal!(system, neighbor_system, u_system, v, + v_neighbor_system, u_neighbor_system, semi, + surface_normal_method) end @inline function adhesion_force!(dv_particle, diff --git a/test/schemes/fluid/surface_normal_sph.jl b/test/schemes/fluid/surface_normal_sph.jl index 727a71e506..ce7b2154c3 100644 --- a/test/schemes/fluid/surface_normal_sph.jl +++ b/test/schemes/fluid/surface_normal_sph.jl @@ -113,9 +113,6 @@ function compute_and_test_surface_values(system, semi, ode; NDIMS=2) TrixiParticles.compute_surface_normal!(system, system.surface_normal_method, v, u, v0_ode, u0_ode, semi, 0.0) - TrixiParticles.remove_invalid_normals!(system, system.surface_tension, - system.surface_normal_method) - # After computation, check that surface normals have been computed and are not NaN or Inf @test all(isfinite, system.cache.surface_normal) @test all(isfinite, system.cache.neighbor_count) @@ -251,7 +248,8 @@ end NDIMS, smoothing_length, smoothing_kernel, surface_normal_method=ColorfieldSurfaceNormal(interface_threshold=0.1, ideal_density_threshold=0.9), - wall=true, walldistance=2.0, boundary_system_type=:wall) + wall=true, walldistance=particle_spacing, + boundary_system_type=:wall) rigid_system, rigid_boundary, rigid_semi, rigid_ode = create_fluid_system(coordinates, velocity, mass, density, particle_spacing, @@ -259,11 +257,19 @@ end NDIMS, smoothing_length, smoothing_kernel, surface_normal_method=ColorfieldSurfaceNormal(interface_threshold=0.1, ideal_density_threshold=0.9), - wall=true, walldistance=2.0, + wall=true, walldistance=particle_spacing, boundary_system_type=:rigid) + free_system, _, free_semi, + free_ode = create_fluid_system(coordinates, velocity, mass, density, particle_spacing, + SurfaceTensionMorris(surface_tension_coefficient=0.072); + NDIMS, smoothing_length, smoothing_kernel, + surface_normal_method=ColorfieldSurfaceNormal(interface_threshold=0.1, + ideal_density_threshold=0.9)) + compute_and_test_surface_values(wall_system, wall_semi, wall_ode; NDIMS) compute_and_test_surface_values(rigid_system, rigid_semi, rigid_ode; NDIMS) + compute_and_test_surface_values(free_system, free_semi, free_ode; NDIMS) @test isapprox(rigid_boundary.boundary_model.cache.initial_colorfield, wall_boundary.boundary_model.cache.initial_colorfield, @@ -274,6 +280,14 @@ end @test isapprox(rigid_system.cache.neighbor_count, wall_system.cache.neighbor_count, rtol=sqrt(eps()), atol=sqrt(eps())) + @test all(isfinite, wall_system.cache.support_moment) + @test maximum(abs, wall_system.cache.support_moment) > 0 + @test isapprox(rigid_system.cache.support_moment, + wall_system.cache.support_moment, + rtol=sqrt(eps()), atol=sqrt(eps())) + @test maximum(abs, + wall_system.cache.support_moment - free_system.cache.support_moment) > + sqrt(eps()) end @testset verbose=true "CSS/CSF: Sphere Surface Normals" begin diff --git a/test/schemes/fluid/surface_tension.jl b/test/schemes/fluid/surface_tension.jl index 33eb52ff03..089340f0f6 100644 --- a/test/schemes/fluid/surface_tension.jl +++ b/test/schemes/fluid/surface_tension.jl @@ -1,5 +1,81 @@ @testset verbose=true "Surface Tension" begin + @testset "smooth interface activity" begin + method = ColorfieldSurfaceNormal(; boundary_contact_threshold=1, + interface_threshold=0.1f0, + ideal_density_threshold=0.9, + interface_taper_start=0.8, + support_taper_width=0.05) + @test method isa ColorfieldSurfaceNormal{Float64} + @test method.interface_taper_start === 0.8 + @test method.support_taper_width === 0.05 + @test ColorfieldSurfaceNormal(1, 1, 0) isa ColorfieldSurfaceNormal{Float64} + @test ColorfieldSurfaceNormal(; boundary_contact_threshold=0.1f0, + interface_threshold=0.01f0, + ideal_density_threshold=0.0f0, + interface_taper_start=0.8f0, + support_taper_width=0.025f0) isa + ColorfieldSurfaceNormal{Float32} + + for ELTYPE in (Float32, Float64) + @test TrixiParticles.cubic_smoothstep(ELTYPE(-1)) === ELTYPE(0) + @test TrixiParticles.cubic_smoothstep(ELTYPE(0)) === ELTYPE(0) + @test TrixiParticles.cubic_smoothstep(ELTYPE(0.5)) === ELTYPE(0.5) + @test TrixiParticles.cubic_smoothstep(ELTYPE(1)) === ELTYPE(1) + @test TrixiParticles.cubic_smoothstep(ELTYPE(2)) === ELTYPE(1) + + method_ = ColorfieldSurfaceNormal(; boundary_contact_threshold=ELTYPE(0.1), + interface_threshold=ELTYPE(0.1), + ideal_density_threshold=ELTYPE(0.9), + interface_taper_start=ELTYPE(0.8), + support_taper_width=ELTYPE(0.05)) + @test TrixiParticles.gradient_interface_activity(ELTYPE(0.08), one(ELTYPE), + method_) === ELTYPE(0) + @test TrixiParticles.gradient_interface_activity(ELTYPE(0.09), one(ELTYPE), + method_) ≈ ELTYPE(0.5) + @test TrixiParticles.gradient_interface_activity(ELTYPE(0.1), one(ELTYPE), + method_) === ELTYPE(1) + @test TrixiParticles.support_interface_activity(ELTYPE(0.9), method_) === + ELTYPE(1) + @test TrixiParticles.support_interface_activity(ELTYPE(0.925), method_) ≈ + ELTYPE(0.5) + @test TrixiParticles.support_interface_activity(ELTYPE(0.95), method_) === + ELTYPE(0) + + step = sqrt(eps(ELTYPE)) + derivative_at_zero = TrixiParticles.cubic_smoothstep(step) / step + derivative_at_one = (one(ELTYPE) - + TrixiParticles.cubic_smoothstep(one(ELTYPE) - step)) / step + @test abs(derivative_at_zero) < 4step + @test abs(derivative_at_one) < 4step + end + + disabled = ColorfieldSurfaceNormal(; ideal_density_threshold=0.0) + @test TrixiParticles.support_interface_activity(10.0, disabled) == 1.0 + @test TrixiParticles.normalized_surface_curvature(1.0, 0.0) == 0.0 + @test TrixiParticles.normalized_surface_curvature(1.0, eps()) == 0.0 + @test TrixiParticles.normalized_surface_curvature(2.0, 0.5) == 4.0 + + for threshold in (-1, NaN, Inf) + @test_throws ArgumentError ColorfieldSurfaceNormal(interface_threshold=threshold) + @test_throws ArgumentError ColorfieldSurfaceNormal(ideal_density_threshold=threshold) + end + for taper_start in (-0.1, 1.0, NaN, Inf) + @test_throws ArgumentError ColorfieldSurfaceNormal(; + interface_taper_start=taper_start) + end + for taper_width in (0.0, -0.1, NaN, Inf) + @test_throws ArgumentError ColorfieldSurfaceNormal(; + support_taper_width=taper_width) + end + + system_data = Dict{String, Any}() + TrixiParticles.add_system_data!(system_data, method) + @test system_data["surface_normal_method"]["interface_threshold"] ≈ 0.1 + @test system_data["surface_normal_method"]["interface_taper_start"] === 0.8 + @test system_data["surface_normal_method"]["support_taper_width"] === 0.05 + end + @testset verbose=true "`cohesion_force_akinci`" begin surface_tension = SurfaceTensionAkinci(surface_tension_coefficient=1.0) support_radius = 1.0 @@ -163,12 +239,19 @@ system = build_morris_system(:wcsph, 2) system.cache.surface_normal .= [2.0 1.0; 0.0 1.0] + system.cache.support_moment .= 0 TrixiParticles.remove_invalid_normals!(system, system.surface_tension, system.surface_normal_method) @test system.cache.delta_s ≈ [4.0, 2sqrt(2)] @test system.cache.interface_activity == [1.0, 1.0] @test system.cache.surface_normal[:, 1] ≈ [1.0, 0.0] @test system.cache.surface_normal[:, 2] ≈ [1 / sqrt(2), 1 / sqrt(2)] + system.cache.surface_normal[:, 1] .= [NaN, 0.0] + TrixiParticles.remove_invalid_normals!(system, system.surface_tension, + system.surface_normal_method) + @test iszero(system.cache.surface_normal[:, 1]) + @test iszero(system.cache.delta_s[1]) + @test iszero(system.cache.interface_activity[1]) system.cache.surface_normal[1, :] .= 1.0 system.cache.surface_normal[2, :] .= 0.0 @@ -185,6 +268,47 @@ SVector(0.0, 0.0)) @test scaled_acceleration ≈ acceleration / 4 + + semi = Semidiscretization(system) + ode = semidiscretize(semi, (0.0, 0.01)) + v_ode, u_ode = ode.u0.x + TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, 0.0) + system.cache.surface_normal .= [1.0 0.0; 0.0 1.0] + + function curvature_with_neighbor_activity(activity) + system.cache.interface_activity .= [1.0, activity] + fill!(system.cache.curvature, 0) + fill!(system.cache.correction_factor, 0) + GC.@preserve v_ode u_ode begin + v = TrixiParticles.wrap_v(v_ode, system, semi) + u = TrixiParticles.wrap_u(u_ode, system, semi) + TrixiParticles.calc_curvature!(system, system, u, v, v, u, semi, + system.surface_normal_method, + system.surface_normal_method) + end + denominator = system.cache.correction_factor[1] + return TrixiParticles.normalized_surface_curvature(system.cache.curvature[1], + denominator) + end + + curvature_zero = curvature_with_neighbor_activity(0.0) + curvature_small = curvature_with_neighbor_activity(1.0e-6) + curvature_full = curvature_with_neighbor_activity(1.0) + @test iszero(curvature_zero) + @test abs(curvature_small) < 1.0e-4 * abs(curvature_full) + @test isfinite(curvature_full) + + curvature_numerator = copy(system.cache.curvature) + correction_factor = copy(system.cache.correction_factor) + GC.@preserve v_ode u_ode begin + v = TrixiParticles.wrap_v(v_ode, system, semi) + u = TrixiParticles.wrap_u(u_ode, system, semi) + TrixiParticles.calc_curvature!(system, system, u, v, v, u, semi, + system.surface_normal_method, + system.surface_normal_method) + end + @test system.cache.curvature ≈ 2curvature_numerator + @test system.cache.correction_factor ≈ 2correction_factor end @testset "compute_stress_tensors! (MomentumMorris)" begin From 631909a1a132919ee46622d4e0759572a4e8effc Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Sun, 9 Aug 2026 13:44:06 +0200 Subject: [PATCH 55/59] Add balanced CSS surface tension --- NEWS.md | 16 +- docs/src/systems/fluid.md | 48 +++- src/io/write_vtk.jl | 14 +- .../fluid/entropically_damped_sph/system.jl | 2 - src/schemes/fluid/surface_normal_sph.jl | 88 +++--- src/schemes/fluid/surface_tension.jl | 109 +++----- .../fluid/weakly_compressible_sph/system.jl | 4 +- test/schemes/fluid/surface_normal_sph.jl | 60 ++++ test/schemes/fluid/surface_tension.jl | 256 ++++++++++++------ 9 files changed, 362 insertions(+), 235 deletions(-) diff --git a/NEWS.md b/NEWS.md index 4a2cb0d941..d1dcf76719 100644 --- a/NEWS.md +++ b/NEWS.md @@ -11,15 +11,19 @@ used in the Julia ecosystem. Notable changes will be documented in this file for - Corrected `SurfaceTensionMorris` to apply its local CSF acceleration once per particle and retain the required one-phase surface delta. Previous coefficients compensated implicitly for a dimensionally incomplete force repeated once per fluid neighbor and must be recalibrated. -- For `SurfaceTensionMorris` with `ColorfieldSurfaceNormal`, `ideal_density_threshold` now denotes - a fraction of the continuous complete-support kernel moment instead of an integer neighbor-count - fraction. The default zero still disables interior filtering. +- For `SurfaceTensionMorris` and `SurfaceTensionMomentumMorris` with + `ColorfieldSurfaceNormal`, `ideal_density_threshold` now denotes a fraction of the continuous + complete-support kernel moment instead of an integer neighbor-count fraction. The default zero + still disables interior filtering. ### Features -- Added C1 interface activation for Morris CSF with `ColorfieldSurfaceNormal`. Color-gradient and - continuous support-moment indicators taper the physical surface delta without another neighbor - pass. +- Reworked `SurfaceTensionMomentumMorris` as a balanced continuum-surface-stress operator with a + symmetric support correction. It conserves pairwise linear momentum without a cached stress + tensor or global reduction. +- Added C1 interface activation for Morris CSF and CSS with `ColorfieldSurfaceNormal`. + Color-gradient and continuous support-moment indicators taper the physical surface delta without + another neighbor pass. - Added `CorrectedCSFSurfaceNormal`, an explicit free-surface implementation of the C-CSF interface geometry from Vergnaud et al. (2022) for `SurfaceTensionMorris`. - Added the computation of boundary normals for `RectangularTank`s and `SphereShape`s. diff --git a/docs/src/systems/fluid.md b/docs/src/systems/fluid.md index 49d4349cf6..f0d00a1500 100644 --- a/docs/src/systems/fluid.md +++ b/docs/src/systems/fluid.md @@ -383,7 +383,7 @@ adequate resolution. #### Smooth colorfield interface activity -With `ColorfieldSurfaceNormal`, Morris CSF uses a C1 interface activity ``\lambda_a``. Let +With `ColorfieldSurfaceNormal`, Morris CSF and CSS use a C1 interface activity ``\lambda_a``. Let ``h_c`` be the compact-support radius, ``\gamma_a=h_c\Vert\bm g_a\Vert``, ``\epsilon_n`` be `interface_threshold`, and ``\alpha`` be `interface_taper_start` (default `0.8`). With @@ -415,7 +415,7 @@ support activity is ``\lambda_{q,a}=1-S((q_a-\tau)/\Delta q)``, where ``\Delta q \delta_{s,a}=2\Vert\bm g_a\Vert\lambda_a. ``` -Setting `ideal_density_threshold=0` disables support filtering. For Morris CSF with +Setting `ideal_density_threshold=0` disables support filtering. For Morris CSF/CSS with `ColorfieldSurfaceNormal`, this keyword represents a continuous fraction of complete kernel support instead of an integer neighbor-count fraction. Dummy boundary particles complete ``q_a`` near walls without carrying capillary stress. These controls do not alter the separate @@ -439,10 +439,10 @@ contact-angle terms are not included. ### [Morris-based momentum-conserving surface tension model](@id moriss_css) -In addition to the simpler curvature-based formulation, [Morris](@cite Morris2000) introduced a momentum-conserving approach. -This method treats surface tension forces as arising from the divergence of a stress tensor, ensuring exact conservation -of linear momentum and offering more robust behavior for high-resolution or long-duration simulations -where accumulated numerical error can be significant. +[`SurfaceTensionMomentumMorris`](@ref) implements a balanced continuum-surface-stress (CSS) +formulation for one-phase free surfaces. It treats surface tension as the divergence of a +localized tangential stress, avoiding the explicit curvature pass required by +[`SurfaceTensionMorris`](@ref). #### Stress tensor formulation @@ -464,23 +464,41 @@ with: - ``\hat{\bm{n}}``: Unit normal vector, - ``I``: Identity matrix. -This divergence can be computed numerically in the SPH framework as +For a free surface represented only by fluid particles, the raw color gradient ``\bm{g}_a`` is +sampled over one half of the kernel-smoothed interface. TrixiParticles.jl therefore uses ```math -\bm{F}_{a}^{\sigma} -= m_a \sum_b \frac{m_b}{\rho_a \rho_b} (\bm{S}_a + \bm{S}_b) \nabla_a W_{ab}. +\delta_{s,a} = 2\Vert\bm{g}_a\Vert\lambda_a, +\qquad +\hat{\bm{n}}_a = \frac{\bm{g}_a}{\Vert\bm{g}_a\Vert}, ``` -TrixiParticles.jl stores ``\sigma`` outside the tensor and uses the stabilized tensor +where ``\lambda_a`` is the smooth interface activity described above. During the same neighbor +pass, the scalar consistency measure + ```math -\bm{S}_a^{\text{impl}} -= \delta_{s,a} (I - \hat{\bm{n}}_a \otimes \hat{\bm{n}}_a) - \delta_{s,\max} I, +q_a = -\frac{1}{d}\sum_b \frac{m_b}{\rho_b} + \bm{r}_{ab}\mathbin{\cdot}\nabla_a W_{ab} ``` -#### Advantages and limitations +is accumulated. Its continuum interior value is one. The symmetric pair correction +``c_{ab}=2/(q_a+q_b)`` restores the linear kernel-gradient scaling near truncated support while +retaining an antisymmetric pair force. The acceleration is evaluated directly, without storing +``\bm S``: + +```math +\frac{\mathrm{d}\bm{v}_a}{\mathrm{d}t}\bigg|_\sigma += \sigma\sum_b\frac{m_b c_{ab}}{\rho_a\rho_b} +\left[ +\delta_{s,a}(I-\hat{\bm{n}}_a\otimes\hat{\bm{n}}_a) ++\delta_{s,b}(I-\hat{\bm{n}}_b\otimes\hat{\bm{n}}_b) +\right]\nabla_a W_{ab}. +``` -While momentum conservation makes this model attractive, it requires additional computational effort and stabilization -techniques to address instabilities in high-density regions. +For constant smoothing length, every coefficient multiplying a particle pair is symmetric and +``\nabla_bW_{ba}=-\nabla_aW_{ab}``. The model therefore conserves linear momentum to roundoff. +Dummy boundary particles complete ``q_a`` near walls but do not carry capillary stress. The +Akinci free-surface correction is deliberately not applied to this continuum stress. ### API diff --git a/src/io/write_vtk.jl b/src/io/write_vtk.jl index fa3fb80985..273fe9a3ae 100644 --- a/src/io/write_vtk.jl +++ b/src/io/write_vtk.jl @@ -365,7 +365,19 @@ function write2vtk!(vtk, v, u, t, system::AbstractFluidSystem) vtk["curvature"] = system.cache.curvature end if system.surface_tension isa SurfaceTensionMomentumMorris - vtk["surface_stress_tensor"] = system.cache.stress_tensor + stress_tensor = zeros(eltype(system), ndims(system), ndims(system), + n_integrated_particles(system)) + for particle in each_integrated_particle(system) + normal = surface_normal(system, particle) + delta_s = system.cache.delta_s[particle] + for i in 1:ndims(system), j in 1:ndims(system) + stress_tensor[i, j, + particle] = delta_s * + ((i == j) - normal[i] * normal[j]) + end + end + vtk["surface_divergence_correction"] = system.cache.divergence_correction + vtk["surface_stress_tensor"] = stress_tensor end end diff --git a/src/schemes/fluid/entropically_damped_sph/system.jl b/src/schemes/fluid/entropically_damped_sph/system.jl index aee6e1ee16..86498f9d24 100644 --- a/src/schemes/fluid/entropically_damped_sph/system.jl +++ b/src/schemes/fluid/entropically_damped_sph/system.jl @@ -311,7 +311,6 @@ function update_pressure!(system::EntropicallyDampedSPHSystem, v, u, v_ode, u_od 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, @@ -354,7 +353,6 @@ function update_final!(system::EntropicallyDampedSPHSystem, v, u, v_ode, u_ode, # Surface normal of neighbor and boundary needs to have been calculated already compute_curvature!(system, surface_tension, v, u, v_ode, u_ode, semi, t) - compute_stress_tensors!(system, surface_tension, v, u, v_ode, u_ode, semi, t) update_average_pressure!(system, system.average_pressure_reduction, v_ode, u_ode, semi) update_shifting!(system, shifting_technique(system), v, u, v_ode, u_ode, semi) end diff --git a/src/schemes/fluid/surface_normal_sph.jl b/src/schemes/fluid/surface_normal_sph.jl index 7334f58c75..f0b5cf11da 100644 --- a/src/schemes/fluid/surface_normal_sph.jl +++ b/src/schemes/fluid/surface_normal_sph.jl @@ -8,13 +8,13 @@ Color field based computation of the interface normals. # Keywords - `boundary_contact_threshold=0.1`: If this threshold is reached the fluid is assumed to be in contact with the boundary. - `interface_threshold=0.01`: Threshold for normals to be removed as being invalid. -- `ideal_density_threshold=0.0`: For Morris CSF, assume particles are inside when their +- `ideal_density_threshold=0.0`: For Morris CSF/CSS, assume particles are inside when their continuous kernel-support moment is above this fraction of complete support. Zero disables this filter. Other models retain their existing neighbor-count interpretation. -- `interface_taper_start=0.8`: Start Morris CSF interface activation at this fraction of +- `interface_taper_start=0.8`: Start Morris CSF/CSS interface activation at this fraction of `interface_threshold`. -- `support_taper_width=0.025`: Width of the Morris CSF support-moment transition above +- `support_taper_width=0.025`: Width of the Morris CSF/CSS support-moment transition above `ideal_density_threshold`. """ struct ColorfieldSurfaceNormal{ELTYPE} @@ -121,11 +121,21 @@ end return one(support_moment) - cubic_smoothstep(transition_coordinate) end +@inline function surface_support_moment(system, ::SurfaceTensionMorris, particle) + return @inbounds system.cache.support_moment[particle] +end + +@inline function surface_support_moment(system, ::SurfaceTensionMomentumMorris, particle) + return @inbounds system.cache.divergence_correction[particle] +end + @inline function surface_interface_activity(system, particle) return surface_interface_activity(surface_tension_model(system), system, particle) end -@inline function surface_interface_activity(::SurfaceTensionMorris, system, particle) +@inline function surface_interface_activity(::Union{SurfaceTensionMorris, + SurfaceTensionMomentumMorris}, + system, particle) return @inbounds system.cache.interface_activity[particle] end @@ -217,6 +227,15 @@ end return system end +@inline function accumulate_surface_support_moment!(system, + ::SurfaceTensionMomentumMorris, + particle, volume, pos_diff, + grad_kernel) + value = -volume * dot(pos_diff, grad_kernel) / ndims(system) + @inbounds system.cache.divergence_correction[particle] += value + return system +end + @inline function accumulate_boundary_surface_support_moment!(system, surface_tension, neighbor_system, v_neighbor_system, particle, @@ -225,15 +244,16 @@ end end @inline function accumulate_boundary_surface_support_moment!(system, - ::SurfaceTensionMorris, + surface_tension::Union{SurfaceTensionMorris, + SurfaceTensionMomentumMorris}, neighbor_system, v_neighbor_system, particle, neighbor, pos_diff, distance) m_b = hydrodynamic_mass(neighbor_system, neighbor) density_neighbor = current_density(v_neighbor_system, neighbor_system, neighbor) grad_kernel = smoothing_kernel_grad(system, pos_diff, distance, particle) - value = -m_b / density_neighbor * dot(pos_diff, grad_kernel) / ndims(system) - @inbounds system.cache.support_moment[particle] += value + accumulate_surface_support_moment!(system, surface_tension, particle, + m_b / density_neighbor, pos_diff, grad_kernel) return system end @@ -313,50 +333,9 @@ function remove_invalid_normals!(system::AbstractFluidSystem, surface_tension, return system end -# See Morris 2000 "Simulating surface tension with smoothed particle hydrodynamics" -function remove_invalid_normals!(system::AbstractFluidSystem, - surface_tension::SurfaceTensionMomentumMorris, - surface_normal_method::ColorfieldSurfaceNormal) - (; cache, smoothing_kernel) = system - (; ideal_density_threshold, interface_threshold) = surface_normal_method - (; neighbor_count) = cache - - smoothing_length_ = initial_smoothing_length(system) - - # We remove invalid normals i.e. they have a small norm (eq. 20) - normal_condition2 = (interface_threshold / - compact_support(smoothing_kernel, smoothing_length_))^2 - - for particle in each_integrated_particle(system) - - # Heuristic condition if there is no gas phase to find the free surface. - # We remove normals for particles which have a lot of support e.g. they are in the interior. - if ideal_density_threshold > 0 && - ideal_density_threshold * - ideal_neighbor_count(Val(ndims(system)), cache.reference_particle_spacing, - compact_support(smoothing_kernel, smoothing_length_)) < - neighbor_count[particle] - cache.surface_normal[1:ndims(system), particle] .= 0 - continue - end - - particle_surface_normal = surface_normal(system, particle) - norm2 = dot(particle_surface_normal, particle_surface_normal) - - # See eq. 21 - if norm2 > normal_condition2 - cache.surface_normal[1:ndims(system), - particle] = particle_surface_normal / sqrt(norm2) - else - cache.surface_normal[1:ndims(system), particle] .= 0 - end - end - - return system -end - function remove_invalid_normals!(system::AbstractFluidSystem, - ::SurfaceTensionMorris, + surface_tension::Union{SurfaceTensionMorris, + SurfaceTensionMomentumMorris}, surface_normal_method::ColorfieldSurfaceNormal) (; cache, smoothing_kernel) = system support_radius = compact_support(smoothing_kernel, initial_smoothing_length(system)) @@ -375,7 +354,7 @@ function remove_invalid_normals!(system::AbstractFluidSystem, normal_norm = sqrt(norm2) gradient_activity = gradient_interface_activity(normal_norm, support_radius, surface_normal_method) - support_moment = cache.support_moment[particle] + support_moment = surface_support_moment(system, surface_tension, particle) support_activity = support_interface_activity(support_moment, surface_normal_method) activity = gradient_activity * support_activity @@ -403,6 +382,13 @@ end return system end +@inline function reset_surface_interface_data!(system, ::SurfaceTensionMomentumMorris) + set_zero!(system.cache.divergence_correction) + set_zero!(system.cache.interface_activity) + set_zero!(system.cache.delta_s) + return system +end + function compute_surface_normal!(system, surface_normal_method, v, u, v_ode, u_ode, semi, t) return system end diff --git a/src/schemes/fluid/surface_tension.jl b/src/schemes/fluid/surface_tension.jl index a19947e805..1a9e89e0ea 100644 --- a/src/schemes/fluid/surface_tension.jl +++ b/src/schemes/fluid/surface_tension.jl @@ -86,17 +86,23 @@ end @doc raw""" SurfaceTensionMomentumMorris(surface_tension_coefficient=1.0) -This model implements the momentum-conserving surface tension approach outlined by Morris -[Morris2000](@cite). It calculates surface tension forces using the divergence of a stress -tensor, ensuring exact conservation of linear momentum. This method is particularly -useful for simulations where momentum conservation is critical, though it may require -numerical adjustments at higher resolutions. +This model implements the conservative continuum-surface-stress (CSS) approach outlined by +Morris [Morris2000](@cite). It computes the divergence of +``\sigma\delta_s(I - \hat{n}\otimes\hat{n})`` with the same symmetric pair operator used by +the fluid momentum equation. This avoids an explicit curvature estimate and conserves linear +momentum exactly for constant smoothing length. + +The unnormalized color-gradient magnitude is retained as the surface delta ``\delta_s`` before +the gradient is converted to a unit normal. The stress projection is evaluated directly during +the fluid interaction, so no per-particle stress tensor or global reduction is required. A +symmetric scalar reproducing correction is accumulated during the normal pass and applied to the +stress divergence. It restores first-order scaling near truncated kernel support without another +neighbor traversal or loss of pairwise momentum conservation. 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`: Physical surface tension coefficient in N/m. """ struct SurfaceTensionMomentumMorris{ELTYPE} <: AbstractSurfaceTension surface_tension_coefficient::ELTYPE @@ -109,13 +115,9 @@ end function create_cache_surface_tension(::SurfaceTensionMomentumMorris, ELTYPE, NDIMS, nparticles) delta_s = Array{ELTYPE, 1}(undef, nparticles) - # Allocate stress tensor for each particle: NDIMS x NDIMS x nparticles - stress_tensor = Array{ELTYPE, 3}(undef, NDIMS, NDIMS, nparticles) - return (; stress_tensor, delta_s) -end - -@inline function stress_tensor(particle_system::AbstractFluidSystem, particle) - return extract_smatrix(particle_system.cache.stress_tensor, particle_system, particle) + interface_activity = Array{ELTYPE, 1}(undef, nparticles) + divergence_correction = Array{ELTYPE, 1}(undef, nparticles) + return (; delta_s, interface_activity, divergence_correction) end # Note that `floating_point_number^integer_literal` is lowered to `Base.literal_pow`. @@ -259,57 +261,22 @@ end normal end -function compute_stress_tensors!(system, surface_tension, v, u, v_ode, u_ode, semi, t) - return system -end - -# Section 6 in Morris 2000 "Simulating surface tension with smoothed particle hydrodynamics" -function compute_stress_tensors!(system::AbstractFluidSystem, - ::SurfaceTensionMomentumMorris, - v, u, v_ode, u_ode, semi, t) - (; cache) = system - (; delta_s, stress_tensor) = cache - - # Reset surface stress_tensor - set_zero!(stress_tensor) - - max_delta_s = maximum(delta_s) - NDIMS = ndims(system) - - @trixi_timeit timer() "compute surface stress tensor" begin - @threaded semi for particle in each_integrated_particle(system) - normal = surface_normal(system, particle) - delta_s_particle = delta_s[particle] - if delta_s_particle > eps() - for i in 1:NDIMS, j in 1:NDIMS - delta_ij = (i == j) ? 1 : 0 - stress_tensor[i, j, - particle] = delta_s_particle * - (delta_ij - normal[i] * normal[j]) - - delta_ij * max_delta_s - end - end - end - end - - return system -end +@inline function surface_stress_times_gradient(particle_system, particle, grad_kernel) + delta_s = @inbounds particle_system.cache.delta_s[particle] + iszero(delta_s) && return zero(grad_kernel) -function compute_surface_delta_function!(system, surface_tension, semi) - return system + normal = surface_normal(particle_system, particle) + return delta_s * (grad_kernel - normal * dot(normal, grad_kernel)) end -# Eq. 6 in Morris 2000 "Simulating surface tension with smoothed particle hydrodynamics" -function compute_surface_delta_function!(system, ::SurfaceTensionMomentumMorris, semi) - (; cache) = system - (; delta_s) = cache - - set_zero!(delta_s) - - @threaded semi for particle in each_integrated_particle(system) - delta_s[particle] = norm(surface_normal(system, particle)) - end - return system +@inline function symmetric_surface_divergence_correction(particle_system, + neighbor_system, + particle, neighbor) + correction_a = @inbounds particle_system.cache.divergence_correction[particle] + correction_b = @inbounds neighbor_system.cache.divergence_correction[neighbor] + denominator = correction_a + correction_b + denominator > eps(denominator) || return zero(denominator) + return 2 / denominator end @inline function surface_tension_force!(dv_particle, @@ -325,13 +292,19 @@ end # No surface tension with oneself. See `src/general/smoothing_kernels.jl` for more details. distance^2 < eps(initial_smoothing_length(particle_system)^2) && return dv_particle - S_a = stress_tensor(particle_system, particle) - S_b = stress_tensor(neighbor_system, neighbor) - m_b = hydrodynamic_mass(neighbor_system, neighbor) - - dv_particle[] += surface_tension_correction * surface_tension_coefficient * m_b * - (S_a + S_b) / (rho_a * rho_b) * grad_kernel + stress_gradient_a = surface_stress_times_gradient(particle_system, particle, + grad_kernel) + stress_gradient_b = surface_stress_times_gradient(neighbor_system, neighbor, + grad_kernel) + divergence_correction = symmetric_surface_divergence_correction(particle_system, + neighbor_system, + particle, neighbor) + + # This uses the same symmetric stress-divergence operator as the pressure force. The + # Akinci free-surface correction is deliberately not applied to a continuum stress. + dv_particle[] += divergence_correction * surface_tension_coefficient * m_b / + (rho_a * rho_b) * (stress_gradient_a + stress_gradient_b) return dv_particle end diff --git a/src/schemes/fluid/weakly_compressible_sph/system.jl b/src/schemes/fluid/weakly_compressible_sph/system.jl index bc3a09f81b..4e6b1c3936 100644 --- a/src/schemes/fluid/weakly_compressible_sph/system.jl +++ b/src/schemes/fluid/weakly_compressible_sph/system.jl @@ -322,7 +322,7 @@ end end function update_pressure!(system::WeaklyCompressibleSPHSystem, v, u, v_ode, u_ode, semi, t) - (; density_calculator, correction, surface_normal_method, surface_tension) = system + (; density_calculator, correction, surface_normal_method) = system compute_pressure!(system, v, semi) @@ -335,7 +335,6 @@ function update_pressure!(system::WeaklyCompressibleSPHSystem, v, u, v_ode, u_od # 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) return system end @@ -345,7 +344,6 @@ function update_final!(system::WeaklyCompressibleSPHSystem, v, u, v_ode, u_ode, # Surface normal of neighbor and boundary needs to have been calculated already compute_curvature!(system, surface_tension, v, u, v_ode, u_ode, semi, t) - compute_stress_tensors!(system, surface_tension, v, u, v_ode, u_ode, semi, t) update_shifting!(system, shifting_technique(system), v, u, v_ode, u_ode, semi) end diff --git a/test/schemes/fluid/surface_normal_sph.jl b/test/schemes/fluid/surface_normal_sph.jl index ce7b2154c3..316672416e 100644 --- a/test/schemes/fluid/surface_normal_sph.jl +++ b/test/schemes/fluid/surface_normal_sph.jl @@ -228,6 +228,66 @@ end @test isapprox(weighted_curvature, 2 / radius; rtol=0.15) end +@testset "CSS flat-pool geometry" begin + particle_spacing = 0.1 + reference_density = 1000.0 + smoothing_kernel = WendlandC2Kernel{2}() + smoothing_length = 1.4particle_spacing + state_equation = StateEquationCole(; sound_speed=10.0, reference_density, + exponent=1) + fluid = RectangularShape(particle_spacing, (9, 6), (0.0, 0.0); + density=reference_density) + normal_method = ColorfieldSurfaceNormal(; boundary_contact_threshold=0.1, + interface_threshold=0.01, + ideal_density_threshold=0.9) + surface_tension = SurfaceTensionMomentumMorris(; surface_tension_coefficient=0.072) + fluid_system = WeaklyCompressibleSPHSystem(fluid; smoothing_kernel, + smoothing_length, + density_calculator=ContinuityDensity(), + state_equation, surface_tension, + surface_normal_method=normal_method, + reference_particle_spacing=particle_spacing) + + # The top wall row continues the fluid lattice one spacing below the bottom fluid row. + wall = RectangularShape(particle_spacing, (9, 3), (0.0, -0.3); + density=reference_density) + boundary_model = BoundaryModelDummyParticles(wall; fluid_system, + boundary_density_calculator=AdamiPressureExtrapolation()) + boundary_system = WallBoundarySystem(wall, boundary_model) + semi = Semidiscretization(fluid_system, boundary_system) + ode = semidiscretize(semi, (0.0, 0.01)) + v_ode, u_ode = ode.u0.x + TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, 0.0) + + coordinates = fluid.coordinates + particle_at(position) = findfirst(particle -> coordinates[:, particle] == position, + axes(coordinates, 2)) + bottom_center = particle_at([0.45, 0.05]) + interior_center = particle_at([0.45, 0.25]) + top_center = particle_at([0.45, 0.55]) + centerline_particles = [bottom_center, interior_center, top_center] + + acceleration = GC.@preserve v_ode u_ode begin + v = TrixiParticles.wrap_v(v_ode, fluid_system, semi) + u = TrixiParticles.wrap_u(u_ode, fluid_system, semi) + v_boundary = TrixiParticles.wrap_v(v_ode, boundary_system, semi) + u_boundary = TrixiParticles.wrap_u(u_ode, boundary_system, semi) + dv = zeros(eltype(v), size(v)) + TrixiParticles.interact!(dv, v, u, v, u, fluid_system, fluid_system, semi) + TrixiParticles.interact!(dv, v, u, v_boundary, u_boundary, fluid_system, + boundary_system, semi) + Array(dv[1:2, :]) + end + + # Wall particles complete the support moment without carrying capillary stress. + @test fluid_system.cache.divergence_correction[bottom_center] >= 0.9 + @test fluid_system.cache.interface_activity[bottom_center] == 0 + @test fluid_system.cache.delta_s[bottom_center] == 0 + @test fluid_system.cache.delta_s[top_center] > 0 + @test iszero(fluid_system.cache.delta_s[interior_center]) + @test maximum(abs, acceleration[:, centerline_particles]) < 1.0e-12 +end + @testset verbose=true "Rigid Dummy Boundary Matches Wall Boundary" begin NDIMS = 2 particle_spacing = 0.2 diff --git a/test/schemes/fluid/surface_tension.jl b/test/schemes/fluid/surface_tension.jl index 089340f0f6..fc5b6f4243 100644 --- a/test/schemes/fluid/surface_tension.jl +++ b/test/schemes/fluid/surface_tension.jl @@ -311,96 +311,174 @@ @test system.cache.correction_factor ≈ 2correction_factor end - @testset "compute_stress_tensors! (MomentumMorris)" begin - # 1. Define Minimal Initial Condition with 2 Particles in 2D - coords = [0.0 1.0; - 0.0 0.0] - velocity = zeros(2, 2) - mass = ones(2) - density = ones(2) - - ic = InitialCondition(; coordinates=coords, velocity, mass, density, - particle_spacing=1.0) - - # 2. Define Density Calculator, State Equation, and Kernel - density_calc = SummationDensity() - eq_state = StateEquationCole(sound_speed=10.0, - reference_density=1.0, - exponent=1) - kernel = WendlandC2Kernel{2}() - smoothing_length = 0.5 - - # 3. Create the WeaklyCompressibleSPHSystem with Surface Tension - system = WeaklyCompressibleSPHSystem(ic; smoothing_kernel=kernel, - smoothing_length, - density_calculator=density_calc, - state_equation=eq_state, - surface_tension=SurfaceTensionMomentumMorris(surface_tension_coefficient=1.0), - surface_normal_method=ColorfieldSurfaceNormal(interface_threshold=0.1, - ideal_density_threshold=0.9), - reference_particle_spacing=1.0,) - - # 4. Verify Cache Contains Necessary Fields + @testset "balanced continuum surface stress" begin + initial_condition = InitialCondition(; coordinates=[0.0 0.75; 0.0 0.0], + velocity=zeros(2, 2), mass=[2.0, 3.0], + density=ones(2), particle_spacing=0.5) + surface_tension = SurfaceTensionMomentumMorris(; + surface_tension_coefficient=0.7) + normal_method = ColorfieldSurfaceNormal(; interface_threshold=0.1) + system = WeaklyCompressibleSPHSystem(initial_condition; + smoothing_kernel=WendlandC2Kernel{2}(), + smoothing_length=0.5, + density_calculator=SummationDensity(), + state_equation=StateEquationCole(; + sound_speed=10.0, + reference_density=1.0, + exponent=1), + surface_tension, + surface_normal_method=normal_method, + reference_particle_spacing=0.5) + @test haskey(system.cache, :delta_s) + @test haskey(system.cache, :interface_activity) + @test haskey(system.cache, :divergence_correction) @test haskey(system.cache, :surface_normal) - @test haskey(system.cache, :stress_tensor) - - # 5. Manually Populate `delta_s` and `surface_normal` - system.cache.delta_s .= [1.0, 2.0] - system.cache.surface_normal .= hcat([1.0, 0.0], [1 / sqrt(2), 1 / sqrt(2)]) - system.cache.stress_tensor .= zeros(2, 2, 2) # Reset to zero before computation - - # 6. Call `compute_stress_tensors!` with `SurfaceTensionMomentumMorris` - TrixiParticles.compute_stress_tensors!(system, - SurfaceTensionMomentumMorris(), - nothing, nothing, # v, u (not needed for stress computation) - nothing, nothing, # v_ode, u_ode (not needed) - SerialBackend(), # semi (only passed to `@threaded`) - 0.0) - - # 7. Define Reference Stress Tensors by Hand - # - # Reference calculations based on the formula: - # σ_ij(a) = δs_a (δ_ij - n_i n_j) - δ_ij max(δs) - # - # For Particle 1: - # δs = 1.0 - # n = (1.0, 0.0) - # max(δs) = 2.0 - # σ_11 = 1*(1 - 1^2) - 1*2 = -2 - # σ_12 = 1*(0 - 1*0) - 0*2 = 0 - # σ_21 = 1*(0 - 1*0) - 0*2 = 0 - # σ_22 = 1*(1 - 0^2) - 1*2 = 1 - 2 = -1 - # - # Resulting Stress Tensor for Particle 1: - # [-2.0 0.0 - # 0.0 -1.0] - # - # For Particle 2: - # δs = 2.0 - # n = (1/√2, 1/√2) - # max(δs) = 2.0 - # σ_11 = 2*(1 - (1/√2)^2) - 1*2 = 2*(1 - 0.5) - 2 = 1 - 2 = -1 - # σ_12 = 2*(0 - (1/√2)^2) - 0*2 = 2*(0 - 0.5) = -1 - # σ_21 = 2*(0 - (1/√2)^2) - 0*2 = -1 - # σ_22 = 2*(1 - (1/√2)^2) - 1*2 = 2*(1 - 0.5) - 2 = 1 - 2 = -1 - # - # Resulting Stress Tensor for Particle 2: - # [-1.0 -1.0 - # -1.0 -1.0] - - ref_particle_1 = [-2.0 0.0; - 0.0 -1.0] - ref_particle_2 = [-1.0 -1.0; - -1.0 -1.0] - - # 8. Retrieve Computed Stress Tensor - computed = system.cache.stress_tensor - - # 9. Perform Assertions - @test all(isfinite, computed) - - @test isapprox(computed[:, :, 1], ref_particle_1; atol=1e-14) - @test isapprox(computed[:, :, 2], ref_particle_2; atol=1e-14) + @test !haskey(system.cache, :stress_tensor) + + # Capture the one-phase surface delta before normalizing the color gradient. + system.cache.surface_normal .= [2.0 1.0; 0.0 1.0] + system.cache.divergence_correction .= 0 + TrixiParticles.remove_invalid_normals!(system, surface_tension, normal_method) + @test system.cache.delta_s ≈ [4.0, 2sqrt(2)] + @test system.cache.interface_activity == [1.0, 1.0] + @test system.cache.surface_normal[:, 1] ≈ [1.0, 0.0] + @test system.cache.surface_normal[:, 2] ≈ [1 / sqrt(2), 1 / sqrt(2)] + + grad_kernel = SVector(0.3, -0.4) + stress_gradient_1 = 4.0 .* (grad_kernel - SVector(1.0, 0.0) * 0.3) + normal_2 = SVector(1 / sqrt(2), 1 / sqrt(2)) + stress_gradient_2 = 2sqrt(2) .* (grad_kernel - + normal_2 * dot(normal_2, grad_kernel)) + @test TrixiParticles.surface_stress_times_gradient(system, 1, grad_kernel) ≈ + stress_gradient_1 + @test TrixiParticles.surface_stress_times_gradient(system, 2, grad_kernel) ≈ + stress_gradient_2 + + rho_a = 2.0 + rho_b = 3.0 + system.cache.divergence_correction .= [0.5, 1.0] + divergence_correction = 2 / (0.5 + 1.0) + pos_diff = SVector(-0.75, 0.0) + distance = norm(pos_diff) + dv_a = Ref(zero(pos_diff)) + TrixiParticles.surface_tension_force!(dv_a, surface_tension, surface_tension, + system, system, 1, 2, pos_diff, distance, + rho_a, rho_b, grad_kernel, 4.0) + expected = 3divergence_correction * surface_tension.surface_tension_coefficient / + (rho_a * rho_b) * (stress_gradient_1 + stress_gradient_2) + @test dv_a[] ≈ expected + + # The symmetric stress divergence conserves pairwise momentum and deliberately + # ignores the Akinci-specific correction factor passed above. + dv_b = Ref(zero(pos_diff)) + TrixiParticles.surface_tension_force!(dv_b, surface_tension, surface_tension, + system, system, 2, 1, -pos_diff, distance, + rho_b, rho_a, -grad_kernel, 4.0) + @test 2dv_a[] ≈ -3dv_b[] + + semi = Semidiscretization(system) + ode = semidiscretize(semi, (0.0, 0.01)) + v_ode, u_ode = ode.u0.x + vtk = Dict{String, Any}() + GC.@preserve v_ode u_ode begin + v = TrixiParticles.wrap_v(v_ode, system, semi) + u = TrixiParticles.wrap_u(u_ode, system, semi) + TrixiParticles.write2vtk!(vtk, v, u, 0.0, system) + end + @test vtk["surface_divergence_correction"] == [0.5, 1.0] + @test size(vtk["surface_stress_tensor"]) == (2, 2, 2) + @test vtk["surface_stress_tensor"][:, :, 1] ≈ [0.0 0.0; 0.0 4.0] + @test all(isfinite, vtk["surface_stress_tensor"]) + + system.cache.divergence_correction .= 0 + unsupported_force = Ref(zero(pos_diff)) + TrixiParticles.surface_tension_force!(unsupported_force, surface_tension, + surface_tension, system, system, 1, 2, + pos_diff, distance, rho_a, rho_b, + grad_kernel, 1.0) + @test iszero(unsupported_force[]) + + filtered_method = ColorfieldSurfaceNormal(; interface_threshold=0.1, + ideal_density_threshold=0.9, + support_taper_width=0.05) + system.cache.surface_normal .= 0 + system.cache.surface_normal[1, 1] = 0.2 + system.cache.divergence_correction .= [0.925, 1.0] + TrixiParticles.remove_invalid_normals!(system, surface_tension, filtered_method) + @test system.cache.interface_activity[1] ≈ 0.5 + @test system.cache.delta_s[1] ≈ 0.2 + @test system.cache.surface_normal[:, 1] == [1.0, 0.0] + end + + @testset "CSS static Laplace balance" begin + reference_density = 1000.0 + target_particles = 375 + drop_volume = 1.0e-6 + particle_spacing = cbrt(drop_volume / target_particles) + radius = cbrt(3drop_volume / (4pi)) + initial_condition = SphereShape(particle_spacing, radius + particle_spacing / 2, + (0.0, 0.0, 0.0), reference_density; + sphere_type=VoxelSphere()) + smoothing_kernel = WendlandC2Kernel{3}() + smoothing_length = 1.4particle_spacing + + function initial_acceleration(system) + semi = Semidiscretization(system) + ode = semidiscretize(semi, (0.0, 0.01)) + v_ode, u_ode = ode.u0.x + TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, 0.0) + return GC.@preserve v_ode u_ode begin + v = TrixiParticles.wrap_v(v_ode, system, semi) + u = TrixiParticles.wrap_u(u_ode, system, semi) + dv = zeros(eltype(v), size(v)) + TrixiParticles.interact!(dv, v, u, v, u, system, system, semi) + Array(dv[1:3, :]) + end + end + + coefficient = 1.0 + css = SurfaceTensionMomentumMorris(; surface_tension_coefficient=coefficient) + css_system = WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel, + smoothing_length, + density_calculator=ContinuityDensity(), + state_equation=StateEquationCole(; + sound_speed=100.0, + reference_density, + exponent=1), + surface_tension=css, + surface_normal_method=ColorfieldSurfaceNormal(; + boundary_contact_threshold=Inf, + interface_threshold=0.01, + ideal_density_threshold=0.95), + reference_particle_spacing=particle_spacing) + css_acceleration = initial_acceleration(css_system) + + pressure_basis = 1.0 + sound_speed = 100.0 + pressure_reference_density = reference_density - pressure_basis / sound_speed^2 + pressure_system = WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel, + smoothing_length, + density_calculator=ContinuityDensity(), + state_equation=StateEquationCole(; + sound_speed, + reference_density=pressure_reference_density, + exponent=1)) + pressure_acceleration = initial_acceleration(pressure_system) / pressure_basis + + interface = findall(>(0), css_system.cache.delta_s) + capillary = vec(css_acceleration[:, interface]) + unit_pressure = vec(pressure_acceleration[:, interface]) + pressure_jump = -dot(capillary, unit_pressure) / dot(unit_pressure, unit_pressure) + volume = sum(css_system.mass) / reference_density + equivalent_radius = cbrt(3volume / (4pi)) + inferred_surface_tension = pressure_jump * equivalent_radius / 2 + total_force = vec(sum(css_acceleration .* reshape(css_system.mass, 1, :); + dims=2)) + + @test inferred_surface_tension ≈ coefficient rtol = 0.05 + @test norm(total_force) < 1.0e-12 + @test all(isfinite, css_system.cache.divergence_correction) + @test minimum(css_system.cache.divergence_correction) > 0 end end From d7c32a3eb819f7bece13b894fc53b7fcfb738cc8 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Sun, 9 Aug 2026 14:54:30 +0200 Subject: [PATCH 56/59] Add normal smoothing and VTK diagnostics --- NEWS.md | 3 + docs/src/systems/fluid.md | 26 +++++++ src/io/io.jl | 1 + src/io/write_vtk.jl | 50 +++++++----- src/schemes/fluid/surface_normal_sph.jl | 99 ++++++++++++++++++++++-- src/schemes/fluid/surface_tension.jl | 4 +- test/schemes/fluid/surface_normal_sph.jl | 99 ++++++++++++++++++++++++ test/schemes/fluid/surface_tension.jl | 50 +++++++++++- 8 files changed, 301 insertions(+), 31 deletions(-) diff --git a/NEWS.md b/NEWS.md index d1dcf76719..b75ca32eb6 100644 --- a/NEWS.md +++ b/NEWS.md @@ -21,6 +21,9 @@ used in the Julia ecosystem. Notable changes will be documented in this file for - Reworked `SurfaceTensionMomentumMorris` as a balanced continuum-surface-stress operator with a symmetric support correction. It conserves pairwise linear momentum without a cached stress tensor or global reduction. +- Added opt-in activity-weighted Shepard normal smoothing for Morris CSF/CSS and expanded VTK + output with raw and capillary normals, surface delta, activity, support, force, and reconstructed + stress diagnostics. - Added C1 interface activation for Morris CSF and CSS with `ColorfieldSurfaceNormal`. Color-gradient and continuous support-moment indicators taper the physical surface delta without another neighbor pass. diff --git a/docs/src/systems/fluid.md b/docs/src/systems/fluid.md index f0d00a1500..43432e6e5f 100644 --- a/docs/src/systems/fluid.md +++ b/docs/src/systems/fluid.md @@ -421,6 +421,26 @@ support instead of an integer neighbor-count fraction. Dummy boundary particles ``q_a`` near walls without carrying capillary stress. These controls do not alter the separate C-CSF geometry described below. +#### Optional normal smoothing + +`ColorfieldSurfaceNormal(normal_smoothing=true)` applies one activity-weighted Shepard pass to +the unit-normal directions. For active neighbors, + +```math +\widetilde{\bm n}_a = +\frac{\sum_b \lambda_b(m_b/\rho_b)W_{ab}\hat{\bm n}_b} + {\sum_b \lambda_b(m_b/\rho_b)W_{ab}}, +\qquad +\hat{\bm n}^{\sigma}_a = +\frac{\widetilde{\bm n}_a}{\Vert\widetilde{\bm n}_a\Vert}. +``` + +The raw normal is used as a finite fallback when the weighted direction is undefined. Smoothing +changes only the normal used by Morris curvature and force evaluation and by CSS stress +evaluation. The raw colorfield normal, interface activity, and surface delta remain unchanged, +so geometry diagnostics and particle regularization are not silently modified. The option is +disabled by default and therefore adds no cache or traversal unless requested. + [`CorrectedCSFSurfaceNormal`](@ref) selects the corrected continuous-surface-force (C-CSF) interface geometry of [Vergnaud et al.](@cite Vergnaud2022) for [`SurfaceTensionMorris`](@ref). It computes the outward normal from the renormalized gradient of the minimum eigenvalue of the @@ -500,6 +520,12 @@ For constant smoothing length, every coefficient multiplying a particle pair is Dummy boundary particles complete ``q_a`` near walls but do not carry capillary stress. The Akinci free-surface correction is deliberately not applied to this continuum stress. +VTK output exposes both `surf_normal`, the raw geometry normal, and `surface_tension_normal`, the +normal actually used by the capillary operator. It also includes `surface_delta`, +`interface_activity`, and `surface_tension`. Morris CSF adds `curvature` and +`surface_support_moment`; CSS adds `surface_divergence_correction` and the physical +`surface_stress_tensor`, reconstructed only while writing output. + ### API ```@autodocs diff --git a/src/io/io.jl b/src/io/io.jl index 188f5f617a..d386856108 100644 --- a/src/io/io.jl +++ b/src/io/io.jl @@ -323,6 +323,7 @@ function add_system_data!(system_data, surface_normal_method::ColorfieldSurfaceN system_data["surface_normal_method"]["ideal_density_threshold"] = surface_normal_method.ideal_density_threshold system_data["surface_normal_method"]["interface_taper_start"] = surface_normal_method.interface_taper_start system_data["surface_normal_method"]["support_taper_width"] = surface_normal_method.support_taper_width + system_data["surface_normal_method"]["normal_smoothing"] = surface_normal_method.normal_smoothing end function add_system_data!(system_data, surface_normal_method::CorrectedCSFSurfaceNormal) diff --git a/src/io/write_vtk.jl b/src/io/write_vtk.jl index 273fe9a3ae..5e247f9f22 100644 --- a/src/io/write_vtk.jl +++ b/src/io/write_vtk.jl @@ -339,36 +339,48 @@ function write2vtk!(vtk, v, u, t, system::AbstractFluidSystem) if system.surface_tension isa SurfaceTensionMorris || system.surface_tension isa SurfaceTensionMomentumMorris surface_tension = zeros((ndims(system), n_integrated_particles(system))) - system_coords = current_coordinates(u, system) - surface_tension_a = surface_tension_model(system) - surface_tension_b = surface_tension_model(system) - nhs = create_neighborhood_search(nothing, system, system) - - foreach_point_neighbor(system_coords, system_coords, - nhs) do particle, neighbor, pos_diff, distance - rho_a = current_density(v, system, particle) - rho_b = current_density(v, system, neighbor) - grad_kernel = smoothing_kernel_grad(system, pos_diff, distance, particle) - - dv_surface_tension = Ref(zero(pos_diff)) - surface_tension_force!(dv_surface_tension, - surface_tension_a, surface_tension_b, - system, system, particle, neighbor, - pos_diff, distance, rho_a, rho_b, grad_kernel, 1) - - surface_tension[1:ndims(system), particle] .+= dv_surface_tension[] + if surface_tension_a isa SurfaceTensionMorris + for particle in each_integrated_particle(system) + rho_a = current_density(v, system, particle) + velocity = current_velocity(v, system, particle) + acceleration = surface_tension_acceleration(surface_tension_a, system, + particle, rho_a, velocity) + surface_tension[1:ndims(system), particle] .= acceleration + end + else + system_coords = current_coordinates(u, system) + nhs = create_neighborhood_search(nothing, system, system) + foreach_point_neighbor(system_coords, system_coords, + nhs) do particle, neighbor, pos_diff, distance + rho_a = current_density(v, system, particle) + rho_b = current_density(v, system, neighbor) + grad_kernel = smoothing_kernel_grad(system, pos_diff, distance, particle) + + dv_surface_tension = Ref(zero(pos_diff)) + surface_tension_force!(dv_surface_tension, + surface_tension_a, surface_tension_a, + system, system, particle, neighbor, + pos_diff, distance, rho_a, rho_b, grad_kernel, 1) + + surface_tension[1:ndims(system), particle] .+= dv_surface_tension[] + end end vtk["surface_tension"] = surface_tension + vtk["surface_delta"] = system.cache.delta_s + vtk["interface_activity"] = system.cache.interface_activity + vtk["surface_tension_normal"] = [surface_tension_normal(system, particle) + for particle in eachparticle(system)] if system.surface_tension isa SurfaceTensionMorris vtk["curvature"] = system.cache.curvature + vtk["surface_support_moment"] = system.cache.support_moment end if system.surface_tension isa SurfaceTensionMomentumMorris stress_tensor = zeros(eltype(system), ndims(system), ndims(system), n_integrated_particles(system)) for particle in each_integrated_particle(system) - normal = surface_normal(system, particle) + normal = surface_tension_normal(system, particle) delta_s = system.cache.delta_s[particle] for i in 1:ndims(system), j in 1:ndims(system) stress_tensor[i, j, diff --git a/src/schemes/fluid/surface_normal_sph.jl b/src/schemes/fluid/surface_normal_sph.jl index f0b5cf11da..23868ce4d5 100644 --- a/src/schemes/fluid/surface_normal_sph.jl +++ b/src/schemes/fluid/surface_normal_sph.jl @@ -1,7 +1,7 @@ @doc raw""" ColorfieldSurfaceNormal(; boundary_contact_threshold=0.1, interface_threshold=0.01, - ideal_density_threshold=0.0, interface_taper_start=0.8, - support_taper_width=0.025) + ideal_density_threshold=0.0, interface_taper_start=0.8, + support_taper_width=0.025, normal_smoothing=false) Color field based computation of the interface normals. @@ -16,6 +16,9 @@ Color field based computation of the interface normals. `interface_threshold`. - `support_taper_width=0.025`: Width of the Morris CSF/CSS support-moment transition above `ideal_density_threshold`. +- `normal_smoothing=false`: Apply one activity-weighted Shepard smoothing pass to unit + normals before Morris curvature, force, or CSS stress + evaluation. Raw geometry remains unchanged. """ struct ColorfieldSurfaceNormal{ELTYPE} boundary_contact_threshold::ELTYPE @@ -23,6 +26,7 @@ struct ColorfieldSurfaceNormal{ELTYPE} ideal_density_threshold::ELTYPE interface_taper_start::ELTYPE support_taper_width::ELTYPE + normal_smoothing::Bool end @doc raw""" @@ -55,7 +59,7 @@ end function ColorfieldSurfaceNormal(; boundary_contact_threshold=0.1, interface_threshold=0.01, ideal_density_threshold=0.0, interface_taper_start=0.8, - support_taper_width=0.025) + support_taper_width=0.025, normal_smoothing=false) if !(boundary_contact_threshold isa Real) || isnan(boundary_contact_threshold) || boundary_contact_threshold < 0 throw(ArgumentError("`boundary_contact_threshold` must be non-negative and not NaN")) @@ -76,6 +80,8 @@ function ColorfieldSurfaceNormal(; boundary_contact_threshold=0.1, interface_thr support_taper_width <= 0 throw(ArgumentError("`support_taper_width` must be finite and positive")) end + normal_smoothing isa Bool || + throw(ArgumentError("`normal_smoothing` must be `true` or `false`")) thresholds = promote(boundary_contact_threshold, interface_threshold, ideal_density_threshold) @@ -87,7 +93,8 @@ function ColorfieldSurfaceNormal(; boundary_contact_threshold=0.1, interface_thr taper_start = convert(ELTYPE, interface_taper_start) taper_width = convert(ELTYPE, support_taper_width) - return ColorfieldSurfaceNormal(thresholds..., taper_start, taper_width) + return ColorfieldSurfaceNormal(thresholds..., taper_start, taper_width, + normal_smoothing) end @inline function cubic_smoothstep(value) @@ -149,12 +156,18 @@ function create_cache_surface_normal(surface_normal_method, ELTYPE, NDIMS, npart return (;) end -function create_cache_surface_normal(::ColorfieldSurfaceNormal, ELTYPE, NDIMS, nparticles) +function create_cache_surface_normal(method::ColorfieldSurfaceNormal, ELTYPE, NDIMS, + nparticles) surface_normal = Array{ELTYPE, 2}(undef, NDIMS, nparticles) neighbor_count = Array{ELTYPE, 1}(undef, nparticles) colorfield = Array{ELTYPE, 1}(undef, nparticles) correction_factor = Array{ELTYPE, 1}(undef, nparticles) - return (; surface_normal, neighbor_count, colorfield, correction_factor) + cache = (; surface_normal, neighbor_count, colorfield, correction_factor) + method.normal_smoothing || return cache + + smoothed_surface_normal = Array{ELTYPE, 2}(undef, NDIMS, nparticles) + normal_smoothing_weight = Array{ELTYPE, 1}(undef, nparticles) + return (; cache..., smoothed_surface_normal, normal_smoothing_weight) end function create_cache_surface_normal(::CorrectedCSFSurfaceNormal, ELTYPE, NDIMS, nparticles) @@ -176,6 +189,22 @@ end return extract_svector(cache.surface_normal, particle_system, particle) end +@inline function surface_tension_normal(particle_system::AbstractFluidSystem, particle) + return surface_tension_normal(surface_normal_method(particle_system), particle_system, + particle) +end + +@inline function surface_tension_normal(surface_normal_method, particle_system, particle) + return surface_normal(particle_system, particle) +end + +@inline function surface_tension_normal(method::ColorfieldSurfaceNormal, particle_system, + particle) + method.normal_smoothing || return surface_normal(particle_system, particle) + return extract_svector(particle_system.cache.smoothed_surface_normal, + particle_system, particle) +end + function calc_normal!(system, neighbor_system, u_system, v, v_neighbor_system, u_neighbor_system, semi, surface_normal_method, neighbor_surface_normal_method) @@ -389,6 +418,59 @@ end return system end +@inline function smooth_surface_normals!(system, surface_normal_method, v, u, semi) + return system +end + +function smooth_surface_normals!(system::AbstractFluidSystem, + surface_normal_method::ColorfieldSurfaceNormal, + v, u, semi) + surface_normal_method.normal_smoothing || return system + cache = system.cache + normal_sum = cache.smoothed_surface_normal + weight_sum = cache.normal_smoothing_weight + coordinates = current_coordinates(u, system) + set_zero!(normal_sum) + set_zero!(weight_sum) + + @trixi_timeit timer() "smooth surface normals" begin + foreach_point_neighbor(system, system, coordinates, coordinates, semi; + points=each_integrated_particle(system)) do particle, + neighbor, + pos_diff, + distance + target_activity = surface_interface_activity(system, particle) + target_activity > zero(target_activity) || return + activity = surface_interface_activity(system, neighbor) + activity > zero(activity) || return + volume = hydrodynamic_mass(system, neighbor) / + current_density(v, system, neighbor) + weight = activity * volume * smoothing_kernel(system, distance, particle) + normal = surface_normal(system, neighbor) + for dimension in 1:ndims(system) + @inbounds normal_sum[dimension, particle] += weight * normal[dimension] + end + @inbounds weight_sum[particle] += weight + end + end + + for particle in each_integrated_particle(system) + surface_interface_activity(system, particle) > zero(eltype(system)) || continue + weight = @inbounds weight_sum[particle] + normal = extract_svector(normal_sum, system, particle) + normal_norm = norm(normal) + raw_normal = surface_normal(system, particle) + use_smoothed_normal = weight > eps(weight) && normal_norm > eps(normal_norm) + for dimension in 1:ndims(system) + @inbounds normal_sum[dimension, + particle] = use_smoothed_normal ? + normal[dimension] / normal_norm : + raw_normal[dimension] + end + end + return system +end + function compute_surface_normal!(system, surface_normal_method, v, u, v_ode, u_ode, semi, t) return system end @@ -413,6 +495,7 @@ function compute_surface_normal!(system::AbstractFluidSystem, surface_normal_method(neighbor_system)) end remove_invalid_normals!(system, surface_tension, surface_normal_method_) + smooth_surface_normals!(system, surface_normal_method_, v, u, semi) return system end @@ -562,8 +645,8 @@ function calc_curvature!(system::AbstractFluidSystem, neighbor_system::AbstractF semi) do particle, neighbor, pos_diff, distance m_b = hydrodynamic_mass(neighbor_system, neighbor) rho_b = current_density(v_neighbor_system, neighbor_system, neighbor) - n_a = surface_normal(system, particle) - n_b = surface_normal(neighbor_system, neighbor) + n_a = surface_tension_normal(system, particle) + n_b = surface_tension_normal(neighbor_system, neighbor) v_b = m_b / rho_b activity_a = surface_interface_activity(system, particle) activity_b = surface_interface_activity(neighbor_system, neighbor) diff --git a/src/schemes/fluid/surface_tension.jl b/src/schemes/fluid/surface_tension.jl index 1a9e89e0ea..74322b0f65 100644 --- a/src/schemes/fluid/surface_tension.jl +++ b/src/schemes/fluid/surface_tension.jl @@ -255,7 +255,7 @@ end delta_s = @inbounds particle_system.cache.delta_s[particle] iszero(delta_s) && return zero(vector_template) - normal = surface_normal(particle_system, particle) + normal = surface_tension_normal(particle_system, particle) curvature_a = curvature(particle_system, particle) return -surface_tension.surface_tension_coefficient / rho_a * curvature_a * delta_s * normal @@ -265,7 +265,7 @@ end delta_s = @inbounds particle_system.cache.delta_s[particle] iszero(delta_s) && return zero(grad_kernel) - normal = surface_normal(particle_system, particle) + normal = surface_tension_normal(particle_system, particle) return delta_s * (grad_kernel - normal * dot(normal, grad_kernel)) end diff --git a/test/schemes/fluid/surface_normal_sph.jl b/test/schemes/fluid/surface_normal_sph.jl index 316672416e..7d152b3a24 100644 --- a/test/schemes/fluid/surface_normal_sph.jl +++ b/test/schemes/fluid/surface_normal_sph.jl @@ -228,6 +228,105 @@ end @test isapprox(weighted_curvature, 2 / radius; rtol=0.15) end +@testset "Shepard-smoothed CSS normals" begin + particle_spacing = 0.1 + reference_density = 1000.0 + fluid = SphereShape(particle_spacing, 0.5, (0.0, 0.0, 0.0), reference_density; + sphere_type=RoundSphere()) + smoothing_kernel = WendlandC2Kernel{3}() + smoothing_length = 1.4particle_spacing + state_equation = StateEquationCole(; sound_speed=10.0, reference_density, + exponent=7) + surface_tension = SurfaceTensionMomentumMorris(; surface_tension_coefficient=1.0) + normal_method = ColorfieldSurfaceNormal(; ideal_density_threshold=0.95, + normal_smoothing=true) + system = WeaklyCompressibleSPHSystem(fluid; smoothing_kernel, smoothing_length, + density_calculator=ContinuityDensity(), + state_equation, surface_tension, + surface_normal_method=normal_method, + reference_particle_spacing=particle_spacing) + semi = Semidiscretization(system) + ode = semidiscretize(semi, (0.0, 0.01)) + v_ode, u_ode = ode.u0.x + TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, 0.0) + + active = findall(>(0), system.cache.interface_activity) + @test !isempty(active) + @test haskey(system.cache, :smoothed_surface_normal) + @test haskey(system.cache, :normal_smoothing_weight) + @test all(isfinite, system.cache.smoothed_surface_normal) + @test all(isfinite, system.cache.normal_smoothing_weight) + @test all(active) do particle + isapprox(norm(TrixiParticles.surface_tension_normal(system, particle)), 1; + atol=1.0e-12) + end + + raw_system = WeaklyCompressibleSPHSystem(fluid; smoothing_kernel, smoothing_length, + density_calculator=ContinuityDensity(), + state_equation, surface_tension, + surface_normal_method=ColorfieldSurfaceNormal(; + ideal_density_threshold=0.95), + reference_particle_spacing=particle_spacing) + raw_semi = Semidiscretization(raw_system) + raw_ode = semidiscretize(raw_semi, (0.0, 0.01)) + TrixiParticles.update_systems_and_nhs(raw_ode.u0.x..., raw_semi, 0.0) + + # Smoothing changes only the capillary direction, not raw geometry or activity. + @test !haskey(raw_system.cache, :smoothed_surface_normal) + @test !haskey(raw_system.cache, :normal_smoothing_weight) + @test system.cache.surface_normal ≈ raw_system.cache.surface_normal + @test system.cache.interface_activity ≈ raw_system.cache.interface_activity + @test system.cache.delta_s ≈ raw_system.cache.delta_s + differences = [norm(TrixiParticles.surface_tension_normal(system, particle) - + TrixiParticles.surface_normal(system, particle)) + for particle in active] + candidate = active[argmax(differences)] + @test maximum(differences) > 1.0e-4 + + inactive = setdiff(eachparticle(system), active) + @test all(particle -> iszero(TrixiParticles.surface_tension_normal(system, particle)), + inactive) + + grad_kernel = SVector(0.3, -0.4, 0.2) + normal = TrixiParticles.surface_tension_normal(system, candidate) + delta_s = system.cache.delta_s[candidate] + expected_stress_gradient = delta_s * + (grad_kernel - normal * dot(normal, grad_kernel)) + @test TrixiParticles.surface_stress_times_gradient(system, candidate, grad_kernel) ≈ + expected_stress_gradient + + vtk = Dict{String, Any}() + GC.@preserve v_ode u_ode begin + v = TrixiParticles.wrap_v(v_ode, system, semi) + u = TrixiParticles.wrap_u(u_ode, system, semi) + TrixiParticles.write2vtk!(vtk, v, u, 0.0, system) + end + expected_stress = delta_s * + (Matrix{Float64}(I, 3, 3) - normal * transpose(normal)) + @test vtk["surf_normal"][candidate] ≈ TrixiParticles.surface_normal(system, candidate) + @test vtk["surface_tension_normal"][candidate] ≈ normal + @test vtk["surface_stress_tensor"][:, :, candidate] ≈ expected_stress + + system.cache.surface_normal .= reshape([1.0, 0.0, 0.0], 3, 1) + system.cache.interface_activity .= 1 + GC.@preserve v_ode u_ode begin + v = TrixiParticles.wrap_v(v_ode, system, semi) + u = TrixiParticles.wrap_u(u_ode, system, semi) + TrixiParticles.smooth_surface_normals!(system, normal_method, v, u, semi) + end + @test all(particle -> TrixiParticles.surface_tension_normal(system, particle) == + SVector(1.0, 0.0, 0.0), eachparticle(system)) + + system.cache.surface_normal .= 0 + GC.@preserve v_ode u_ode begin + v = TrixiParticles.wrap_v(v_ode, system, semi) + u = TrixiParticles.wrap_u(u_ode, system, semi) + TrixiParticles.smooth_surface_normals!(system, normal_method, v, u, semi) + end + @test all(iszero, system.cache.smoothed_surface_normal) + @test all(isfinite, system.cache.smoothed_surface_normal) +end + @testset "CSS flat-pool geometry" begin particle_spacing = 0.1 reference_density = 1000.0 diff --git a/test/schemes/fluid/surface_tension.jl b/test/schemes/fluid/surface_tension.jl index fc5b6f4243..90c7d51042 100644 --- a/test/schemes/fluid/surface_tension.jl +++ b/test/schemes/fluid/surface_tension.jl @@ -9,6 +9,7 @@ @test method isa ColorfieldSurfaceNormal{Float64} @test method.interface_taper_start === 0.8 @test method.support_taper_width === 0.05 + @test !method.normal_smoothing @test ColorfieldSurfaceNormal(1, 1, 0) isa ColorfieldSurfaceNormal{Float64} @test ColorfieldSurfaceNormal(; boundary_contact_threshold=0.1f0, interface_threshold=0.01f0, @@ -68,12 +69,17 @@ @test_throws ArgumentError ColorfieldSurfaceNormal(; support_taper_width=taper_width) end + for normal_smoothing in (0, 1, nothing) + @test_throws ArgumentError ColorfieldSurfaceNormal(; normal_smoothing) + end + @test ColorfieldSurfaceNormal(; normal_smoothing=true).normal_smoothing system_data = Dict{String, Any}() TrixiParticles.add_system_data!(system_data, method) @test system_data["surface_normal_method"]["interface_threshold"] ≈ 0.1 @test system_data["surface_normal_method"]["interface_taper_start"] === 0.8 @test system_data["surface_normal_method"]["support_taper_width"] === 0.05 + @test system_data["surface_normal_method"]["normal_smoothing"] === false end @testset verbose=true "`cohesion_force_akinci`" begin @@ -167,7 +173,7 @@ end @testset "Morris CSF local force" begin - function build_morris_system(solver, particle_count) + function build_morris_system(solver, particle_count; normal_smoothing=false) coordinates = zeros(2, particle_count) coordinates[1, :] .= range(0.0; step=0.25, length=particle_count) initial_condition = InitialCondition(; coordinates, @@ -177,7 +183,8 @@ particle_spacing=0.25) smoothing_kernel = WendlandC2Kernel{2}() surface_tension = SurfaceTensionMorris(; surface_tension_coefficient=0.7) - normal_method = ColorfieldSurfaceNormal(; interface_threshold=0.1) + normal_method = ColorfieldSurfaceNormal(; interface_threshold=0.1, + normal_smoothing) if solver == :wcsph return WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel, smoothing_length=0.5, @@ -261,6 +268,19 @@ system, 1, 1.0, SVector(0.0, 0.0)) @test acceleration ≈ SVector(-4.2, 0.0) + + smoothed_system = build_morris_system(:wcsph, 2; normal_smoothing=true) + smoothed_system.cache.surface_normal .= [1.0 1.0; 0.0 0.0] + smoothed_system.cache.smoothed_surface_normal .= [0.0 0.0; 1.0 1.0] + smoothed_system.cache.curvature .= 3.0 + smoothed_system.cache.delta_s .= 2.0 + smoothed_acceleration = TrixiParticles.surface_tension_acceleration(smoothed_system.surface_tension, + smoothed_system, + 1, 1.0, + SVector(0.0, + 0.0)) + @test smoothed_acceleration ≈ SVector(0.0, -4.2) + @test TrixiParticles.surface_normal(smoothed_system, 1) == SVector(1.0, 0.0) system.cache.curvature[1] /= 2 system.cache.delta_s[1] /= 2 scaled_acceleration = TrixiParticles.surface_tension_acceleration(system.surface_tension, @@ -273,6 +293,28 @@ ode = semidiscretize(semi, (0.0, 0.01)) v_ode, u_ode = ode.u0.x TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, 0.0) + system.cache.surface_normal[1, :] .= 1.0 + system.cache.surface_normal[2, :] .= 0.0 + system.cache.curvature .= 3.0 + system.cache.delta_s .= 2.0 + system.cache.interface_activity .= 1.0 + vtk = Dict{String, Any}() + expected_vtk_acceleration = GC.@preserve v_ode u_ode begin + v = TrixiParticles.wrap_v(v_ode, system, semi) + u = TrixiParticles.wrap_u(u_ode, system, semi) + rho_a = TrixiParticles.current_density(v, system, 1) + velocity = TrixiParticles.current_velocity(v, system, 1) + expected = TrixiParticles.surface_tension_acceleration(system.surface_tension, + system, 1, rho_a, + velocity) + TrixiParticles.write2vtk!(vtk, v, u, 0.0, system) + expected + end + @test vtk["surface_tension"][:, 1] ≈ expected_vtk_acceleration + @test vtk["surface_delta"] == system.cache.delta_s + @test vtk["interface_activity"] == system.cache.interface_activity + @test vtk["surface_support_moment"] == system.cache.support_moment + @test vtk["surface_tension_normal"][1] == SVector(1.0, 0.0) system.cache.surface_normal .= [1.0 0.0; 0.0 1.0] function curvature_with_neighbor_activity(activity) @@ -386,6 +428,10 @@ u = TrixiParticles.wrap_u(u_ode, system, semi) TrixiParticles.write2vtk!(vtk, v, u, 0.0, system) end + @test vtk["surface_delta"] == system.cache.delta_s + @test vtk["interface_activity"] == system.cache.interface_activity + @test vtk["surface_tension_normal"] == [TrixiParticles.surface_normal(system, 1), + TrixiParticles.surface_normal(system, 2)] @test vtk["surface_divergence_correction"] == [0.5, 1.0] @test size(vtk["surface_stress_tensor"]) == (2, 2, 2) @test vtk["surface_stress_tensor"][:, :, 1] ≈ [0.0 0.0; 0.0 4.0] From c66b01904285086cb011122d9d82fde3e6d621ce Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Sun, 9 Aug 2026 15:41:57 +0200 Subject: [PATCH 57/59] Add free-surface tangential shifting --- NEWS.md | 2 + docs/src/systems/weakly_compressible_sph.md | 26 +++ src/TrixiParticles.jl | 1 + src/io/io.jl | 4 + .../fluid/entropically_damped_sph/system.jl | 2 + src/schemes/fluid/shifting_techniques.jl | 128 +++++++++-- src/schemes/fluid/surface_normal_sph.jl | 11 + .../fluid/weakly_compressible_sph/system.jl | 2 + test/schemes/fluid/shifting_techniques.jl | 200 ++++++++++++++++++ 9 files changed, 354 insertions(+), 22 deletions(-) diff --git a/NEWS.md b/NEWS.md index b75ca32eb6..51b9b6c930 100644 --- a/NEWS.md +++ b/NEWS.md @@ -24,6 +24,8 @@ used in the Julia ecosystem. Notable changes will be documented in this file for - Added opt-in activity-weighted Shepard normal smoothing for Morris CSF/CSS and expanded VTK output with raw and capillary normals, surface delta, activity, support, force, and reconstructed stress diagnostics. +- Added `FreeSurfaceTangentialShifting`, an opt-in treatment that smoothly removes the + interface-normal component of Sun particle shifting while retaining full interior shifting. - Added C1 interface activation for Morris CSF and CSS with `ColorfieldSurfaceNormal`. Color-gradient and continuous support-moment indicators taper the physical surface delta without another neighbor pass. diff --git a/docs/src/systems/weakly_compressible_sph.md b/docs/src/systems/weakly_compressible_sph.md index 86edf2590c..5fca29fdb3 100644 --- a/docs/src/systems/weakly_compressible_sph.md +++ b/docs/src/systems/weakly_compressible_sph.md @@ -173,6 +173,32 @@ of PST is commonly referred to as ``\delta^+``-SPH. To apply particle shifting, use the keyword argument `shifting_technique` in the constructor of a system that supports it. +The default particle-shifting configuration remains restricted to closed systems. Morris CSF +and CSS simulations can explicitly opt into tangential free-surface shifting by reusing their +smooth interface activity and raw normal: + +```julia +surface_tension = SurfaceTensionMomentumMorris(surface_tension_coefficient=0.072) +surface_normal_method = ColorfieldSurfaceNormal() +shifting_technique = ConsistentShiftingSun2019(; + free_surface_treatment=FreeSurfaceTangentialShifting()) +``` + +For interface activity ``\lambda_a`` and raw normal ``\bm n_a``, the corrected shifting velocity +is + +```math +\delta\bm v_a^{\mathrm{fs}} = \delta\bm v_a +- \lambda_a\frac{\delta\bm v_a\mathbin{\cdot}\bm n_a} + {\bm n_a\mathbin{\cdot}\bm n_a}\bm n_a. +``` + +Full shifting is retained in the interior. Across the smooth interface transition, the normal +component is progressively removed until shifting is tangential at the represented surface. The +raw geometry normal is used even when capillary normal smoothing is enabled. This treatment also +supports free-surface C-CSF geometry with `SurfaceTensionMorris` and both callback-based Sun (2017) +and consistent Sun (2019) shifting, but it is not available for TVF. It is an explicit +regularization option, not a general guarantee against free-surface tensile instability. ## [Transport Velocity Formulation (TVF)](@id transport_velocity_formulation) diff --git a/src/TrixiParticles.jl b/src/TrixiParticles.jl index d66c22d898..1a8404a5d2 100644 --- a/src/TrixiParticles.jl +++ b/src/TrixiParticles.jl @@ -81,6 +81,7 @@ export InfoCallback, SolutionSavingCallback, DensityReinitializationCallback, export ContinuityDensity, SummationDensity export PenaltyForceGanzenmueller, TransportVelocityAdami, ParticleShiftingTechnique, ParticleShiftingTechniqueSun2017, ConsistentShiftingSun2019, + FreeSurfaceTangentialShifting, ContinuityEquationTermSun2019, MomentumEquationTermSun2019, VelocityAveraging export SchoenbergCubicSplineKernel, SchoenbergQuarticSplineKernel, SchoenbergQuinticSplineKernel, GaussianKernel, WendlandC2Kernel, WendlandC4Kernel, diff --git a/src/io/io.jl b/src/io/io.jl index d386856108..8e950b0bd0 100644 --- a/src/io/io.jl +++ b/src/io/io.jl @@ -367,6 +367,10 @@ end function add_system_data!(system_data, shifting_technique::ParticleShiftingTechnique) system_data["shifting_technique"] = Dict{String, Any}() system_data["shifting_technique"]["model"] = type2string(shifting_technique) + treatment = shifting_technique.free_surface_treatment + system_data["shifting_technique"]["free_surface_treatment"] = isnothing(treatment) ? + nothing : + type2string(treatment) end function add_system_data!(system_data, viscosity::ViscosityCarreauYasuda) diff --git a/src/schemes/fluid/entropically_damped_sph/system.jl b/src/schemes/fluid/entropically_damped_sph/system.jl index 86498f9d24..ac80dbf4a7 100644 --- a/src/schemes/fluid/entropically_damped_sph/system.jl +++ b/src/schemes/fluid/entropically_damped_sph/system.jl @@ -123,6 +123,8 @@ function EntropicallyDampedSPHSystem(initial_condition; smoothing_kernel, smooth surface_normal_method = ColorfieldSurfaceNormal() end validate_corrected_csf(surface_normal_method, surface_tension) + validate_free_surface_shifting(shifting_technique, surface_normal_method, + surface_tension) if surface_normal_method !== nothing && reference_particle_spacing < eps() throw(ArgumentError("`reference_particle_spacing` must be set to a positive value when using a surface-normal method or a surface tension model")) diff --git a/src/schemes/fluid/shifting_techniques.jl b/src/schemes/fluid/shifting_techniques.jl index 9e2c1f23bc..7f3dee20ce 100644 --- a/src/schemes/fluid/shifting_techniques.jl +++ b/src/schemes/fluid/shifting_techniques.jl @@ -58,13 +58,45 @@ end return v_diff end +""" + FreeSurfaceTangentialShifting() + +Remove the interface-normal component of the particle-shifting velocity using the raw +free-surface normal. Full shifting is retained in the fluid interior, while particles in the +smooth interface transition are blended towards tangential-only shifting. + +This treatment requires a [`ColorfieldSurfaceNormal`](@ref) with +[`SurfaceTensionMorris`](@ref) or [`SurfaceTensionMomentumMorris`](@ref), or a +[`CorrectedCSFSurfaceNormal`](@ref) with `SurfaceTensionMorris`. It is an explicit opt-in through +the `free_surface_treatment` keyword of [`ParticleShiftingTechnique`](@ref). +""" +struct FreeSurfaceTangentialShifting end + +@inline supports_free_surface_shifting(surface_normal_method, surface_tension) = false + +@inline validate_free_surface_shifting(::Nothing, surface_normal_method, + surface_tension) = nothing + +function validate_free_surface_shifting(::FreeSurfaceTangentialShifting, + surface_normal_method, surface_tension) + supports_free_surface_shifting(surface_normal_method, surface_tension) || + throw(ArgumentError("`FreeSurfaceTangentialShifting` requires " * + "`ColorfieldSurfaceNormal` with Morris/CSS surface tension or " * + "`CorrectedCSFSurfaceNormal` with Morris surface tension")) + return nothing +end + +@inline validate_free_surface_shifting(shifting, surface_normal_method, + surface_tension) = nothing + @doc raw""" ParticleShiftingTechnique(; integrate_shifting_velocity=true, update_everystage=false, modify_continuity_equation=true, - second_continuity_equation_term=ContinuityEquationTermSun2019(), - momentum_equation_term=MomentumEquationTermSun2019(), - v_max_factor=1, sound_speed_factor=0) + second_continuity_equation_term=ContinuityEquationTermSun2019(), + momentum_equation_term=MomentumEquationTermSun2019(), + v_max_factor=1, sound_speed_factor=0, + free_surface_treatment=nothing) Particle Shifting Technique by [Sun et al. (2017)](@cite Sun2017) and [Sun et al. (2019)](@cite Sun2019). @@ -133,27 +165,33 @@ We provide the following convenience constructors for common variants of the met `sound_speed_factor * c`, where `c` is the speed of sound. Only one of `v_max_factor` and `sound_speed_factor` can be non-zero. +- `free_surface_treatment`: Treatment applied to shifting near a free surface. The default + `nothing` retains the closed-system formulation. Use + [`FreeSurfaceTangentialShifting`](@ref) with a supported + Morris CSF/CSS interface to remove the interface-normal + shifting component. !!! warning - The Particle Shifting Technique needs to be disabled close to the free surface - and therefore requires a free surface detection method. This is not yet implemented. - **This technique cannot be used in a free surface simulation.** + The default `free_surface_treatment=nothing` is for closed systems. Free-surface use requires + an explicit compatible treatment such as [`FreeSurfaceTangentialShifting`](@ref). """ struct ParticleShiftingTechnique{integrate_shifting_velocity, update_everystage, modify_continuity_equation, compute_v_max, - ELTYPE, S, M} <: AbstractShiftingTechnique + ELTYPE, S, M, F} <: AbstractShiftingTechnique v_factor :: ELTYPE second_continuity_equation_term :: S momentum_equation_term :: M + free_surface_treatment :: F function ParticleShiftingTechnique(; integrate_shifting_velocity=true, update_everystage=false, modify_continuity_equation=true, second_continuity_equation_term=ContinuityEquationTermSun2019(), momentum_equation_term=MomentumEquationTermSun2019(), - v_max_factor=1, sound_speed_factor=0) + v_max_factor=1, sound_speed_factor=0, + free_surface_treatment=nothing) if !integrate_shifting_velocity && update_everystage throw(ArgumentError("ParticleShiftingTechnique: " * "integrate_shifting_velocity=false requires " * @@ -190,6 +228,12 @@ struct ParticleShiftingTechnique{integrate_shifting_velocity, "must be positive")) end + if !(free_surface_treatment isa Union{Nothing, + FreeSurfaceTangentialShifting}) + throw(ArgumentError("ParticleShiftingTechnique: `free_surface_treatment` " * + "must be `nothing` or `FreeSurfaceTangentialShifting()`")) + end + v_factor = max(v_max_factor, sound_speed_factor) compute_v_max = v_max_factor > 0 @@ -198,12 +242,20 @@ struct ParticleShiftingTechnique{integrate_shifting_velocity, modify_continuity_equation, compute_v_max, typeof(v_factor), typeof(second_continuity_equation_term), - typeof(momentum_equation_term)}(v_factor, + typeof(momentum_equation_term), + typeof(free_surface_treatment)}(v_factor, second_continuity_equation_term, - momentum_equation_term) + momentum_equation_term, + free_surface_treatment) end end +function validate_free_surface_shifting(shifting::ParticleShiftingTechnique, + surface_normal_method, surface_tension) + return validate_free_surface_shifting(shifting.free_surface_treatment, + surface_normal_method, surface_tension) +end + """ ParticleShiftingTechniqueSun2017(; kwargs...) @@ -218,10 +270,11 @@ ParticleShiftingTechnique(integrate_shifting_velocity=false, modify_continuity_equation=false, second_continuity_equation_term=nothing, momentum_equation_term=nothing, - v_max_factor=1, sound_speed_factor=0) + v_max_factor=1, sound_speed_factor=0, + free_surface_treatment=nothing) # output -ParticleShiftingTechnique{false, false, false, true, Int64, Nothing, Nothing}(1, nothing, nothing) +ParticleShiftingTechnique{false, false, false, true, Int64, Nothing, Nothing, Nothing}(1, nothing, nothing, nothing) ``` See [ParticleShiftingTechnique](@ref ParticleShiftingTechnique) for all available options. @@ -234,13 +287,12 @@ See [ParticleShiftingTechnique](@ref ParticleShiftingTechnique) for all availabl shifting_technique = ParticleShiftingTechniqueSun2017() # output -ParticleShiftingTechnique{false, false, false, true, Int64, Nothing, Nothing}(1, nothing, nothing) +ParticleShiftingTechnique{false, false, false, true, Int64, Nothing, Nothing, Nothing}(1, nothing, nothing, nothing) ``` !!! warning - The Particle Shifting Technique needs to be disabled close to the free surface - and therefore requires a free surface detection method. This is not yet implemented. - **This technique cannot be used in a free surface simulation.** + The default `free_surface_treatment=nothing` is for closed systems. See + [`FreeSurfaceTangentialShifting`](@ref) for explicit free-surface use. """ function ParticleShiftingTechniqueSun2017(; kwargs...) return ParticleShiftingTechnique(; integrate_shifting_velocity=false, @@ -264,10 +316,11 @@ ParticleShiftingTechnique(integrate_shifting_velocity=true, modify_continuity_equation=true, second_continuity_equation_term=ContinuityEquationTermSun2019(), momentum_equation_term=MomentumEquationTermSun2019(), - v_max_factor=0, sound_speed_factor=0.1f0) + v_max_factor=0, sound_speed_factor=0.1f0, + free_surface_treatment=nothing) # output -ParticleShiftingTechnique{true, true, true, false, Float32, ContinuityEquationTermSun2019, MomentumEquationTermSun2019}(0.1f0, ContinuityEquationTermSun2019(), MomentumEquationTermSun2019()) +ParticleShiftingTechnique{true, true, true, false, Float32, ContinuityEquationTermSun2019, MomentumEquationTermSun2019, Nothing}(0.1f0, ContinuityEquationTermSun2019(), MomentumEquationTermSun2019(), nothing) ``` See [ParticleShiftingTechnique](@ref ParticleShiftingTechnique) for all available options. @@ -287,13 +340,12 @@ See [ParticleShiftingTechnique](@ref ParticleShiftingTechnique) for all availabl shifting_technique = ConsistentShiftingSun2019() # output -ParticleShiftingTechnique{true, true, true, false, Float32, ContinuityEquationTermSun2019, MomentumEquationTermSun2019}(0.1f0, ContinuityEquationTermSun2019(), MomentumEquationTermSun2019()) +ParticleShiftingTechnique{true, true, true, false, Float32, ContinuityEquationTermSun2019, MomentumEquationTermSun2019, Nothing}(0.1f0, ContinuityEquationTermSun2019(), MomentumEquationTermSun2019(), nothing) ``` !!! warning - The Particle Shifting Technique needs to be disabled close to the free surface - and therefore requires a free surface detection method. This is not yet implemented. - **This technique cannot be used in a free surface simulation.** + The default `free_surface_treatment=nothing` is for closed systems. See + [`FreeSurfaceTangentialShifting`](@ref) for explicit free-surface use. """ function ConsistentShiftingSun2019(; kwargs...) return ParticleShiftingTechnique(; integrate_shifting_velocity=true, @@ -504,7 +556,39 @@ end end modify_shifting_at_free_surfaces!(system, u, semi) + modify_shifting_with_surface_normal!(system, shifting.free_surface_treatment, semi) + + return system +end +@inline modify_shifting_with_surface_normal!(system, treatment, semi) = system + +@inline function tangential_shifting_velocity(shifting_velocity, normal, activity) + normal_norm_squared = dot(normal, normal) + normal_norm_squared > eps(normal_norm_squared) || return shifting_velocity + isfinite(activity) || return shifting_velocity + + weight = clamp(activity, zero(activity), one(activity)) + normal_component = dot(shifting_velocity, normal) / normal_norm_squared * normal + return shifting_velocity - weight * normal_component +end + +function modify_shifting_with_surface_normal!(system::AbstractFluidSystem, + ::FreeSurfaceTangentialShifting, semi) + delta_v_cache = system.cache.delta_v + @threaded semi for particle in each_integrated_particle(system) + activity = surface_interface_activity(system, particle) + if !iszero(activity) + # Shifting uses raw geometry even when capillary normal smoothing is enabled. + normal = surface_normal(system, particle) + shifting_velocity = extract_svector(delta_v_cache, system, particle) + corrected_velocity = tangential_shifting_velocity(shifting_velocity, normal, + activity) + for dimension in eachindex(corrected_velocity) + @inbounds delta_v_cache[dimension, particle] = corrected_velocity[dimension] + end + end + end return system end diff --git a/src/schemes/fluid/surface_normal_sph.jl b/src/schemes/fluid/surface_normal_sph.jl index 23868ce4d5..27e78ff50b 100644 --- a/src/schemes/fluid/surface_normal_sph.jl +++ b/src/schemes/fluid/surface_normal_sph.jl @@ -43,6 +43,17 @@ This explicit opt-in implements the single-fluid free-surface core (equations 15 """ struct CorrectedCSFSurfaceNormal end +@inline function supports_free_surface_shifting(::ColorfieldSurfaceNormal, + ::Union{SurfaceTensionMorris, + SurfaceTensionMomentumMorris}) + return true +end + +@inline function supports_free_surface_shifting(::CorrectedCSFSurfaceNormal, + ::SurfaceTensionMorris) + return true +end + @inline validate_corrected_csf(surface_normal_method, surface_tension) = nothing function validate_corrected_csf(::CorrectedCSFSurfaceNormal, surface_tension) diff --git a/src/schemes/fluid/weakly_compressible_sph/system.jl b/src/schemes/fluid/weakly_compressible_sph/system.jl index 4e6b1c3936..435b604913 100644 --- a/src/schemes/fluid/weakly_compressible_sph/system.jl +++ b/src/schemes/fluid/weakly_compressible_sph/system.jl @@ -134,6 +134,8 @@ function WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel, surface_normal_method = ColorfieldSurfaceNormal() end validate_corrected_csf(surface_normal_method, surface_tension) + validate_free_surface_shifting(shifting_technique, surface_normal_method, + surface_tension) if surface_normal_method !== nothing && reference_particle_spacing < eps() throw(ArgumentError("`reference_particle_spacing` must be set to a positive value when using a surface-normal method or a surface tension model")) diff --git a/test/schemes/fluid/shifting_techniques.jl b/test/schemes/fluid/shifting_techniques.jl index 20ef2372fa..3c31cd1ffc 100644 --- a/test/schemes/fluid/shifting_techniques.jl +++ b/test/schemes/fluid/shifting_techniques.jl @@ -7,6 +7,81 @@ @test_nowarn ConsistentShiftingSun2019() pst = @test_nowarn ConsistentShiftingSun2019(sound_speed_factor=0.2) @test pst.v_factor == 0.2 + @test isnothing(pst.free_surface_treatment) + + treatment = FreeSurfaceTangentialShifting() + pst = @test_nowarn ConsistentShiftingSun2019(; free_surface_treatment=treatment) + @test pst.free_surface_treatment === treatment + callback_pst = @test_nowarn ParticleShiftingTechniqueSun2017(; + free_surface_treatment=treatment) + @test callback_pst.free_surface_treatment === treatment + @test_throws ArgumentError ParticleShiftingTechnique(free_surface_treatment=:invalid) + + css = SurfaceTensionMomentumMorris(; surface_tension_coefficient=1.0) + morris = SurfaceTensionMorris(; surface_tension_coefficient=1.0) + @test_throws ArgumentError TrixiParticles.validate_free_surface_shifting(pst, + nothing, + css) + @test_throws ArgumentError TrixiParticles.validate_free_surface_shifting(pst, + ColorfieldSurfaceNormal(), + nothing) + @test_throws ArgumentError TrixiParticles.validate_free_surface_shifting(pst, + ColorfieldSurfaceNormal(), + SurfaceTensionAkinci()) + @test_throws ArgumentError TrixiParticles.validate_free_surface_shifting(pst, + CorrectedCSFSurfaceNormal(), + css) + @test_nowarn TrixiParticles.validate_free_surface_shifting(pst, + ColorfieldSurfaceNormal(), + css) + @test_nowarn TrixiParticles.validate_free_surface_shifting(pst, + ColorfieldSurfaceNormal(), + morris) + @test_nowarn TrixiParticles.validate_free_surface_shifting(pst, + CorrectedCSFSurfaceNormal(), + morris) + + system_data = Dict{String, Any}() + TrixiParticles.add_system_data!(system_data, pst) + @test system_data["shifting_technique"]["free_surface_treatment"] == + "FreeSurfaceTangentialShifting" + default_data = Dict{String, Any}() + TrixiParticles.add_system_data!(default_data, ConsistentShiftingSun2019()) + @test isnothing(default_data["shifting_technique"]["free_surface_treatment"]) + + particle_spacing = 0.1 + initial_condition = RectangularShape(particle_spacing, (2, 2), (0.0, 0.0); + density=1.0) + smoothing_kernel = WendlandC2Kernel{2}() + normal_method = ColorfieldSurfaceNormal() + @test_throws ArgumentError WeaklyCompressibleSPHSystem(initial_condition; + smoothing_kernel, + smoothing_length=1.4particle_spacing, + density_calculator=ContinuityDensity(), + state_equation=StateEquationCole(; + sound_speed=10.0, + reference_density=1.0, + exponent=7), + surface_normal_method=normal_method, + shifting_technique=pst, + reference_particle_spacing=particle_spacing) + @test_throws ArgumentError EntropicallyDampedSPHSystem(initial_condition; + smoothing_kernel, + smoothing_length=1.4particle_spacing, + sound_speed=10.0, + density_calculator=ContinuityDensity(), + surface_normal_method=normal_method, + shifting_technique=pst, + reference_particle_spacing=particle_spacing) + @test_nowarn EntropicallyDampedSPHSystem(initial_condition; + smoothing_kernel, + smoothing_length=1.4particle_spacing, + sound_speed=10.0, + density_calculator=ContinuityDensity(), + surface_tension=css, + surface_normal_method=normal_method, + shifting_technique=pst, + reference_particle_spacing=particle_spacing) # Can't use both `v_max_factor` and `sound_speed_factor` @test_throws ArgumentError ParticleShiftingTechnique(v_max_factor=1.0, @@ -28,4 +103,129 @@ modify_continuity_equation=false, second_continuity_equation_term=ContinuityEquationTermSun2019()) end + + @testset "Tangential free-surface projection" begin + shifting_velocity = SVector(3.0, 4.0) + normal = SVector(1.0, 0.0) + + @test TrixiParticles.tangential_shifting_velocity(shifting_velocity, normal, + 0.0) == shifting_velocity + @test TrixiParticles.tangential_shifting_velocity(shifting_velocity, normal, + 0.5) ≈ SVector(1.5, 4.0) + tangential = TrixiParticles.tangential_shifting_velocity(shifting_velocity, + normal, 1.0) + @test tangential ≈ SVector(0.0, 4.0) + @test dot(tangential, normal) ≈ 0.0 + @test TrixiParticles.tangential_shifting_velocity(shifting_velocity, + 2normal, 1.0) ≈ tangential + @test TrixiParticles.tangential_shifting_velocity(shifting_velocity, + normal, -1.0) == shifting_velocity + @test TrixiParticles.tangential_shifting_velocity(shifting_velocity, + normal, 2.0) ≈ tangential + @test TrixiParticles.tangential_shifting_velocity(shifting_velocity, + zero(normal), 1.0) == + shifting_velocity + @test TrixiParticles.tangential_shifting_velocity(shifting_velocity, + SVector(NaN, 0.0), 1.0) == + shifting_velocity + @test TrixiParticles.tangential_shifting_velocity(shifting_velocity, normal, + NaN) == shifting_velocity + + particle_spacing = 0.1 + initial_condition = RectangularShape(particle_spacing, (2, 2), (0.0, 0.0); + density=1.0) + surface_tension = SurfaceTensionMomentumMorris(; + surface_tension_coefficient=1.0) + surface_normal_method = ColorfieldSurfaceNormal(; normal_smoothing=true) + treatment = FreeSurfaceTangentialShifting() + shifting_technique = ConsistentShiftingSun2019(; + free_surface_treatment=treatment) + system = WeaklyCompressibleSPHSystem(initial_condition; + smoothing_kernel=WendlandC2Kernel{2}(), + smoothing_length=1.4particle_spacing, + density_calculator=ContinuityDensity(), + state_equation=StateEquationCole(; + sound_speed=10.0, + reference_density=1.0, + exponent=7), + surface_tension, surface_normal_method, + shifting_technique, + reference_particle_spacing=particle_spacing) + system.cache.delta_v .= reshape([3.0, 4.0], 2, 1) + system.cache.surface_normal .= reshape([1.0, 0.0], 2, 1) + system.cache.smoothed_surface_normal .= reshape([0.0, 1.0], 2, 1) + system.cache.interface_activity .= [1.0, 0.5, 0.0, 1.0] + system.cache.surface_normal[:, 4] .= 0 + + TrixiParticles.modify_shifting_with_surface_normal!(system, treatment, + DummySemidiscretization()) + @test system.cache.delta_v[:, 1] ≈ [0.0, 4.0] + @test system.cache.delta_v[:, 2] ≈ [1.5, 4.0] + @test system.cache.delta_v[:, 3] ≈ [3.0, 4.0] + @test system.cache.delta_v[:, 4] ≈ [3.0, 4.0] + + system.cache.delta_v .= reshape([3.0, 4.0], 2, 1) + TrixiParticles.modify_shifting_with_surface_normal!(system, nothing, + DummySemidiscretization()) + @test all(particle -> system.cache.delta_v[:, particle] ≈ [3.0, 4.0], + eachparticle(system)) + end + + @testset "Integrated tangential shifting" begin + function shifting_system(free_surface_treatment) + particle_spacing = 0.1 + initial_condition = RectangularShape(particle_spacing, (7, 7), (0.0, 0.0); + density=1000.0) + surface_tension = SurfaceTensionMomentumMorris(; + surface_tension_coefficient=0.072) + surface_normal_method = ColorfieldSurfaceNormal(; + ideal_density_threshold=0.9, + normal_smoothing=true) + shifting_technique = ConsistentShiftingSun2019(; + free_surface_treatment) + system = WeaklyCompressibleSPHSystem(initial_condition; + smoothing_kernel=WendlandC2Kernel{2}(), + smoothing_length=1.4particle_spacing, + density_calculator=ContinuityDensity(), + state_equation=StateEquationCole(; + sound_speed=10.0, + reference_density=1000.0, + exponent=7), + surface_tension, surface_normal_method, + shifting_technique, + reference_particle_spacing=particle_spacing) + semi = Semidiscretization(system) + ode = semidiscretize(semi, (0.0, 0.01)) + TrixiParticles.update_systems_and_nhs(ode.u0.x..., semi, 0.0) + return system + end + + untreated = shifting_system(nothing) + treated = shifting_system(FreeSurfaceTangentialShifting()) + @test treated.cache.surface_normal ≈ untreated.cache.surface_normal + @test treated.cache.interface_activity ≈ untreated.cache.interface_activity + + expected = similar(untreated.cache.delta_v) + for particle in eachparticle(treated) + shifting_velocity = TrixiParticles.extract_svector(untreated.cache.delta_v, + untreated, particle) + normal = TrixiParticles.surface_normal(treated, particle) + activity = treated.cache.interface_activity[particle] + expected[:, + particle] = TrixiParticles.tangential_shifting_velocity(shifting_velocity, + normal, + activity) + end + @test treated.cache.delta_v ≈ expected + @test maximum(abs, treated.cache.delta_v - untreated.cache.delta_v) > 1.0e-8 + + surface = findall(==(1), treated.cache.interface_activity) + @test !isempty(surface) + @test maximum(surface) do particle + normal = TrixiParticles.surface_normal(treated, particle) + shifting_velocity = TrixiParticles.extract_svector(treated.cache.delta_v, + treated, particle) + abs(dot(shifting_velocity, normal)) + end < 1.0e-12 + end end From 5579f433cc72dd0a04c85dfaffd30d0187ca6bc9 Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Sun, 9 Aug 2026 18:01:46 +0200 Subject: [PATCH 58/59] Add interface-aware tensile instability control --- NEWS.md | 2 + docs/src/systems/fluid.md | 4 + docs/src/systems/weakly_compressible_sph.md | 33 +++ src/TrixiParticles.jl | 2 +- src/io/io.jl | 17 +- .../fluid/entropically_damped_sph/system.jl | 5 + src/schemes/fluid/pressure_acceleration.jl | 136 +++++++++++- src/schemes/fluid/surface_normal_sph.jl | 14 ++ .../fluid/weakly_compressible_sph/system.jl | 7 +- test/schemes/fluid/pressure_acceleration.jl | 198 ++++++++++++++++++ 10 files changed, 405 insertions(+), 13 deletions(-) diff --git a/NEWS.md b/NEWS.md index 51b9b6c930..1145ef18db 100644 --- a/NEWS.md +++ b/NEWS.md @@ -26,6 +26,8 @@ used in the Julia ecosystem. Notable changes will be documented in this file for stress diagnostics. - Added `FreeSurfaceTangentialShifting`, an opt-in treatment that smoothly removes the interface-normal component of Sun particle shifting while retaining full interior shifting. +- Added `InterfaceAwareTensileInstabilityControl`, an opt-in pressure formulation that applies + tensile-instability control in fluid interiors and smoothly disables it at free surfaces. - Added C1 interface activation for Morris CSF and CSS with `ColorfieldSurfaceNormal`. Color-gradient and continuous support-moment indicators taper the physical surface delta without another neighbor pass. diff --git a/docs/src/systems/fluid.md b/docs/src/systems/fluid.md index 43432e6e5f..f054c8eeb6 100644 --- a/docs/src/systems/fluid.md +++ b/docs/src/systems/fluid.md @@ -526,6 +526,10 @@ normal actually used by the capillary operator. It also includes `surface_delta` `surface_support_moment`; CSS adds `surface_divergence_correction` and the physical `surface_stress_tensor`, reconstructed only while writing output. +Supported Morris CSF/CSS free-surface simulations can opt into +[`InterfaceAwareTensileInstabilityControl`](@ref), which applies tensile-instability control in +the fluid interior and blends back to conservative pressure across the interface transition. + ### API ```@autodocs diff --git a/docs/src/systems/weakly_compressible_sph.md b/docs/src/systems/weakly_compressible_sph.md index 5fca29fdb3..da26880c04 100644 --- a/docs/src/systems/weakly_compressible_sph.md +++ b/docs/src/systems/weakly_compressible_sph.md @@ -298,6 +298,20 @@ The formulation is described in Section 2.1 of this paper. It can be used in combination with the [Particle Shifting Technique (PST)](@ref shifting) to effectively prevent non-physical separation of the fluid from the object. +Direct use of `tensile_instability_control` is restricted to closed systems because its +non-conservative correction must be disabled near a free surface. Morris CSF/CSS simulations +can explicitly reuse their smooth interface activity for this purpose: + +```julia +surface_tension = SurfaceTensionMomentumMorris(surface_tension_coefficient=0.072) +surface_normal_method = ColorfieldSurfaceNormal() +pressure_acceleration = InterfaceAwareTensileInstabilityControl(strength=0.25) +``` + +This option requires `ContinuityDensity`, a state equation with unclipped negative pressure, and +no asymmetric kernel-gradient correction. It also supports `CorrectedCSFSurfaceNormal` with +`SurfaceTensionMorris`. The correction is disabled for fluid-boundary interactions. + As can be seen in the following figure, TIC alone can cause instabilities and does not improve the simulation. PST alone can mostly prevent separation at lower resolutions. @@ -325,6 +339,25 @@ The TIC formulation changes this term to Note that this formulation is asymmetric and sacrifices conservation of linear and angular momentum. +For the interface-aware formulation, let ``\lambda_a`` and ``\lambda_b`` denote the smooth +interface activities and define + +```math +\lambda_{ab} = \operatorname{clamp}(\max(\lambda_a, \lambda_b), 0, 1). +``` + +With a user-selected strength ``s \in (0, 1]``, the pair force is + +```math +\bm{f}_{ab}^{\mathrm{IA-TIC}} = \bm{f}_{ab} ++ s(1-\lambda_{ab})\left(\bm{f}_{ab}^{\mathrm{TIC}}-\bm{f}_{ab}\right). +``` + +Thus, `strength=1` recovers full TIC in the supported interior, while either particle reaching +the represented interface restores the conservative pressure force. Intermediate activity +smoothly blends between the two formulations. + ```@docs tensile_instability_control +InterfaceAwareTensileInstabilityControl ``` diff --git a/src/TrixiParticles.jl b/src/TrixiParticles.jl index 1a8404a5d2..e1b152febd 100644 --- a/src/TrixiParticles.jl +++ b/src/TrixiParticles.jl @@ -90,7 +90,7 @@ export StateEquationCole, StateEquationIdealGas, StateEquationAdaptiveCole export ArtificialViscosityMonaghan, ViscosityAdami, ViscosityMorris, ViscosityAdamiSGS, ViscosityMorrisSGS, ViscosityCarreauYasuda export DensityDiffusionMolteniColagrossi, DensityDiffusionFerrari, DensityDiffusionAntuono -export tensile_instability_control +export tensile_instability_control, InterfaceAwareTensileInstabilityControl export BoundaryModelMonaghanKajtar, BoundaryModelDummyParticles, AdamiPressureExtrapolation, PressureMirroring, PressureZeroing, BoundaryModelCharacteristicsLastiwka, BoundaryModelMirroringTafuni, BoundaryModelDynamicalPressureZhang, diff --git a/src/io/io.jl b/src/io/io.jl index 8e950b0bd0..2c96eed31a 100644 --- a/src/io/io.jl +++ b/src/io/io.jl @@ -75,6 +75,17 @@ end add_system_data!(system_data, data::Nothing) = system_data +@inline pressure_acceleration_name(formulation) = nameof(formulation) +@inline pressure_acceleration_name(control::InterfaceAwareTensileInstabilityControl) = nameof(typeof(control)) + +@inline add_pressure_acceleration_data!(system_data, formulation) = system_data + +function add_pressure_acceleration_data!(system_data, + control::InterfaceAwareTensileInstabilityControl) + system_data["interface_aware_tic_strength"] = control.strength + return system_data +end + function add_system_data!(system_data, system::AbstractFluidSystem) system_data["system_type"] = type2string(system) system_data["particle_spacing"] = particle_spacing(system, 1) @@ -83,7 +94,9 @@ function add_system_data!(system_data, system::AbstractFluidSystem) system_data["smoothing_length"] = system.cache.smoothing_length system_data["acceleration"] = system.acceleration system_data["sound_speed"] = system_sound_speed(system) - system_data["pressure_acceleration_formulation"] = nameof(system.pressure_acceleration_formulation) + formulation = system.pressure_acceleration_formulation + system_data["pressure_acceleration_formulation"] = pressure_acceleration_name(formulation) + add_pressure_acceleration_data!(system_data, formulation) add_system_data!(system_data, shifting_technique(system)) add_system_data!(system_data, system.surface_tension) add_system_data!(system_data, system.surface_normal_method) @@ -105,7 +118,7 @@ function add_system_data!(system_data, system::ImplicitIncompressibleSPHSystem) system_data["smoothing_kernel"] = type2string(system.smoothing_kernel) system_data["smoothing_length"] = system.cache.smoothing_length system_data["acceleration"] = system.acceleration - system_data["pressure_acceleration_formulation"] = nameof(system.pressure_acceleration_formulation) + system_data["pressure_acceleration_formulation"] = pressure_acceleration_name(system.pressure_acceleration_formulation) add_system_data!(system_data, shifting_technique(system)) add_system_data!(system_data, system.viscosity) end diff --git a/src/schemes/fluid/entropically_damped_sph/system.jl b/src/schemes/fluid/entropically_damped_sph/system.jl index ac80dbf4a7..1547a1194c 100644 --- a/src/schemes/fluid/entropically_damped_sph/system.jl +++ b/src/schemes/fluid/entropically_damped_sph/system.jl @@ -30,6 +30,8 @@ See [Entropically Damped Artificial Compressibility for SPH](@ref edac) for more - `pressure_acceleration`: Pressure acceleration formulation (default: inter-particle averaged pressure). When set to `nothing`, the pressure acceleration formulation for the corresponding [density calculator](@ref density_calculator) is chosen. + [`InterfaceAwareTensileInstabilityControl`](@ref) can be used + with a supported Morris CSF/CSS free surface. - `density_calculator`: [Density calculator](@ref density_calculator) (default: [`SummationDensity`](@ref)) - `shifting_technique`: [Shifting technique](@ref shifting) or [transport velocity formulation](@ref transport_velocity_formulation) to use @@ -125,6 +127,9 @@ function EntropicallyDampedSPHSystem(initial_condition; smoothing_kernel, smooth validate_corrected_csf(surface_normal_method, surface_tension) validate_free_surface_shifting(shifting_technique, surface_normal_method, surface_tension) + validate_interface_aware_tic(pressure_acceleration, density_calculator, + nothing, surface_normal_method, + surface_tension, correction) if surface_normal_method !== nothing && reference_particle_spacing < eps() throw(ArgumentError("`reference_particle_spacing` must be set to a positive value when using a surface-normal method or a surface tension model")) diff --git a/src/schemes/fluid/pressure_acceleration.jl b/src/schemes/fluid/pressure_acceleration.jl index b6114c0bc1..6dd04249c8 100644 --- a/src/schemes/fluid/pressure_acceleration.jl +++ b/src/schemes/fluid/pressure_acceleration.jl @@ -61,9 +61,9 @@ the [`WeaklyCompressibleSPHSystem`](@ref) constructor. See [Tensile Instability Control](@ref tic) for more information on this technique. !!! warning - Tensile Instability Control needs to be disabled close to the free surface - and therefore requires a free surface detection method. This is not yet implemented. - **This technique cannot be used in a free surface simulation.** + Direct use of this function must be disabled close to a free surface. For supported + Morris/CSS free-surface simulations, pass + [`InterfaceAwareTensileInstabilityControl`](@ref) as `pressure_acceleration` instead. """ @inline function tensile_instability_control(m_a, m_b, rho_a, rho_b, p_a, p_b, W_a) # Same as `pressure_acceleration_continuity_density`, but using the minus formulation @@ -71,6 +71,67 @@ See [Tensile Instability Control](@ref tic) for more information on this techniq return -m_b * (abs(p_a) + p_b) / (rho_a * rho_b) * W_a end +""" + InterfaceAwareTensileInstabilityControl(; strength=1.0) + +Apply [`tensile_instability_control`](@ref) in fully supported fluid interiors and blend back +to the conservative continuity-density pressure acceleration across a represented free surface. +The control is disabled for fluid-boundary interactions. + +This explicit opt-in requires [`ContinuityDensity`](@ref), unclipped pressure, no asymmetric +kernel-gradient correction, and an interface-activity-providing surface-normal method: either a +[`ColorfieldSurfaceNormal`](@ref) with Morris CSF/CSS surface tension or a +[`CorrectedCSFSurfaceNormal`](@ref) with [`SurfaceTensionMorris`](@ref). The `strength` in +`(0, 1]` scales only the tensile correction; `1` recovers the complete interior TIC +formulation. +""" +struct InterfaceAwareTensileInstabilityControl{T <: Real} + strength::T + + function InterfaceAwareTensileInstabilityControl(; strength=1.0) + strength isa Real && isfinite(strength) && 0 < strength <= 1 || + throw(ArgumentError("`strength` must be finite and in (0, 1]")) + new{typeof(strength)}(strength) + end +end + +@inline validate_interface_aware_tic(pressure_acceleration, density_calculator, + state_equation, surface_normal_method, + surface_tension, correction) = nothing + +function validate_interface_aware_tic(::InterfaceAwareTensileInstabilityControl, + density_calculator, state_equation, + surface_normal_method, surface_tension, correction) + density_calculator isa ContinuityDensity || + throw(ArgumentError("`InterfaceAwareTensileInstabilityControl` requires `ContinuityDensity`")) + uses_asymmetric_kernel_gradient(correction) && + throw(ArgumentError("`InterfaceAwareTensileInstabilityControl` does not support asymmetric kernel-gradient corrections")) + supports_interface_aware_tic(surface_normal_method, surface_tension) || + throw(ArgumentError("`InterfaceAwareTensileInstabilityControl` requires " * + "`ColorfieldSurfaceNormal` with Morris CSF/CSS surface tension " * + "or `CorrectedCSFSurfaceNormal` with `SurfaceTensionMorris`")) + if !isnothing(state_equation) && applicable(clip_negative_pressure, state_equation) && + clip_negative_pressure(state_equation) + throw(ArgumentError("`InterfaceAwareTensileInstabilityControl` requires unclipped negative pressure")) + end + return nothing +end + +@inline function interface_aware_tensile_acceleration(m_a, m_b, rho_a, rho_b, p_a, p_b, + W_a, activity_a, activity_b, + strength) + standard = pressure_acceleration_continuity_density(m_a, m_b, rho_a, rho_b, + p_a, p_b, W_a) + interface_activity = max(activity_a, activity_b) + isfinite(interface_activity) || return standard + + controlled = tensile_instability_control(m_a, m_b, rho_a, rho_b, p_a, p_b, W_a) + interface_activity = clamp(interface_activity, zero(interface_activity), + one(interface_activity)) + interior_weight = strength * (one(interface_activity) - interface_activity) + return standard + interior_weight * (controlled - standard) +end + # This formulation was introduced by Hu and Adams (2006). https://doi.org/10.1016/j.jcp.2005.09.001 # They argued that the formulation is more flexible because of the possibility to formulate # different inter-particle averages or to assume different inter-particle distributions. @@ -93,13 +154,17 @@ end return -volume_term * pressure_tilde * W_a end +@inline function uses_asymmetric_kernel_gradient(correction) + return correction isa Union{KernelCorrection, + GradientCorrection, + BlendedGradientCorrection, + MixedKernelGradientCorrection} +end + function choose_pressure_acceleration_formulation(pressure_acceleration, density_calculator, NDIMS, ELTYPE, correction) - if correction isa KernelCorrection || - correction isa GradientCorrection || - correction isa BlendedGradientCorrection || - correction isa MixedKernelGradientCorrection + if uses_asymmetric_kernel_gradient(correction) if isempty(methods(pressure_acceleration, (ELTYPE, ELTYPE, ELTYPE, ELTYPE, ELTYPE, ELTYPE, SVector{NDIMS, ELTYPE}, SVector{NDIMS, ELTYPE}))) @@ -123,6 +188,16 @@ function choose_pressure_acceleration_formulation(pressure_acceleration, return pressure_acceleration end +function choose_pressure_acceleration_formulation(control::InterfaceAwareTensileInstabilityControl, + density_calculator, NDIMS, ELTYPE, + correction) + density_calculator isa ContinuityDensity || + throw(ArgumentError("`InterfaceAwareTensileInstabilityControl` requires `ContinuityDensity`")) + uses_asymmetric_kernel_gradient(correction) && + throw(ArgumentError("`InterfaceAwareTensileInstabilityControl` does not support asymmetric kernel-gradient corrections")) + return control +end + function choose_pressure_acceleration_formulation(pressure_acceleration::Nothing, density_calculator::SummationDensity, NDIMS, ELTYPE, @@ -143,14 +218,57 @@ end @inline pressure_acceleration_formulation(system) = system.pressure_acceleration_formulation +@inline function evaluate_pressure_acceleration(formulation, particle_system, + neighbor_system, particle, neighbor, + m_a, m_b, rho_a, rho_b, p_a, p_b, W_a) + return formulation(m_a, m_b, rho_a, rho_b, p_a, p_b, W_a) +end + +@inline function evaluate_pressure_acceleration(::InterfaceAwareTensileInstabilityControl, + particle_system, neighbor_system, + particle, neighbor, m_a, m_b, + rho_a, rho_b, p_a, p_b, W_a) + return pressure_acceleration_continuity_density(m_a, m_b, rho_a, rho_b, + p_a, p_b, W_a) +end + +@inline function interface_tic_activity(system::AbstractFluidSystem, particle) + supports_interface_aware_tic(surface_normal_method(system), + surface_tension_model(system)) || + return one(eltype(system)) + return surface_interface_activity(system, particle) +end + +@inline function evaluate_pressure_acceleration(control::InterfaceAwareTensileInstabilityControl, + particle_system, + neighbor_system::AbstractFluidSystem, + particle, neighbor, m_a, m_b, + rho_a, rho_b, p_a, p_b, W_a) + activity_a = interface_tic_activity(particle_system, particle) + activity_b = interface_tic_activity(neighbor_system, neighbor) + return interface_aware_tensile_acceleration(m_a, m_b, rho_a, rho_b, p_a, p_b, W_a, + activity_a, activity_b, control.strength) +end + +@inline function evaluate_pressure_acceleration(::InterfaceAwareTensileInstabilityControl, + particle_system, + neighbor_system::AbstractFluidSystem, + particle, neighbor, m_a, m_b, + rho_a, rho_b, p_a::SMatrix, p_b, W_a) + return pressure_acceleration_continuity_density(m_a, m_b, rho_a, rho_b, + p_a, p_b, W_a) +end + # Formulation using symmetric gradient formulation for corrections not depending on local neighborhood. @inline function pressure_acceleration(particle_system, neighbor_system, particle, neighbor, m_a, m_b, p_a, p_b, rho_a, rho_b, pos_diff, distance, W_a, correction) # Without correction or with `AkinciFreeSurfaceCorrection`, the kernel gradient is # symmetric, so call the symmetric version of the pressure acceleration formulation. - return pressure_acceleration_formulation(particle_system)(m_a, m_b, rho_a, rho_b, - p_a, p_b, W_a) + formulation = pressure_acceleration_formulation(particle_system) + return evaluate_pressure_acceleration(formulation, particle_system, neighbor_system, + particle, neighbor, m_a, m_b, rho_a, rho_b, + p_a, p_b, W_a) end # Formulation using asymmetric gradient formulation for corrections depending on local neighborhood. diff --git a/src/schemes/fluid/surface_normal_sph.jl b/src/schemes/fluid/surface_normal_sph.jl index 27e78ff50b..cd26e928a5 100644 --- a/src/schemes/fluid/surface_normal_sph.jl +++ b/src/schemes/fluid/surface_normal_sph.jl @@ -54,6 +54,20 @@ end return true end +# Interface-aware TIC needs the smooth activity provided by these free-surface models. +@inline supports_interface_aware_tic(surface_normal_method, surface_tension) = false + +@inline function supports_interface_aware_tic(::ColorfieldSurfaceNormal, + ::Union{SurfaceTensionMorris, + SurfaceTensionMomentumMorris}) + return true +end + +@inline function supports_interface_aware_tic(::CorrectedCSFSurfaceNormal, + ::SurfaceTensionMorris) + return true +end + @inline validate_corrected_csf(surface_normal_method, surface_tension) = nothing function validate_corrected_csf(::CorrectedCSFSurfaceNormal, surface_tension) diff --git a/src/schemes/fluid/weakly_compressible_sph/system.jl b/src/schemes/fluid/weakly_compressible_sph/system.jl index 435b604913..3aa4674136 100644 --- a/src/schemes/fluid/weakly_compressible_sph/system.jl +++ b/src/schemes/fluid/weakly_compressible_sph/system.jl @@ -35,7 +35,9 @@ See [Weakly Compressible SPH](@ref wcsph) for more details on the method. By default, the correct formulation is chosen based on the density calculator and the correction method. To use [Tensile Instability Control](@ref tic), pass - [`tensile_instability_control`](@ref) here. + [`tensile_instability_control`](@ref), or use + [`InterfaceAwareTensileInstabilityControl`](@ref) with a + supported Morris CSF/CSS free surface. - `shifting_technique`: [Shifting technique](@ref shifting) or [transport velocity formulation](@ref transport_velocity_formulation) to use with this system. Default is no shifting. @@ -136,6 +138,9 @@ function WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel, validate_corrected_csf(surface_normal_method, surface_tension) validate_free_surface_shifting(shifting_technique, surface_normal_method, surface_tension) + validate_interface_aware_tic(pressure_acceleration, density_calculator, + state_equation, surface_normal_method, + surface_tension, correction) if surface_normal_method !== nothing && reference_particle_spacing < eps() throw(ArgumentError("`reference_particle_spacing` must be set to a positive value when using a surface-normal method or a surface tension model")) diff --git a/test/schemes/fluid/pressure_acceleration.jl b/test/schemes/fluid/pressure_acceleration.jl index 80de3d9a66..7c01e9e3d0 100644 --- a/test/schemes/fluid/pressure_acceleration.jl +++ b/test/schemes/fluid/pressure_acceleration.jl @@ -65,4 +65,202 @@ correction_dict_2[correction_name]) end end + + @testset verbose=true "Interface-Aware Tensile Instability Control" begin + control = InterfaceAwareTensileInstabilityControl() + @test control.strength == 1.0 + @test InterfaceAwareTensileInstabilityControl(; strength=0.25).strength == 0.25 + for strength in (0, -1, 1.1, Inf, NaN, "invalid") + @test_throws ArgumentError InterfaceAwareTensileInstabilityControl(; strength) + end + + m_a = m_b = rho_a = rho_b = 1.0 + p_a = -2.0 + p_b = 0.5 + grad_kernel = SVector(1.0, -0.5) + standard = TrixiParticles.pressure_acceleration_continuity_density(m_a, m_b, + rho_a, rho_b, + p_a, p_b, + grad_kernel) + controlled = tensile_instability_control(m_a, m_b, rho_a, rho_b, + p_a, p_b, grad_kernel) + interface_aware(activity_a, activity_b, + strength=1.0) = TrixiParticles.interface_aware_tensile_acceleration(m_a, + m_b, + rho_a, + rho_b, + p_a, + p_b, + grad_kernel, + activity_a, + activity_b, + strength) + + @test interface_aware(0.0, 0.0) == controlled + @test interface_aware(1.0, 0.0) == standard + @test interface_aware(0.0, 1.0) == standard + @test interface_aware(0.5, 0.0) == (standard + controlled) / 2 + @test interface_aware(0.0, 0.5) == (standard + controlled) / 2 + @test interface_aware(0.0, 0.0, 0.25) == + standard + 0.25 * (controlled - standard) + @test interface_aware(-1.0, -0.5) == controlled + @test interface_aware(2.0, 0.0) == standard + @test interface_aware(NaN, 0.0) == standard + @test interface_aware(Inf, 0.0) == standard + + colorfield = ColorfieldSurfaceNormal(; ideal_density_threshold=0.95) + css = SurfaceTensionMomentumMorris(; surface_tension_coefficient=1.0) + morris = SurfaceTensionMorris(; surface_tension_coefficient=1.0) + ccsf = CorrectedCSFSurfaceNormal() + @test TrixiParticles.supports_interface_aware_tic(colorfield, css) + @test TrixiParticles.supports_interface_aware_tic(colorfield, morris) + @test TrixiParticles.supports_interface_aware_tic(ccsf, morris) + @test !TrixiParticles.supports_interface_aware_tic(ccsf, css) + @test !TrixiParticles.supports_interface_aware_tic(nothing, morris) + + state_equation = StateEquationCole(; sound_speed=10.0, + reference_density=1000.0, + exponent=7, + clip_negative_pressure=false) + clipped_state_equation = StateEquationCole(; sound_speed=10.0, + reference_density=1000.0, + exponent=7, + clip_negative_pressure=true) + validate(density_calculator, equation, normal_method, surface_tension, + correction=nothing) = TrixiParticles.validate_interface_aware_tic(control, + density_calculator, + equation, + normal_method, + surface_tension, + correction) + @test_nowarn validate(ContinuityDensity(), state_equation, colorfield, css) + @test_nowarn validate(ContinuityDensity(), state_equation, colorfield, css, + AkinciFreeSurfaceCorrection(1000.0)) + @test_throws ArgumentError validate(SummationDensity(), state_equation, + colorfield, css) + @test_throws ArgumentError validate(ContinuityDensity(), clipped_state_equation, + colorfield, css) + @test_throws ArgumentError validate(ContinuityDensity(), state_equation, + colorfield, css, KernelCorrection()) + @test_throws ArgumentError validate(ContinuityDensity(), state_equation, + nothing, css) + + @test TrixiParticles.choose_pressure_acceleration_formulation(control, + ContinuityDensity(), + 2, Float64, + nothing) === control + @test_throws ArgumentError TrixiParticles.choose_pressure_acceleration_formulation(control, + SummationDensity(), + 2, + Float64, + nothing) + @test_throws ArgumentError TrixiParticles.choose_pressure_acceleration_formulation(control, + ContinuityDensity(), + 2, + Float64, + GradientCorrection()) + + particle_spacing = 0.1 + initial_condition = RectangularShape(particle_spacing, (3, 3), (0.0, 0.0); + density=1000.0) + smoothing_kernel = WendlandC2Kernel{2}() + smoothing_length = 1.4particle_spacing + system = WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel, + smoothing_length, + density_calculator=ContinuityDensity(), + state_equation, + pressure_acceleration=control, + surface_tension=css, + surface_normal_method=colorfield, + reference_particle_spacing=particle_spacing) + @test system.pressure_acceleration_formulation === control + system_data = Dict{String, Any}() + @test_nowarn TrixiParticles.add_system_data!(system_data, system) + @test system_data["pressure_acceleration_formulation"] == + :InterfaceAwareTensileInstabilityControl + @test system_data["interface_aware_tic_strength"] == 1.0 + + system.cache.interface_activity .= 0.0 + system.cache.interface_activity[2] = 0.5 + actual = TrixiParticles.pressure_acceleration(system, system, 1, 2, + m_a, m_b, p_a, p_b, + rho_a, rho_b, + SVector(0.1, 0.0), 0.1, + grad_kernel, nothing) + @test actual == interface_aware(0.0, 0.5) + + boundary_result = TrixiParticles.evaluate_pressure_acceleration(control, system, + nothing, 1, 1, + m_a, m_b, + rho_a, rho_b, + p_a, p_b, + grad_kernel) + @test boundary_result == standard + + neighbor_without_interface = WeaklyCompressibleSPHSystem(initial_condition; + smoothing_kernel, + smoothing_length, + density_calculator=ContinuityDensity(), + state_equation) + unsupported_fluid_result = TrixiParticles.evaluate_pressure_acceleration(control, + system, + neighbor_without_interface, + 1, 1, + m_a, m_b, + rho_a, + rho_b, + p_a, p_b, + grad_kernel) + @test unsupported_fluid_result == standard + + matrix_pressure_a = TrixiParticles.SMatrix{2, 2}(1.0, 0.0, 0.0, 2.0) + matrix_pressure_b = TrixiParticles.SMatrix{2, 2}(0.5, 0.0, 0.0, 1.0) + matrix_result = TrixiParticles.evaluate_pressure_acceleration(control, system, + system, 1, 2, + m_a, m_b, + rho_a, rho_b, + matrix_pressure_a, + matrix_pressure_b, + grad_kernel) + @test matrix_result == + TrixiParticles.pressure_acceleration_continuity_density(m_a, m_b, + rho_a, rho_b, + matrix_pressure_a, + matrix_pressure_b, + grad_kernel) + + @test_throws ArgumentError WeaklyCompressibleSPHSystem(initial_condition; + smoothing_kernel, + smoothing_length, + density_calculator=ContinuityDensity(), + state_equation, + pressure_acceleration=control) + + edac = EntropicallyDampedSPHSystem(initial_condition; smoothing_kernel, + smoothing_length, sound_speed=10.0, + density_calculator=ContinuityDensity(), + pressure_acceleration=control, + surface_tension=css, + surface_normal_method=colorfield, + reference_particle_spacing=particle_spacing) + @test edac.pressure_acceleration_formulation === control + @test_throws ArgumentError EntropicallyDampedSPHSystem(initial_condition; + smoothing_kernel, + smoothing_length, + sound_speed=10.0, + pressure_acceleration=control, + surface_tension=css, + surface_normal_method=colorfield, + reference_particle_spacing=particle_spacing) + + ccsf_system = WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel, + smoothing_length, + density_calculator=ContinuityDensity(), + state_equation, + pressure_acceleration=control, + surface_tension=morris, + surface_normal_method=ccsf, + reference_particle_spacing=particle_spacing) + @test ccsf_system.pressure_acceleration_formulation === control + end end From 114d7765b22f5cea20166db1c2386dcf6a1c5ade Mon Sep 17 00:00:00 2001 From: Sven Berger Date: Sun, 9 Aug 2026 19:20:35 +0200 Subject: [PATCH 59/59] Add boundary surface quadrature --- NEWS.md | 1 + docs/src/systems/boundary.md | 26 +++++++++++ .../boundary/wall_boundary/dummy_particles.jl | 37 ++++++++++++--- .../dummy_particles/dummy_particles.jl | 45 +++++++++++++++++++ test/systems/boundary_system.jl | 5 ++- 5 files changed, 107 insertions(+), 7 deletions(-) diff --git a/NEWS.md b/NEWS.md index 1145ef18db..c1480e8bcf 100644 --- a/NEWS.md +++ b/NEWS.md @@ -28,6 +28,7 @@ used in the Julia ecosystem. Notable changes will be documented in this file for interface-normal component of Sun particle shifting while retaining full interior shifting. - Added `InterfaceAwareTensileInstabilityControl`, an opt-in pressure formulation that applies tensile-instability control in fluid interiors and smoothly disables it at free surfaces. +- Added optional per-particle `surface_measure` quadrature data to dummy-particle boundaries. - Added C1 interface activation for Morris CSF and CSS with `ColorfieldSurfaceNormal`. Color-gradient and continuous support-moment indicators taper the physical surface delta without another neighbor pass. diff --git a/docs/src/systems/boundary.md b/docs/src/systems/boundary.md index 414d069bef..0656a54d31 100644 --- a/docs/src/systems/boundary.md +++ b/docs/src/systems/boundary.md @@ -57,6 +57,32 @@ of the boundary particle ``b``. BoundaryModelDummyParticles ``` +### Boundary surface quadrature + +Dummy-particle layers can optionally carry one `surface_measure` value per particle. These values +represent a discrete quadrature of the physical boundary surface, + +```math +\int_{\Gamma} f(\bm{x})\,\mathrm{d}A +\approx \sum_b A_b f(\bm{x}_b^\Gamma), +``` + +where ``A_b`` has units of length in 2D and area in 3D. Positive weights identify particles that +sample the physical face; zero weights identify deeper particles that support the hydrodynamic +dummy-boundary model but do not represent another copy of the surface. The consuming geometry +defines the face point ``\bm{x}_b^\Gamma``; it can differ from the dummy-particle coordinate. + +```julia +surface_measure = zeros(nparticles(boundary)) +surface_measure[exposed_particles] .= particle_spacing^2 # Planar 3D face +boundary_model = BoundaryModelDummyParticles(boundary; fluid_system, surface_measure) +``` + +Weights must be finite, nonnegative, and ordered like the boundary `InitialCondition`. The +constructor copies them into `boundary_model.cache.surface_measure` using the boundary model's +scalar type. Omitting the keyword allocates no quadrature cache. Supplying quadrature data alone +does not change density, pressure, viscosity, or force calculations; models that consume it apply +their own geometry and normal requirements. ### Hydrodynamic density of dummy particles diff --git a/src/schemes/boundary/wall_boundary/dummy_particles.jl b/src/schemes/boundary/wall_boundary/dummy_particles.jl index e0ec835c1e..321f1a44de 100644 --- a/src/schemes/boundary/wall_boundary/dummy_particles.jl +++ b/src/schemes/boundary/wall_boundary/dummy_particles.jl @@ -4,7 +4,8 @@ smoothing_length; viscosity=nothing, state_equation=nothing, correction=nothing, clip_negative_pressure=false, - reference_particle_spacing=0.0) + reference_particle_spacing=0.0, + surface_measure=nothing) Boundary model for [`WallBoundarySystem`](@ref). @@ -34,6 +35,9 @@ Boundary model for [`WallBoundarySystem`](@ref). 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. +- `surface_measure=nothing`: Optional nonnegative quadrature weight for each boundary particle. + Positive values represent samples on the physical boundary surface; + zero marks particles in deeper dummy-particle layers. # 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 @@ -88,7 +92,8 @@ end 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)) + reference_particle_spacing=default_reference_particle_spacing(fluid_system), + surface_measure=nothing) 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 @@ -105,13 +110,14 @@ function BoundaryModelDummyParticles(initial_condition; 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)) + reference_particle_spacing=default_reference_particle_spacing(fluid_system), + surface_measure=nothing) return BoundaryModelDummyParticles(initial_density, hydrodynamic_mass, boundary_density_calculator, smoothing_kernel, smoothing_length; viscosity, state_equation, correction, clip_negative_pressure, - reference_particle_spacing) + reference_particle_spacing, surface_measure) end # The default constructor needs to be accessible for Adapt.jl to work with this struct. @@ -121,7 +127,8 @@ function BoundaryModelDummyParticles(initial_density, hydrodynamic_mass, smoothing_length; viscosity=nothing, state_equation=nothing, correction=nothing, clip_negative_pressure=false, - reference_particle_spacing=0.0) + reference_particle_spacing=0.0, + surface_measure=nothing) pressure = initial_boundary_pressure(initial_density, density_calculator, state_equation) NDIMS = ndims(smoothing_kernel) @@ -131,7 +138,8 @@ function BoundaryModelDummyParticles(initial_density, hydrodynamic_mass, cache = (; create_cache_model(viscosity, n_particles, NDIMS)..., create_cache_model(initial_density, density_calculator, NDIMS)..., - create_cache_model(correction, initial_density, NDIMS, n_particles)...) + create_cache_model(correction, initial_density, NDIMS, n_particles)..., + create_cache_surface_measure(surface_measure, ELTYPE, n_particles)...) # If the `reference_density_spacing` is set calculate the `ideal_neighbor_count` if reference_particle_spacing > 0 @@ -149,6 +157,23 @@ function BoundaryModelDummyParticles(initial_density, hydrodynamic_mass, clip_negative_pressure) end +@inline create_cache_surface_measure(::Nothing, ELTYPE, n_particles) = (;) + +function create_cache_surface_measure(surface_measure, ELTYPE, n_particles) + surface_measure isa AbstractVector || + throw(ArgumentError("`surface_measure` must be a vector with one value per boundary particle")) + length(surface_measure) == n_particles || + throw(ArgumentError("`surface_measure` must contain $n_particles values, got $(length(surface_measure))")) + all(value -> value isa Real && isfinite(value) && value >= 0, surface_measure) || + throw(ArgumentError("`surface_measure` values must be finite, real, and nonnegative")) + + converted_measure = collect(ELTYPE, surface_measure) + all(isfinite, converted_measure) || + throw(ArgumentError("`surface_measure` values must remain finite when converted to $ELTYPE")) + + return (; surface_measure=converted_measure) +end + @inline function default_reference_particle_spacing(fluid_system) if hasproperty(fluid_system, :cache) && hasproperty(fluid_system.cache, :reference_particle_spacing) diff --git a/test/schemes/boundary/dummy_particles/dummy_particles.jl b/test/schemes/boundary/dummy_particles/dummy_particles.jl index 3c046dfe69..91831b6ddf 100644 --- a/test/schemes/boundary/dummy_particles/dummy_particles.jl +++ b/test/schemes/boundary/dummy_particles/dummy_particles.jl @@ -10,6 +10,51 @@ @test repr(boundary_model) == expected_repr end + @testset "Surface quadrature" begin + densities = Float32[1000, 1000, 1000] + masses = Float32[1, 1, 1] + smoothing_kernel = SchoenbergCubicSplineKernel{2}() + smoothing_length = 0.1f0 + surface_measure = [0.1, 0.0, 0.2] + expected_measure = Float32[0.1, 0.0, 0.2] + + boundary_model = BoundaryModelDummyParticles(densities, masses, + SummationDensity(), + smoothing_kernel, + smoothing_length; + surface_measure) + @test haskey(boundary_model.cache, :surface_measure) + @test boundary_model.cache.surface_measure == expected_measure + @test eltype(boundary_model.cache.surface_measure) == Float32 + + # Setup data is copied so later changes to the input do not alter the model. + surface_measure[1] = 1.0 + @test boundary_model.cache.surface_measure == expected_measure + + adapted_model = TrixiParticles.Adapt.adapt(Array, boundary_model) + @test adapted_model.cache.surface_measure == expected_measure + + default_model = BoundaryModelDummyParticles(densities, masses, + SummationDensity(), + smoothing_kernel, + smoothing_length) + @test !haskey(default_model.cache, :surface_measure) + + make_model(measure) = BoundaryModelDummyParticles(densities, masses, + SummationDensity(), + smoothing_kernel, + smoothing_length; + surface_measure=measure) + @test make_model(zeros(3)).cache.surface_measure == zeros(Float32, 3) + @test_throws ArgumentError make_model(1.0) + @test_throws ArgumentError make_model(zeros(2)) + @test_throws ArgumentError make_model([-1.0, 0.0, 0.0]) + @test_throws ArgumentError make_model([NaN, 0.0, 0.0]) + @test_throws ArgumentError make_model([Inf, 0.0, 0.0]) + @test_throws ArgumentError make_model([1.0e100, 0.0, 0.0]) + @test_throws ArgumentError make_model(Any["invalid", 0.0, 0.0]) + end + @testset "Pressure clipping" begin state_equation = StateEquationCole(sound_speed=10.0, reference_density=1000.0, diff --git a/test/systems/boundary_system.jl b/test/systems/boundary_system.jl index 67512a19bb..7b99a99f70 100644 --- a/test/systems/boundary_system.jl +++ b/test/systems/boundary_system.jl @@ -42,6 +42,7 @@ state_equation = StateEquationCole(; sound_speed=15.0, reference_density=1000.0, exponent=1) viscosity = ViscosityAdami(nu=1e-6) + surface_measure = [0.1, 0.0] fluid_system = WeaklyCompressibleSPHSystem(fluid_ic; density_calculator=ContinuityDensity(), @@ -52,7 +53,8 @@ boundary_model = BoundaryModelDummyParticles(boundary_ic; fluid_system=fluid_system, - viscosity=viscosity) + viscosity=viscosity, + surface_measure) system = WallBoundarySystem(boundary_ic, boundary_model, adhesion_coefficient=0.3, color_value=2) @@ -67,6 +69,7 @@ @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.boundary_model.cache.surface_measure == surface_measure @test system.adhesion_coefficient == 0.3 @test system.cache.color == 2