diff --git a/.gitignore b/.gitignore index dfcfa33c1b..42ccdb838e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .vscode .DS_Store +.opencode +graphify-out Manifest.toml out out_* diff --git a/NEWS.md b/NEWS.md index c1dbd37d0d..5934958cbf 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,9 +6,51 @@ used in the Julia ecosystem. Notable changes will be documented in this file for ## Version 0.5.3 -### Features - -- Added `flush` keyword argument to `InfoCallback` to flush `stdout` after each output, +### API Changes + +- Akinci cohesion and adhesion kernels now use dimensionally consistent, integral-matched + normalizations in 2D. To preserve previous pairwise kernel contributions at compact-support + radius `h_c`, multiply the surface-tension coefficient by `627 / (790 * h_c)` and the + adhesion coefficient by `42 / (65 * h_c)`; migrated kernel coefficients are + resolution-independent. +- `SurfaceTensionAkinci` now defaults to fluid-only colorfield normals, matching Equation 2 + of Akinci et al. (2013). Pass a finite `boundary_contact_threshold` to + `ColorfieldSurfaceNormal` explicitly to include boundary neighbors. +- 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 Morris and CSS models, `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; validation configurations migrate the previous explicit + value `0.9` to `0.95`. + +### Features + +- Reworked `SurfaceTensionMomentumMorris` as a balanced one-phase CSS model. The model now + retains the physical color-gradient surface delta, applies a conservative scalar reproducing + correction without another neighbor pass, and evaluates stress on demand. +- Added the explicit `WettedAreaContactAngle` model for validated 3D CSS wetting. It applies the + complete derivative of Young's corrected wetted-area energy, including equal-and-opposite wall + and rigid-body reactions. The no-contact default is unchanged. The rejected, unshipped + geometric-normal and contact-line-force candidates were removed. +- Added C1 interface activation for Morris CSF and CSS. The color-gradient and continuous + support-moment indicators taper the physical surface delta without an extra neighbor pass; + CSS retains exact pairwise linear-momentum conservation. +- Added opt-in `CorrectedCSFSurfaceNormal` for the single-fluid free-surface core of the C-CSF + method. It provides renormalized eigenvalue-gradient normals, curvature, and a Shepard-corrected + surface delta for `SurfaceTensionMorris`. A finite contact angle enables planar boundary-integral + geometry on dummy-particle walls with explicit face measures and normal offsets. Hydrodynamic wall + coupling continues to use the configured dummy-particle model. +- Added opt-in one-pass activity-weighted Shepard smoothing for `ColorfieldSurfaceNormal` directions. + Interface activation, surface-delta magnitudes, and particle-shifting normals remain unsmoothed. +- Added opt-in `FreeSurfaceTangentialShifting` for Sun particle shifting with Morris CSF and CSS. + It reuses the smooth color-field interface activity, retains full consistent shifting in the + interior, and projects shifting onto the local tangent plane at the free surface. Closed-system + shifting defaults are unchanged. +- Added the opt-in 3D `SurfaceTensionAkinciCohesionPhysical` model. It converts a physical + surface tension in N/m to the resolution-dependent Akinci cohesion coefficient, supports + same-kernel Young-Dupre wall ratios, and contributes a capillary time-step restriction. +- 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 computation of boundary normals for `RectangularTank`s and `SphereShape`s. diff --git a/Project.toml b/Project.toml index 444c214a92..44b0c1e7c6 100644 --- a/Project.toml +++ b/Project.toml @@ -35,6 +35,7 @@ WriteVTK = "64499a7a-5c06-52f2-abe2-ccb03c286192" [weakdeps] CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba" +Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" OrdinaryDiffEqCore = "bbf590c4-e513-4bbe-9b18-05decba2e5d8" OrdinaryDiffEqLowStorageRK = "b0944070-b475-4768-8dec-fb6eb410534d" OrdinaryDiffEqSymplecticRK = "fa646aed-7ef9-47eb-84c4-9443fc8cbfa8" @@ -43,6 +44,7 @@ Static = "aedffcd0-7271-4cad-89d0-dc628f76c6d3" [extensions] TrixiParticlesOrdinaryDiffEqSymplecticRKExt = ["OrdinaryDiffEqSymplecticRK", "OrdinaryDiffEqCore"] TrixiParticlesCUDAExt = "CUDA" +TrixiParticlesMakieExt = "Makie" [compat] Accessors = "0.1.43" @@ -60,6 +62,7 @@ GPUArraysCore = "0.2" JSON = "1" KernelAbstractions = "0.9" LinearAlgebra = "1" +Makie = "0.24" OrdinaryDiffEqLowStorageRK = "3" OrdinaryDiffEqCore = "4" OrdinaryDiffEqSymplecticRK = "2" diff --git a/README.md b/README.md index bf3c1c9cbc..415b72c23f 100644 --- a/README.md +++ b/README.md @@ -45,24 +45,24 @@ It offers intuitive configuration, robust pre- and post-processing, and vendor-a We provide several example simulation setups in the `examples` folder (which can be accessed from Julia via `examples_dir()`).
|
- + |
- + |
|
- + |
- + |
|
- + |
- + |
|
- + |
- + |
+```
+
+Load 2D geometry files, fill them with particles using `ComplexShape`, and build 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/docs/src/visualization.md b/docs/src/visualization.md
index 9dda94bfba..6b9da14fef 100644
--- a/docs/src/visualization.md
+++ b/docs/src/visualization.md
@@ -1,12 +1,41 @@
# Visualization
-## Export VTK files
+## Export VTK Files
You can export particle data as VTK files by using the [`SolutionSavingCallback`](@ref).
All [predefined examples](examples.md) already use this callback to export VTK files to the `out`
directory relative to the current working directory.
VTK files can be opened in visualization tools such as [ParaView](https://www.paraview.org/)
and [VisIt](https://visit.llnl.gov/).
+## Makie
+
+Load a Makie backend to inspect any two- or three-dimensional solution frame directly. This
+particle-level diagnostic renders spheres whose diameter follows the system's particle spacing;
+it does not reconstruct a continuous fluid surface.
+
+```julia
+using CairoMakie
+
+figure = Figure()
+axis = LScene(figure[1, 1]; show_axis=false)
+trixi2makie(axis, sol)
+figure
+```
+
+Use `frame` to select another saved ODE frame. The keywords `system_indices`, `system_colors`,
+and `marker_size_scales` select and style systems. Colors and size scales can be scalars,
+vectors indexed by system number, or functions with the signature `(system, system_index)`.
+Additional keywords are forwarded to `Makie.meshscatter!`.
+
+The [`compare_akinci`](https://github.com/trixi-framework/TrixiParticles.jl/tree/main/compare_akinci)
+project demonstrates production-style fluid rendering: it reconstructs an isosurface from the SPH
+particles and uses a dielectric water material with the experimental
+[RayMakie](https://makie.org/website/blogposts/raytracing/) backend.
+
+```@docs
+trixi2makie
+```
+
### ParaView
Follow these steps to view the exported VTK files in ParaView:
@@ -18,6 +47,7 @@ Follow these steps to view the exported VTK files in ParaView:
5. Hold the left mouse button to move the solution around.
You will now see the following:
+

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

-#### Visualization with Macro
+#### Visualization with a Macro
To simplify visualization of particle data in ParaView, you can use a macro.
It reduces the manual steps from the previous section to a single click.
Install the macro as follows.
@@ -44,7 +75,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 +108,12 @@ sourceDisplay.UseScaleFunction = 0
sourceDisplay.GaussianRadius = 0.5
```
-#### Show results
+#### Show Results
To view the result variables, first make sure that "fluid_1.pvd" is highlighted in the
"Pipeline Browser", then select a variable in the variable-selection combo box
(see the image below). For example, choose "density". To view the time evolution,
press the play button (also shown below).
+

## API
diff --git a/examples/fluid/akinci_cube_to_sphere_3d.jl b/examples/fluid/akinci_cube_to_sphere_3d.jl
new file mode 100644
index 0000000000..8301553f02
--- /dev/null
+++ b/examples/fluid/akinci_cube_to_sphere_3d.jl
@@ -0,0 +1,111 @@
+# ==========================================================================================
+# 3D Akinci Cube-to-Sphere Experiment
+#
+# Reproduces the setup of Figure 2 in Akinci, Akinci, and Teschner (2013): an initially
+# cubical drop first minimizes its surface area and is then released onto the ground.
+# The companion video reports a 1 cm^3 drop represented by about 7,000 particles.
+# https://doi.org/10.1145/2508363.2508395
+# ==========================================================================================
+
+using TrixiParticles
+using OrdinaryDiffEqLowStorageRK
+
+drop_volume = 1.0e-6
+target_particle_count = 7_000
+cube_side_length = cbrt(drop_volume)
+particles_per_dimension = round(Int, cbrt(target_particle_count))
+particle_spacing = cube_side_length / particles_per_dimension
+fluid_density = 1000.0
+sound_speed = 40.0
+fluid_clip_negative_pressure = true
+gravity = 9.81
+tspan = (0.0, 0.1)
+solution_saveat = ()
+drop_initial_condition = nothing
+provide_boundary_surface_geometry = false
+
+release_time = 0.05
+cube_bottom_height = 0.0025
+floor_size = 0.03
+boundary_layers = 3
+
+state_equation = StateEquationCole(; sound_speed, reference_density=fluid_density,
+ exponent=7,
+ clip_negative_pressure=fluid_clip_negative_pressure)
+
+n_cube = ntuple(_ -> round(Int, cube_side_length / particle_spacing), 3)
+cube_min = (-cube_side_length / 2, -cube_side_length / 2, cube_bottom_height)
+drop = isnothing(drop_initial_condition) ?
+ RectangularShape(particle_spacing, n_cube, cube_min; density=fluid_density) :
+ drop_initial_condition
+
+n_floor = round(Int, floor_size / particle_spacing)
+floor_raw = RectangularShape(particle_spacing, (n_floor, n_floor, boundary_layers),
+ (-floor_size / 2, -floor_size / 2,
+ -boundary_layers * particle_spacing);
+ density=fluid_density)
+floor_surface_measure = nothing
+floor = floor_raw
+if provide_boundary_surface_geometry
+ exposed_height = maximum(floor_raw.coordinates[3, :])
+ exposed = isapprox.(floor_raw.coordinates[3, :], exposed_height;
+ atol=eps(eltype(floor_raw)))
+ normals = zeros(eltype(floor_raw), size(floor_raw.coordinates))
+ normals[3, exposed] .= -particle_spacing / 2
+ floor_surface_measure = zeros(eltype(floor_raw), nparticles(floor_raw))
+ floor_surface_measure[exposed] .= particle_spacing^2
+ floor = InitialCondition(; coordinates=floor_raw.coordinates,
+ velocity=floor_raw.velocity,
+ mass=floor_raw.mass, density=floor_raw.density,
+ pressure=floor_raw.pressure, particle_spacing,
+ normals)
+end
+
+smoothing_length = particle_spacing - eps()
+smoothing_kernel = SchoenbergCubicSplineKernel{3}()
+viscosity = ArtificialViscosityMonaghan(alpha=0.01, beta=0.0)
+surface_tension_coefficient = 1.0
+surface_tension = SurfaceTensionAkinci(; surface_tension_coefficient)
+# Equation 2 sums fluid neighbors only; wall adhesion is modeled separately by Equation 6.
+surface_normal_method = ColorfieldSurfaceNormal(; boundary_contact_threshold=Inf)
+density_calculator = SummationDensity()
+density_diffusion = nothing
+correction = AkinciFreeSurfaceCorrection(fluid_density)
+pressure_acceleration = nothing
+shifting_technique = nothing
+adhesion_coefficient = surface_tension_coefficient
+
+gravity_after_release = let release_time=release_time, gravity=gravity
+ (coords, velocity, density, pressure,
+ t) -> t < release_time ? SVector(0.0, 0.0, 0.0) : SVector(0.0, 0.0, -gravity)
+end
+
+fluid_system = WeaklyCompressibleSPHSystem(drop; smoothing_kernel, smoothing_length,
+ density_calculator, density_diffusion,
+ state_equation, viscosity, pressure_acceleration,
+ surface_tension,
+ surface_normal_method,
+ correction,
+ shifting_technique,
+ reference_particle_spacing=particle_spacing,
+ source_terms=gravity_after_release)
+
+boundary_model = BoundaryModelDummyParticles(floor; fluid_system,
+ boundary_density_calculator=AdamiPressureExtrapolation(),
+ viscosity,
+ clip_negative_pressure=true,
+ surface_measure=floor_surface_measure)
+boundary_system = WallBoundarySystem(floor, boundary_model; adhesion_coefficient)
+
+semi = Semidiscretization(fluid_system, boundary_system)
+ode = semidiscretize(semi, tspan)
+
+info_callback = InfoCallback(interval=100)
+saving_callback = SolutionSavingCallback(dt=0.02)
+timestep_diagnostic_callback = nothing
+update_callback = nothing
+callbacks = CallbackSet(info_callback, saving_callback, timestep_diagnostic_callback,
+ update_callback)
+
+sol = solve(ode, RDPK3SpFSAL35(); abstol=1e-7, reltol=1e-4, dtmax=2e-3,
+ save_everystep=false, saveat=solution_saveat, callback=callbacks)
diff --git a/examples/fluid/akinci_droplet_on_plate_3d.jl b/examples/fluid/akinci_droplet_on_plate_3d.jl
new file mode 100644
index 0000000000..58dface1dc
--- /dev/null
+++ b/examples/fluid/akinci_droplet_on_plate_3d.jl
@@ -0,0 +1,65 @@
+# ==========================================================================================
+# 3D Akinci Droplet Impact on a Plate
+#
+# Reproduces Figure 6 of Akinci, Akinci, and Teschner (2013): a large drop impacts a
+# finite hydrophilic plate, spreads into a sheet, and can drip from its sides. The reference
+# scene is scaled down while retaining its reported adhesion coefficient beta = 0.6.
+# https://doi.org/10.1145/2508363.2508395
+# ==========================================================================================
+
+using TrixiParticles
+using OrdinaryDiffEqLowStorageRK
+
+particle_spacing = 0.0125
+fluid_density = 1000.0
+sound_speed = 40.0
+gravity = 9.81
+tspan = (0.0, 0.45)
+solution_saveat = ()
+
+drop_radius = 0.075
+drop_center = (0.0, 0.0, 0.22)
+impact_velocity = 1.0
+plate_size = (0.4, 0.4)
+boundary_layers = 3
+
+state_equation = StateEquationCole(; sound_speed, reference_density=fluid_density,
+ exponent=7, clip_negative_pressure=true)
+
+drop = SphereShape(particle_spacing, drop_radius, drop_center, fluid_density;
+ sphere_type=VoxelSphere(), velocity=(0.0, 0.0, -impact_velocity))
+
+n_plate = round.(Int, plate_size ./ particle_spacing)
+plate = RectangularShape(particle_spacing, (n_plate..., boundary_layers),
+ (-plate_size[1] / 2, -plate_size[2] / 2,
+ -boundary_layers * particle_spacing);
+ density=fluid_density)
+
+smoothing_length = particle_spacing - eps()
+smoothing_kernel = SchoenbergCubicSplineKernel{3}()
+viscosity = ArtificialViscosityMonaghan(alpha=0.01, beta=0.0)
+surface_tension_coefficient = 1.0
+surface_tension = SurfaceTensionAkinci(; surface_tension_coefficient)
+
+fluid_system = WeaklyCompressibleSPHSystem(drop; smoothing_kernel, smoothing_length,
+ density_calculator=ContinuityDensity(),
+ state_equation, viscosity, surface_tension,
+ correction=AkinciFreeSurfaceCorrection(fluid_density),
+ reference_particle_spacing=particle_spacing,
+ acceleration=(0.0, 0.0, -gravity))
+
+boundary_model = BoundaryModelDummyParticles(plate; fluid_system,
+ boundary_density_calculator=AdamiPressureExtrapolation(),
+ viscosity=ViscosityAdami(nu=0.01),
+ clip_negative_pressure=true)
+boundary_system = WallBoundarySystem(plate, boundary_model; adhesion_coefficient=0.6)
+
+semi = Semidiscretization(fluid_system, boundary_system)
+ode = semidiscretize(semi, tspan)
+
+info_callback = InfoCallback(interval=100)
+saving_callback = SolutionSavingCallback(dt=0.01)
+callbacks = CallbackSet(info_callback, saving_callback)
+
+sol = solve(ode, RDPK3SpFSAL35(); abstol=1e-7, reltol=1e-4, dtmax=1e-3,
+ save_everystep=false, saveat=solution_saveat, callback=callbacks)
diff --git a/examples/fluid/akinci_droplet_splitting_3d.jl b/examples/fluid/akinci_droplet_splitting_3d.jl
new file mode 100644
index 0000000000..453a5e94b6
--- /dev/null
+++ b/examples/fluid/akinci_droplet_splitting_3d.jl
@@ -0,0 +1,90 @@
+# ==========================================================================================
+# 3D Akinci Adhesive-Box Droplet Splitting
+#
+# Reproduces Figure 9 of Akinci, Akinci, and Teschner (2013): a drop adheres strongly to a
+# box while a descending blade with zero adhesion splits it. The contrast between the two
+# boundary adhesion coefficients is the central feature of the experiment.
+# https://doi.org/10.1145/2508363.2508395
+# ==========================================================================================
+
+using TrixiParticles
+using OrdinaryDiffEqLowStorageRK
+
+particle_spacing = 0.01
+fluid_density = 1000.0
+sound_speed = 30.0
+gravity = 9.81
+tspan = (0.0, 0.35)
+solution_saveat = ()
+
+tank_size = (0.24, 0.18, 0.14)
+drop_radius = 0.05
+drop_center = (tank_size[1] / 2, tank_size[2] / 2,
+ drop_radius + particle_spacing / 2)
+boundary_layers = 3
+blade_speed = 0.5
+blade_motion_time = 0.25
+
+state_equation = StateEquationCole(; sound_speed, reference_density=fluid_density,
+ exponent=7, clip_negative_pressure=true)
+tank = RectangularTank(particle_spacing, (0.0, 0.0, 0.0), tank_size, fluid_density;
+ n_layers=boundary_layers,
+ faces=(true, true, true, true, true, false))
+drop = SphereShape(particle_spacing, drop_radius, drop_center, fluid_density;
+ sphere_type=VoxelSphere())
+
+blade_height = 0.08
+blade_width = 0.12
+n_blade = (3, round(Int, blade_width / particle_spacing),
+ round(Int, blade_height / particle_spacing))
+blade = RectangularShape(particle_spacing, n_blade,
+ (tank_size[1] / 2 - 1.5 * particle_spacing,
+ tank_size[2] / 2 - blade_width / 2,
+ drop_center[3] + drop_radius + 0.03);
+ density=fluid_density)
+
+blade_motion = let blade_speed=blade_speed, blade_motion_time=blade_motion_time
+ (x, t) -> x + SVector(0.0, 0.0, -blade_speed * min(t, blade_motion_time))
+end
+blade_is_moving = let blade_motion_time=blade_motion_time
+ t -> t < blade_motion_time
+end
+prescribed_blade_motion = PrescribedMotion(blade_motion, blade_is_moving)
+
+smoothing_length = particle_spacing - eps()
+smoothing_kernel = SchoenbergCubicSplineKernel{3}()
+viscosity = ArtificialViscosityMonaghan(alpha=0.01, beta=0.0)
+surface_tension_coefficient = 1.0
+surface_tension = SurfaceTensionAkinci(; surface_tension_coefficient)
+
+fluid_system = WeaklyCompressibleSPHSystem(drop; smoothing_kernel, smoothing_length,
+ density_calculator=ContinuityDensity(),
+ state_equation, viscosity, surface_tension,
+ correction=AkinciFreeSurfaceCorrection(fluid_density),
+ reference_particle_spacing=particle_spacing,
+ acceleration=(0.0, 0.0, -gravity))
+
+tank_boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system,
+ boundary_density_calculator=AdamiPressureExtrapolation(),
+ viscosity=ViscosityAdami(nu=0.01),
+ clip_negative_pressure=true)
+blade_boundary_model = BoundaryModelDummyParticles(blade; fluid_system,
+ boundary_density_calculator=AdamiPressureExtrapolation(),
+ viscosity=ViscosityAdami(nu=0.01),
+ clip_negative_pressure=true)
+
+tank_boundary_system = WallBoundarySystem(tank.boundary, tank_boundary_model;
+ adhesion_coefficient=2.0)
+blade_boundary_system = WallBoundarySystem(blade, blade_boundary_model;
+ prescribed_motion=prescribed_blade_motion,
+ adhesion_coefficient=0.0)
+
+semi = Semidiscretization(fluid_system, tank_boundary_system, blade_boundary_system)
+ode = semidiscretize(semi, tspan)
+
+info_callback = InfoCallback(interval=100)
+saving_callback = SolutionSavingCallback(dt=0.01)
+callbacks = CallbackSet(info_callback, saving_callback)
+
+sol = solve(ode, RDPK3SpFSAL35(); abstol=1e-7, reltol=1e-4, dtmax=1e-3,
+ save_everystep=false, saveat=solution_saveat, callback=callbacks)
diff --git a/examples/fluid/akinci_rolling_droplet_3d.jl b/examples/fluid/akinci_rolling_droplet_3d.jl
new file mode 100644
index 0000000000..eaeda425be
--- /dev/null
+++ b/examples/fluid/akinci_rolling_droplet_3d.jl
@@ -0,0 +1,144 @@
+# ==========================================================================================
+# 3D Akinci Rolling-Droplet and Two-Way-Adhesion Experiment
+#
+# Reproduces the mechanisms of Figure 10 in Akinci, Akinci, and Teschner (2013): a strongly
+# cohesive drop rolls down a viscous adhesive incline while two rigid figures interact with
+# it. One figure has fluid adhesion and the other has none. The articulated ragdolls from the
+# paper are represented by rigid, figure-shaped particle bodies supported by this solver.
+# https://doi.org/10.1145/2508363.2508395
+# ==========================================================================================
+
+using TrixiParticles
+using OrdinaryDiffEqLowStorageRK
+
+particle_spacing = 0.01
+fluid_density = 1000.0
+rigid_density = 2000.0
+sound_speed = 40.0
+gravity = 9.81
+tspan = (0.0, 0.4)
+solution_saveat = ()
+include_rigid_figures = true
+
+incline_angle = deg2rad(20.0)
+plane_length = 0.6
+plane_width = 0.3
+boundary_layers = 3
+drop_radius = 0.07
+
+tangent = SVector(cos(incline_angle), 0.0, sin(incline_angle))
+cross_slope = SVector(0.0, 1.0, 0.0)
+plane_normal = SVector(-sin(incline_angle), 0.0, cos(incline_angle))
+plane_center = SVector(0.0, 0.0, 0.0)
+
+plane_corner = plane_center - plane_length / 2 * tangent -
+ plane_width / 2 * cross_slope -
+ boundary_layers * particle_spacing *
+ plane_normal
+plane = extrude_geometry((collect(plane_corner),
+ collect(plane_corner + plane_length * tangent),
+ collect(plane_corner + plane_width * cross_slope));
+ particle_spacing, direction=plane_normal,
+ n_extrude=boundary_layers, density=fluid_density)
+
+drop_center = plane_center + 0.15 * tangent +
+ (drop_radius + particle_spacing) * plane_normal
+drop = SphereShape(particle_spacing, drop_radius, drop_center, fluid_density;
+ sphere_type=VoxelSphere())
+
+state_equation = StateEquationCole(; sound_speed, reference_density=fluid_density,
+ exponent=7, clip_negative_pressure=true)
+smoothing_length = particle_spacing - eps()
+smoothing_kernel = SchoenbergCubicSplineKernel{3}()
+viscosity = ArtificialViscosityMonaghan(alpha=0.01, beta=0.0)
+surface_tension_coefficient = 3.0
+surface_tension = SurfaceTensionAkinci(; surface_tension_coefficient)
+
+fluid_system = WeaklyCompressibleSPHSystem(drop; smoothing_kernel, smoothing_length,
+ density_calculator=ContinuityDensity(),
+ state_equation, viscosity, surface_tension,
+ correction=AkinciFreeSurfaceCorrection(fluid_density),
+ reference_particle_spacing=particle_spacing,
+ acceleration=(0.0, 0.0, -gravity))
+
+plane_boundary_model = BoundaryModelDummyParticles(plane; fluid_system,
+ boundary_density_calculator=AdamiPressureExtrapolation(),
+ viscosity=ViscosityAdami(nu=1.0),
+ clip_negative_pressure=true)
+plane_boundary_system = WallBoundarySystem(plane, plane_boundary_model;
+ adhesion_coefficient=1.2)
+
+function rigid_figure(center, density, particle_spacing)
+ velocity = (0.0, 0.0, 0.0)
+ function block(n_particles, block_center)
+ min_corner = block_center .- 0.5 * particle_spacing .* n_particles
+ return RectangularShape(particle_spacing, n_particles, min_corner;
+ density, velocity)
+ end
+
+ torso = block((3, 2, 5), center)
+ arms = block((7, 2, 2), center + SVector(0.0, 0.0, 1.5 * particle_spacing))
+ left_leg = block((2, 2, 4),
+ center + SVector(-particle_spacing, 0.0, -4 * particle_spacing))
+ right_leg = block((2, 2, 4),
+ center + SVector(particle_spacing, 0.0, -4 * particle_spacing))
+ head = SphereShape(particle_spacing, 1.5 * particle_spacing,
+ center + SVector(0.0, 0.0, 4 * particle_spacing), density;
+ sphere_type=VoxelSphere(), velocity)
+
+ return union(torso, arms, left_leg, right_leg, head)
+end
+
+if include_rigid_figures
+ figure_height = 9 * particle_spacing
+ figure_center_height = drop_radius + figure_height / 2 + 2 * particle_spacing
+ figure_1_center = drop_center + figure_center_height * plane_normal -
+ 0.055 * cross_slope
+ figure_2_center = drop_center + figure_center_height * plane_normal +
+ 0.055 * cross_slope
+ adhesive_figure = rigid_figure(figure_1_center, rigid_density, particle_spacing)
+ nonadhesive_figure = rigid_figure(figure_2_center, rigid_density, particle_spacing)
+
+ function rigid_boundary_model(shape)
+ hydrodynamic_density = fill(fluid_density, nparticles(shape))
+ hydrodynamic_mass = fill(fluid_density * particle_spacing^3, nparticles(shape))
+ return BoundaryModelDummyParticles(shape; fluid_system,
+ initial_density=hydrodynamic_density,
+ hydrodynamic_mass,
+ boundary_density_calculator=AdamiPressureExtrapolation(),
+ viscosity=ViscosityAdami(nu=0.01),
+ clip_negative_pressure=true)
+ end
+
+ contact_model = RigidContactModel(; normal_stiffness=2.0e5,
+ normal_damping=200.0,
+ contact_distance=2 * particle_spacing)
+ adhesive_figure_system = RigidBodySystem(adhesive_figure;
+ boundary_model=rigid_boundary_model(adhesive_figure),
+ contact_model,
+ acceleration=(0.0, 0.0, -gravity),
+ particle_spacing,
+ adhesion_coefficient=1.0,
+ color_value=2)
+ nonadhesive_figure_system = RigidBodySystem(nonadhesive_figure;
+ boundary_model=rigid_boundary_model(nonadhesive_figure),
+ contact_model,
+ acceleration=(0.0, 0.0, -gravity),
+ particle_spacing,
+ adhesion_coefficient=0.0,
+ color_value=3)
+else
+ adhesive_figure_system = nothing
+ nonadhesive_figure_system = nothing
+end
+
+semi = Semidiscretization(fluid_system, plane_boundary_system,
+ adhesive_figure_system, nonadhesive_figure_system)
+ode = semidiscretize(semi, tspan)
+
+info_callback = InfoCallback(interval=100)
+saving_callback = SolutionSavingCallback(dt=0.01)
+callbacks = CallbackSet(info_callback, saving_callback)
+
+sol = solve(ode, RDPK3SpFSAL49(); abstol=1e-7, reltol=1e-4, dtmax=5e-4,
+ save_everystep=false, saveat=solution_saveat, callback=callbacks)
diff --git a/examples/fluid/akinci_stream_over_sphere_3d.jl b/examples/fluid/akinci_stream_over_sphere_3d.jl
new file mode 100644
index 0000000000..ec3ecdfe68
--- /dev/null
+++ b/examples/fluid/akinci_stream_over_sphere_3d.jl
@@ -0,0 +1,93 @@
+# ==========================================================================================
+# 3D Akinci Stream Flowing over a Sphere
+#
+# Reproduces Figure 7 of Akinci, Akinci, and Teschner (2013): a cylindrical inflow adheres
+# to and flows around a solid sphere. An open boundary continuously feeds the stream, so this
+# setup demonstrates fluid-solid adhesion rather than a finite falling water column.
+# https://doi.org/10.1145/2508363.2508395
+# ==========================================================================================
+
+using TrixiParticles
+using OrdinaryDiffEqLowStorageRK
+
+particle_spacing = 0.0075
+fluid_density = 1000.0
+sound_speed = 30.0
+gravity = 9.81
+tspan = (0.0, 0.5)
+solution_saveat = ()
+
+stream_radius = 0.03
+stream_speed = 0.5
+initial_stream_length = 0.08
+sphere_radius = 0.05
+sphere_center = (0.0, 0.0, -0.14)
+boundary_layers = 3
+open_boundary_layers = 6
+
+state_equation = StateEquationCole(; sound_speed, reference_density=fluid_density,
+ exponent=7, clip_negative_pressure=true)
+
+cross_section = SphereShape(particle_spacing, stream_radius, (0.0, 0.0), fluid_density;
+ sphere_type=VoxelSphere())
+initial_stream_layers = round(Int, initial_stream_length / particle_spacing)
+initial_stream = extrude_geometry(cross_section; direction=SVector(0.0, 0.0, -1.0),
+ n_extrude=initial_stream_layers,
+ velocity=(0.0, 0.0, -stream_speed))
+initial_stream.coordinates[3, :] .-= particle_spacing
+
+solid_sphere = SphereShape(particle_spacing, sphere_radius, sphere_center, fluid_density;
+ sphere_type=RoundSphere(), n_layers=boundary_layers)
+
+face = ([-stream_radius, -stream_radius, 0.0],
+ [stream_radius, -stream_radius, 0.0],
+ [-stream_radius, stream_radius, 0.0])
+inflow = BoundaryZone(; boundary_face=face, face_normal=[0.0, 0.0, -1.0],
+ density=fluid_density, particle_spacing, open_boundary_layers,
+ boundary_type=InFlow(), reference_density=fluid_density,
+ reference_velocity=[0.0, 0.0, -stream_speed],
+ extrude_geometry=cross_section)
+buffer_size = 8 * nparticles(inflow.initial_condition)
+
+smoothing_length = particle_spacing - eps()
+smoothing_kernel = SchoenbergCubicSplineKernel{3}()
+viscosity = ArtificialViscosityMonaghan(alpha=0.01, beta=0.0)
+surface_tension_coefficient = 1.0
+surface_tension = SurfaceTensionAkinci(; surface_tension_coefficient)
+
+fluid_system = WeaklyCompressibleSPHSystem(initial_stream; smoothing_kernel,
+ smoothing_length,
+ density_calculator=ContinuityDensity(),
+ state_equation, viscosity, surface_tension,
+ correction=AkinciFreeSurfaceCorrection(fluid_density),
+ reference_particle_spacing=particle_spacing,
+ acceleration=(0.0, 0.0, -gravity), buffer_size)
+
+open_boundary = OpenBoundarySystem(inflow; fluid_system, buffer_size,
+ boundary_model=BoundaryModelMirroringTafuni(;
+ mirror_method=ZerothOrderMirroring()))
+
+sphere_boundary_model = BoundaryModelDummyParticles(solid_sphere; fluid_system,
+ boundary_density_calculator=AdamiPressureExtrapolation(),
+ viscosity=ViscosityAdami(nu=0.01),
+ clip_negative_pressure=true)
+sphere_boundary_system = WallBoundarySystem(solid_sphere, sphere_boundary_model;
+ adhesion_coefficient=1.0)
+
+min_corner = [-0.2, -0.2, -1.6]
+max_corner = [0.2, 0.2, open_boundary_layers * particle_spacing]
+neighborhood_search = GridNeighborhoodSearch{3}(;
+ cell_list=FullGridCellList(; min_corner,
+ max_corner),
+ update_strategy=ParallelUpdate())
+
+semi = Semidiscretization(fluid_system, open_boundary, sphere_boundary_system;
+ neighborhood_search)
+ode = semidiscretize(semi, tspan)
+
+info_callback = InfoCallback(interval=100)
+saving_callback = SolutionSavingCallback(dt=0.01)
+callbacks = CallbackSet(info_callback, saving_callback, UpdateCallback())
+
+sol = solve(ode, RDPK3SpFSAL35(); abstol=1e-7, reltol=1e-4, dtmax=1e-3,
+ save_everystep=false, saveat=solution_saveat, callback=callbacks)
diff --git a/examples/fluid/akinci_water_crown_3d.jl b/examples/fluid/akinci_water_crown_3d.jl
new file mode 100644
index 0000000000..6c54dd34d0
--- /dev/null
+++ b/examples/fluid/akinci_water_crown_3d.jl
@@ -0,0 +1,73 @@
+# ==========================================================================================
+# 3D Akinci Water-Crown Experiment
+#
+# Reproduces the experiment in Figures 1 and 5 of Akinci, Akinci, and Teschner (2013):
+# a fast drop impacts a shallow pool and produces a crown and secondary droplets. The
+# dimensions and drop volume match the paper; the particle count is reduced from one million.
+# https://doi.org/10.1145/2508363.2508395
+# ==========================================================================================
+
+using TrixiParticles
+using OrdinaryDiffEqLowStorageRK
+
+particle_spacing = 0.0025
+fluid_density = 1000.0
+sound_speed = 40.0
+gravity = 9.81
+tspan = (0.0, 0.12)
+solution_saveat = ()
+
+# The paper reports a 6.5 cm^3 drop and a 15 x 4 x 15 cm^3 filled container.
+pool_size = (0.15, 0.15, 0.04)
+tank_size = (0.15, 0.15, 0.1)
+drop_volume = 6.5e-6
+drop_radius = cbrt(3 * drop_volume / (4pi))
+# `VoxelSphere` otherwise moves the outer particle centers half a spacing inwards. Compensate
+# for that inset so the discretized volume remains close to the reported volume.
+drop_sampling_radius = drop_radius + particle_spacing / 2
+drop_center = (tank_size[1] / 2, tank_size[2] / 2, 0.075)
+# The impact speed is not reported. This value reproduces the crown at reduced resolution.
+impact_velocity = 2.0
+boundary_layers = 3
+
+state_equation = StateEquationCole(; sound_speed, reference_density=fluid_density,
+ exponent=7, clip_negative_pressure=true)
+
+tank = RectangularTank(particle_spacing, pool_size, tank_size, fluid_density;
+ n_layers=boundary_layers,
+ faces=(true, true, true, true, true, false),
+ acceleration=(0.0, 0.0, -gravity), state_equation)
+drop = SphereShape(particle_spacing, drop_sampling_radius, drop_center, fluid_density;
+ sphere_type=VoxelSphere(), velocity=(0.0, 0.0, -impact_velocity))
+fluid = union(tank.fluid, drop)
+
+smoothing_length = particle_spacing - eps()
+smoothing_kernel = SchoenbergCubicSplineKernel{3}()
+density_calculator = SummationDensity()
+viscosity = ArtificialViscosityMonaghan(alpha=0.001, beta=0.0)
+surface_tension_coefficient = 1.0
+surface_tension = SurfaceTensionAkinci(; surface_tension_coefficient)
+
+fluid_system = WeaklyCompressibleSPHSystem(fluid; smoothing_kernel, smoothing_length,
+ density_calculator,
+ state_equation, viscosity, surface_tension,
+ correction=AkinciFreeSurfaceCorrection(fluid_density),
+ reference_particle_spacing=particle_spacing,
+ acceleration=(0.0, 0.0, -gravity))
+
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system,
+ boundary_density_calculator=AdamiPressureExtrapolation(),
+ viscosity,
+ clip_negative_pressure=true)
+boundary_system = WallBoundarySystem(tank.boundary, boundary_model;
+ adhesion_coefficient=1.0)
+
+semi = Semidiscretization(fluid_system, boundary_system)
+ode = semidiscretize(semi, tspan)
+
+info_callback = InfoCallback(interval=100)
+saving_callback = SolutionSavingCallback(dt=0.01)
+callbacks = CallbackSet(info_callback, saving_callback)
+
+sol = solve(ode, RDPK3SpFSAL35(); abstol=1e-7, reltol=1e-4, dtmax=1e-3,
+ save_everystep=false, saveat=solution_saveat, callback=callbacks)
diff --git a/examples/fluid/akinci_wetting_3d.jl b/examples/fluid/akinci_wetting_3d.jl
new file mode 100644
index 0000000000..6e00c43d65
--- /dev/null
+++ b/examples/fluid/akinci_wetting_3d.jl
@@ -0,0 +1,108 @@
+# ==========================================================================================
+# 3D Akinci Wetting-Regime Experiment
+#
+# Reproduces Figure 8 of Akinci, Akinci, and Teschner (2013). The companion video reports a
+# 1 cm^3 drop represented by about 750 particles and provides the coefficient pairs below.
+# https://doi.org/10.1145/2508363.2508395
+# ==========================================================================================
+
+using TrixiParticles
+using OrdinaryDiffEqLowStorageRK
+
+drop_volume = 1.0e-6
+target_particle_count = 750
+drop_radius = cbrt(3 * drop_volume / (4pi))
+particle_spacing = cbrt(drop_volume / target_particle_count)
+# Compensate for the half-spacing inset of the outer `VoxelSphere` particle centers.
+drop_sampling_radius = drop_radius + particle_spacing / 2
+fluid_density = 1000.0
+sound_speed = 30.0
+gravity = 9.81
+tspan = (0.0, 0.2)
+solution_saveat = ()
+wetting_case = "intermediate_wetting"
+
+if wetting_case == "no_wetting"
+ surface_tension_coefficient = 1.0
+ adhesion_coefficient = 0.0
+elseif wetting_case == "weak_wetting"
+ surface_tension_coefficient = 1.0
+ adhesion_coefficient = 0.05
+elseif wetting_case == "moderate_wetting"
+ surface_tension_coefficient = 1.0
+ adhesion_coefficient = 0.1
+elseif wetting_case == "intermediate_wetting"
+ surface_tension_coefficient = 1.0
+ adhesion_coefficient = 0.25
+elseif wetting_case == "strong_wetting"
+ surface_tension_coefficient = 0.1
+ adhesion_coefficient = 0.01
+elseif wetting_case == "near_perfect_wetting"
+ surface_tension_coefficient = 0.01
+ adhesion_coefficient = 0.001
+elseif wetting_case == "perfect_wetting"
+ surface_tension_coefficient = 0.001
+ adhesion_coefficient = 0.0
+else
+ throw(ArgumentError("unknown `wetting_case`: $wetting_case"))
+end
+
+drop_center = (0.0, 0.0, drop_radius + particle_spacing / 2)
+plate_size = (0.03, 0.03)
+boundary_layers = 3
+boundary_density_calculator = AdamiPressureExtrapolation()
+
+state_equation = StateEquationCole(; sound_speed, reference_density=fluid_density,
+ exponent=7, clip_negative_pressure=true)
+drop = SphereShape(particle_spacing, drop_sampling_radius, drop_center, fluid_density;
+ sphere_type=VoxelSphere())
+initial_fluid_density = nothing
+if !isnothing(initial_fluid_density)
+ drop = InitialCondition(; particle_spacing, coordinates=drop.coordinates,
+ velocity=drop.velocity, mass=drop.mass,
+ density=initial_fluid_density, pressure=drop.pressure)
+end
+
+n_plate = round.(Int, plate_size ./ particle_spacing)
+plate = RectangularShape(particle_spacing, (n_plate..., boundary_layers),
+ (-plate_size[1] / 2, -plate_size[2] / 2,
+ -boundary_layers * particle_spacing);
+ density=fluid_density)
+
+smoothing_length = particle_spacing - eps()
+smoothing_kernel = SchoenbergCubicSplineKernel{3}()
+viscosity = ArtificialViscosityMonaghan(alpha=0.01, beta=0.0)
+surface_tension = SurfaceTensionAkinci(; surface_tension_coefficient)
+# Equation 2 sums fluid neighbors only; wall adhesion is modeled separately by Equation 6.
+surface_normal_method = ColorfieldSurfaceNormal(; boundary_contact_threshold=Inf)
+fluid_density_calculator = SummationDensity()
+fluid_density_diffusion = nothing
+pressure_acceleration = nothing
+
+fluid_system = WeaklyCompressibleSPHSystem(drop; smoothing_kernel, smoothing_length,
+ density_calculator=fluid_density_calculator,
+ density_diffusion=fluid_density_diffusion,
+ state_equation, viscosity, surface_tension,
+ surface_normal_method, pressure_acceleration,
+ correction=AkinciFreeSurfaceCorrection(fluid_density),
+ reference_particle_spacing=particle_spacing,
+ acceleration=(0.0, 0.0, -gravity))
+
+boundary_hydrodynamic_mass = plate.mass
+boundary_model = BoundaryModelDummyParticles(plate; fluid_system,
+ hydrodynamic_mass=boundary_hydrodynamic_mass,
+ boundary_density_calculator,
+ viscosity,
+ clip_negative_pressure=true)
+boundary_system = WallBoundarySystem(plate, boundary_model; adhesion_coefficient)
+
+parallelization_backend = PolyesterBackend()
+semi = Semidiscretization(fluid_system, boundary_system; parallelization_backend)
+ode = semidiscretize(semi, tspan)
+
+info_callback = InfoCallback(interval=100)
+saving_callback = SolutionSavingCallback(dt=0.01)
+callbacks = CallbackSet(info_callback, saving_callback)
+
+sol = solve(ode, RDPK3SpFSAL35(); abstol=1e-7, reltol=1e-4, dtmax=1e-3,
+ save_everystep=false, saveat=solution_saveat, callback=callbacks)
diff --git a/examples/fluid/dam_break_2d.jl b/examples/fluid/dam_break_2d.jl
index 11ee987599..8f6a387221 100644
--- a/examples/fluid/dam_break_2d.jl
+++ b/examples/fluid/dam_break_2d.jl
@@ -65,12 +65,15 @@ viscosity_fluid = ArtificialViscosityMonaghan(; alpha, beta=0.0)
density_diffusion = DensityDiffusionMolteniColagrossi(delta=0.1)
# density_diffusion = DensityDiffusionAntuono(delta=0.1)
+surface_tension = nothing
+reference_particle_spacing = 0
+
fluid_system = WeaklyCompressibleSPHSystem(tank.fluid; smoothing_kernel, smoothing_length,
density_calculator=fluid_density_calculator,
state_equation, viscosity=viscosity_fluid,
density_diffusion, acceleration=(0.0, -gravity),
- correction=nothing, surface_tension=nothing,
- reference_particle_spacing=0)
+ correction=nothing, surface_tension,
+ reference_particle_spacing)
# ==========================================================================================
# ==== Boundary
@@ -80,12 +83,8 @@ viscosity_wall = nothing
# viscosity_wall = viscosity_fluid
# Clip negative boundary pressure values to avoid sticking artifacts at the boundary.
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- smoothing_kernel, smoothing_length;
- state_equation,
- correction=nothing,
- reference_particle_spacing=0,
viscosity=viscosity_wall,
clip_negative_pressure=true)
diff --git a/examples/fluid/dam_break_3d.jl b/examples/fluid/dam_break_3d.jl
index ae9b580960..0e5438ac53 100644
--- a/examples/fluid/dam_break_3d.jl
+++ b/examples/fluid/dam_break_3d.jl
@@ -57,10 +57,8 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid; smoothing_kernel, smoothi
boundary_density_calculator = AdamiPressureExtrapolation()
# Clip negative boundary pressure values to avoid sticking artifacts at the boundary.
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- smoothing_kernel, smoothing_length;
- state_equation,
clip_negative_pressure=true)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model)
diff --git a/examples/fluid/dam_break_oil_film_2d.jl b/examples/fluid/dam_break_oil_film_2d.jl
index 7a890eb918..3d57557097 100644
--- a/examples/fluid/dam_break_oil_film_2d.jl
+++ b/examples/fluid/dam_break_oil_film_2d.jl
@@ -33,12 +33,15 @@ nu_sim_water = nu_ratio * nu_sim_oil
oil_viscosity = ViscosityMorris(nu=nu_sim_oil)
-# TODO: broken if both systems use surface tension
+# A physically consistent two-phase surface-tension interaction requires an explicit
+# interface model. Until that model is available, this example focuses on density and
+# viscosity contrast and keeps surface tension disabled on both fluids.
+surface_tension = nothing
trixi_include(@__MODULE__, joinpath(examples_dir(), "fluid", "dam_break_2d.jl");
sol=nothing, fluid_particle_spacing, tspan,
viscosity_fluid=ViscosityMorris(nu=nu_sim_water), smoothing_length, gravity,
density_diffusion=nothing, sound_speed, prefix="",
- reference_particle_spacing=fluid_particle_spacing)
+ surface_tension)
# ==========================================================================================
# ==== Setup oil layer
@@ -65,18 +68,7 @@ oil_system = WeaklyCompressibleSPHSystem(oil;
state_equation=oil_eos,
viscosity=oil_viscosity,
acceleration=(0.0, -gravity),
- surface_tension=SurfaceTensionAkinci(surface_tension_coefficient=0.01),
- correction=AkinciFreeSurfaceCorrection(oil_density),
- reference_particle_spacing=fluid_particle_spacing)
-
-# oil_system = WeaklyCompressibleSPHSystem(oil;
-# smoothing_kernel, smoothing_length,
-# density_calculator=fluid_density_calculator,
-# state_equation=oil_eos,
-# viscosity=oil_viscosity,
-# acceleration=(0.0, -gravity),
-# surface_tension=SurfaceTensionMorris(surface_tension_coefficient=0.03),
-# reference_particle_spacing=fluid_particle_spacing)
+ surface_tension)
# ==========================================================================================
# ==== Simulation
diff --git a/examples/fluid/falling_water_column_2d.jl b/examples/fluid/falling_water_column_2d.jl
index 694c13ab76..c9c4b00fa5 100644
--- a/examples/fluid/falling_water_column_2d.jl
+++ b/examples/fluid/falling_water_column_2d.jl
@@ -55,10 +55,8 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid; smoothing_kernel, smoothi
boundary_density_calculator = AdamiPressureExtrapolation()
# Clip negative boundary pressure values to avoid sticking artifacts at the boundary.
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- smoothing_kernel, smoothing_length;
- state_equation,
clip_negative_pressure=true)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model)
diff --git a/examples/fluid/falling_water_spheres_2d.jl b/examples/fluid/falling_water_spheres_2d.jl
index 1f0016ac6d..6c7ffa5e83 100644
--- a/examples/fluid/falling_water_spheres_2d.jl
+++ b/examples/fluid/falling_water_spheres_2d.jl
@@ -1,9 +1,8 @@
# ==========================================================================================
-# 2D Falling Water Spheres Simulation (With and Without Surface Tension)
+# 2D Falling Water Spheres Simulation with Surface Tension
#
# This example simulates two circular water "spheres" falling under gravity.
-# One sphere includes a surface tension model (Akinci et al.), while the other does not.
-# This demonstrates the effect of surface tension on fluid behavior.
+# Both spheres belong to one fluid system and use the same surface tension model.
# ==========================================================================================
using TrixiParticles
@@ -44,6 +43,7 @@ sphere1 = SphereShape(fluid_particle_spacing, sphere_radius, sphere1_center,
fluid_density, sphere_type=VoxelSphere(), velocity=(0.0, -3.0))
sphere2 = SphereShape(fluid_particle_spacing, sphere_radius, sphere2_center,
fluid_density, sphere_type=VoxelSphere(), velocity=(0.0, -3.0))
+falling_spheres = isnothing(sphere2) ? sphere1 : union(sphere1, sphere2)
# ==========================================================================================
# ==== Fluid
@@ -53,28 +53,26 @@ sphere2 = SphereShape(fluid_particle_spacing, sphere_radius, sphere2_center,
fluid_smoothing_length = 1.0 * fluid_particle_spacing - eps()
fluid_smoothing_kernel = SchoenbergCubicSplineKernel{2}()
-fluid_density_calculator = ContinuityDensity()
-
nu = 0.005
alpha = 8 * nu / (fluid_smoothing_length * sound_speed)
viscosity = ArtificialViscosityMonaghan(; alpha, beta=0.0)
-density_diffusion = DensityDiffusionAntuono(delta=0.1)
-surface_tension_coefficient = 0.05
+
+# Preserve the pairwise cohesion and adhesion strengths configured with the previous
+# 3D-normalized 2D kernels at the default compact-support radius of 0.01 m.
+akinci_reference_support_radius = 0.01
+akinci_cohesion_migration = 627 / (790 * akinci_reference_support_radius)
+akinci_adhesion_migration = 42 / (65 * akinci_reference_support_radius)
+surface_tension_coefficient = 0.05 * akinci_cohesion_migration
surface_tension = SurfaceTensionAkinci(; surface_tension_coefficient)
+reference_particle_spacing = isnothing(surface_tension) ? 0 : fluid_particle_spacing
-sphere_surface_tension = EntropicallyDampedSPHSystem(sphere1;
+sphere_surface_tension = EntropicallyDampedSPHSystem(falling_spheres;
smoothing_kernel=fluid_smoothing_kernel,
smoothing_length=fluid_smoothing_length,
sound_speed, viscosity,
density_calculator=ContinuityDensity(),
acceleration, surface_tension,
- reference_particle_spacing=fluid_particle_spacing)
-
-sphere = WeaklyCompressibleSPHSystem(sphere2; smoothing_kernel=fluid_smoothing_kernel,
- smoothing_length=fluid_smoothing_length,
- density_calculator=fluid_density_calculator,
- state_equation, viscosity, density_diffusion,
- acceleration)
+ reference_particle_spacing)
# ==========================================================================================
# ==== Boundary
@@ -82,20 +80,19 @@ boundary_density_calculator = AdamiPressureExtrapolation()
wall_viscosity = nu
# Clip negative boundary pressure values to avoid sticking artifacts at the boundary.
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary;
+ fluid_system=sphere_surface_tension,
boundary_density_calculator,
- fluid_smoothing_kernel, fluid_smoothing_length;
state_equation,
viscosity=ViscosityAdami(nu=wall_viscosity),
- reference_particle_spacing=fluid_particle_spacing,
clip_negative_pressure=true)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model;
- adhesion_coefficient=1.0)
+ adhesion_coefficient=1.0 * akinci_adhesion_migration)
# ==========================================================================================
# ==== Simulation
-semi = Semidiscretization(sphere_surface_tension, sphere, boundary_system)
+semi = Semidiscretization(sphere_surface_tension, boundary_system)
ode = semidiscretize(semi, tspan)
info_callback = InfoCallback(interval=1000)
diff --git a/examples/fluid/falling_water_spheres_3d.jl b/examples/fluid/falling_water_spheres_3d.jl
index 511927145f..84db78fe13 100644
--- a/examples/fluid/falling_water_spheres_3d.jl
+++ b/examples/fluid/falling_water_spheres_3d.jl
@@ -1,10 +1,8 @@
# ==========================================================================================
-# 3D Falling Water Spheres Simulation (With and Without Surface Tension)
+# 3D Falling Water Spheres Simulation with Surface Tension
#
# This example extends `falling_water_spheres_2d.jl` to three dimensions.
-# It simulates two spherical volumes of water falling under gravity.
-# One sphere includes a surface tension model, while the other does not,
-# demonstrating the effect of surface tension in 3D.
+# It simulates two spherical volumes of water in one fluid system falling under gravity.
# ==========================================================================================
using TrixiParticles
diff --git a/examples/fluid/hydrostatic_water_column_2d.jl b/examples/fluid/hydrostatic_water_column_2d.jl
index 58b381c0c0..93e6665c8f 100644
--- a/examples/fluid/hydrostatic_water_column_2d.jl
+++ b/examples/fluid/hydrostatic_water_column_2d.jl
@@ -60,10 +60,8 @@ boundary_density_calculator = AdamiPressureExtrapolation()
# This is to set wall viscosity with `trixi_include`
viscosity_wall = nothing
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- smoothing_kernel, smoothing_length;
- state_equation,
viscosity=viscosity_wall)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model,
prescribed_motion=nothing)
diff --git a/examples/fluid/lid_driven_cavity_2d.jl b/examples/fluid/lid_driven_cavity_2d.jl
index c1aa4ae2a8..747caf2763 100644
--- a/examples/fluid/lid_driven_cavity_2d.jl
+++ b/examples/fluid/lid_driven_cavity_2d.jl
@@ -84,20 +84,14 @@ is_moving(t) = true
lid_movement = PrescribedMotion(lid_movement_function, is_moving)
-boundary_model_cavity = BoundaryModelDummyParticles(cavity.boundary.density,
- cavity.boundary.mass,
- AdamiPressureExtrapolation(),
- smoothing_kernel, smoothing_length;
- viscosity, state_equation)
-
-boundary_model_lid = BoundaryModelDummyParticles(lid.density, lid.mass,
- AdamiPressureExtrapolation(),
- smoothing_kernel, smoothing_length;
- viscosity, state_equation)
-
-boundary_system_cavity = WallBoundarySystem(cavity.boundary, boundary_model_cavity)
-
-boundary_system_lid = WallBoundarySystem(lid, boundary_model_lid,
+cavity_boundary_model = BoundaryModelDummyParticles(cavity.boundary;
+ fluid_system=fluid_system,
+ viscosity=viscosity)
+boundary_system_cavity = WallBoundarySystem(cavity.boundary, cavity_boundary_model)
+
+lid_boundary_model = BoundaryModelDummyParticles(lid; fluid_system=fluid_system,
+ viscosity=viscosity)
+boundary_system_lid = WallBoundarySystem(lid, lid_boundary_model,
prescribed_motion=lid_movement)
# ==========================================================================================
diff --git a/examples/fluid/moving_wall_2d.jl b/examples/fluid/moving_wall_2d.jl
index 6e0b0f9dfe..253386981c 100644
--- a/examples/fluid/moving_wall_2d.jl
+++ b/examples/fluid/moving_wall_2d.jl
@@ -59,10 +59,8 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid; smoothing_kernel, smoothi
# ==========================================================================================
# ==== Boundary
boundary_density_calculator = AdamiPressureExtrapolation()
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
- boundary_density_calculator,
- smoothing_kernel, smoothing_length;
- state_equation)
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
+ boundary_density_calculator)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model,
prescribed_motion=boundary_movement)
diff --git a/examples/fluid/periodic_array_of_cylinders_2d.jl b/examples/fluid/periodic_array_of_cylinders_2d.jl
index f2899b6bfd..ec1344eafc 100644
--- a/examples/fluid/periodic_array_of_cylinders_2d.jl
+++ b/examples/fluid/periodic_array_of_cylinders_2d.jl
@@ -71,10 +71,8 @@ fluid_system = WeaklyCompressibleSPHSystem(fluid; smoothing_kernel, smoothing_le
# ==========================================================================================
# ==== Boundary
-boundary_model = BoundaryModelDummyParticles(boundary.density, boundary.mass,
- AdamiPressureExtrapolation(), smoothing_kernel,
- smoothing_length;
- viscosity=ViscosityAdami(; nu), state_equation)
+boundary_model = BoundaryModelDummyParticles(boundary; fluid_system=fluid_system,
+ viscosity=ViscosityAdami(; nu))
boundary_system = WallBoundarySystem(boundary, boundary_model)
diff --git a/examples/fluid/periodic_channel_2d.jl b/examples/fluid/periodic_channel_2d.jl
index 9635f48a78..097e9b5f16 100644
--- a/examples/fluid/periodic_channel_2d.jl
+++ b/examples/fluid/periodic_channel_2d.jl
@@ -59,10 +59,8 @@ viscosity_wall = nothing
# Activate to switch to no-slip walls
#viscosity_wall = ViscosityAdami(nu=0.0025 * smoothing_length * sound_speed / 8)
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- smoothing_kernel, smoothing_length;
- state_equation,
viscosity=viscosity_wall)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model)
diff --git a/examples/fluid/pipe_flow_2d.jl b/examples/fluid/pipe_flow_2d.jl
index 6022b980e4..1539d9c0f0 100644
--- a/examples/fluid/pipe_flow_2d.jl
+++ b/examples/fluid/pipe_flow_2d.jl
@@ -136,16 +136,13 @@ outflow = BoundaryZone(; boundary_face=face_out, face_normal=(-flow_direction),
initial_condition=outlet.fluid, boundary_type=boundary_type_out)
open_boundary = OpenBoundarySystem(inflow, outflow; fluid_system,
- boundary_model=open_boundary_model,
- buffer_size=n_buffer_particles)
+ boundary_model=open_boundary_model)
# ==========================================================================================
# ==== Boundary
wall = union(pipe.boundary, inlet.boundary, outlet.boundary)
viscosity_boundary = viscosity
-boundary_model = BoundaryModelDummyParticles(wall.density, wall.mass,
- AdamiPressureExtrapolation(), smoothing_kernel,
- smoothing_length; state_equation,
+boundary_model = BoundaryModelDummyParticles(wall; fluid_system=fluid_system,
viscosity=viscosity_boundary)
boundary_system = WallBoundarySystem(wall, boundary_model)
diff --git a/examples/fluid/poiseuille_flow_2d.jl b/examples/fluid/poiseuille_flow_2d.jl
index ba36a2f471..175571a8f2 100644
--- a/examples/fluid/poiseuille_flow_2d.jl
+++ b/examples/fluid/poiseuille_flow_2d.jl
@@ -145,16 +145,14 @@ outlet_boundary_zone = BoundaryZone(; boundary_face=outlet_face,
open_boundary = OpenBoundarySystem(inlet_boundary_zone, outlet_boundary_zone; fluid_system,
boundary_model=open_boundary_model,
- calculate_flow_rate=true,
- buffer_size=n_buffer_particles)
+ calculate_flow_rate=true)
# ==========================================================================================
# ==== Boundary
wall_boundary = union(channel.boundary)
-boundary_model = BoundaryModelDummyParticles(wall_boundary.density, wall_boundary.mass,
- AdamiPressureExtrapolation(), smoothing_kernel,
- smoothing_length; state_equation, viscosity)
+boundary_model = BoundaryModelDummyParticles(wall_boundary; fluid_system=fluid_system,
+ viscosity)
boundary_system = WallBoundarySystem(wall_boundary, boundary_model)
diff --git a/examples/fluid/poiseuille_flow_3d.jl b/examples/fluid/poiseuille_flow_3d.jl
index 4459df0648..7d76cc8ffb 100644
--- a/examples/fluid/poiseuille_flow_3d.jl
+++ b/examples/fluid/poiseuille_flow_3d.jl
@@ -162,14 +162,12 @@ outlet_zone = BoundaryZone(; boundary_face=outlet_face,
boundary_type=outlet_boundary_type)
open_boundary = OpenBoundarySystem(inlet_zone, outlet_zone; fluid_system,
- boundary_model=open_boundary_model,
- buffer_size=n_buffer_particles)
+ boundary_model=open_boundary_model)
# ==========================================================================================
# ==== Boundary
-boundary_model = BoundaryModelDummyParticles(wall_boundary.density, wall_boundary.mass,
- AdamiPressureExtrapolation(), smoothing_kernel,
- smoothing_length; state_equation, viscosity)
+boundary_model = BoundaryModelDummyParticles(wall_boundary; fluid_system=fluid_system,
+ viscosity)
boundary_system = WallBoundarySystem(wall_boundary, boundary_model)
diff --git a/examples/fluid/sphere_surface_tension_2d.jl b/examples/fluid/sphere_surface_tension_2d.jl
index 1abcdcb149..7b32ba3a3a 100644
--- a/examples/fluid/sphere_surface_tension_2d.jl
+++ b/examples/fluid/sphere_surface_tension_2d.jl
@@ -15,15 +15,15 @@ particle_spacing = 0.05
# Use a higher resolution for a better result
# particle_spacing = 0.025
-# Note: Only square shapes will result in a sphere.
-# Furthermore, changes of the coefficients might be necessary for higher resolutions or larger squares.
+# Note: Only square shapes will result in a sphere. A higher resolution improves the interface
+# without requiring a resolution-dependent Akinci coefficient.
fluid_size = (0.5, 0.5)
sound_speed = 20.0
state_equation = StateEquationCole(; sound_speed, reference_density=fluid_density,
exponent=7, clip_negative_pressure=true)
-# For all surface tension simulations, we need a compact support of `2 * particle_spacing`
+# The surface tension configurations below use a compact support of `2 * particle_spacing`.
# smoothing_length = particle_spacing
# smoothing_kernel = WendlandC2Kernel{2}()
# nu = 0.01
@@ -38,11 +38,14 @@ fluid = RectangularShape(particle_spacing, round.(Int, fluid_size ./ particle_sp
alpha = 8 * nu / (smoothing_length * sound_speed)
source_terms = SourceTermDamping(; damping_coefficient=0.5)
+# The coefficient below is migrated from 0.02 at a compact-support radius of 0.1 m.
+# akinci_surface_tension = SurfaceTensionAkinci(
+# surface_tension_coefficient=0.15873417721518987)
# fluid_system = WeaklyCompressibleSPHSystem(fluid; smoothing_kernel=fluid_smoothing_kernel,
# smoothing_length, density_calculator=SummationDensity(),
# state_equation, reference_particle_spacing=particle_spacing,
# viscosity=ArtificialViscosityMonaghan(; alpha, beta=0.0),
-# surface_tension=SurfaceTensionAkinci(surface_tension_coefficient=0.02),
+# surface_tension=akinci_surface_tension,
# correction=AkinciFreeSurfaceCorrection(fluid_density),
# source_terms)
diff --git a/examples/fluid/sphere_surface_tension_3d.jl b/examples/fluid/sphere_surface_tension_3d.jl
index 7cc8ce78af..6e759bc3a3 100644
--- a/examples/fluid/sphere_surface_tension_3d.jl
+++ b/examples/fluid/sphere_surface_tension_3d.jl
@@ -17,7 +17,7 @@ fluid_size = (0.9, 0.9, 0.9)
sound_speed = 20.0
-# For all surface tension simulations, we need a compact support of `2 * particle_spacing`
+# The surface tension configurations below use a compact support of `2 * particle_spacing`.
smoothing_length = 1.0 * particle_spacing
nu = 0.04
diff --git a/examples/fluid/sphere_surface_tension_wall_2d.jl b/examples/fluid/sphere_surface_tension_wall_2d.jl
index 5065536c93..b3ef8de88d 100644
--- a/examples/fluid/sphere_surface_tension_wall_2d.jl
+++ b/examples/fluid/sphere_surface_tension_wall_2d.jl
@@ -48,21 +48,32 @@ fluid_smoothing_kernel = SchoenbergCubicSplineKernel{2}()
nu = 0.001
alpha = 8 * nu / (fluid_smoothing_length * sound_speed)
-# `adhesion_coefficient = 1.0` and `surface_tension_coefficient = 0.01` for perfect wetting
-# `adhesion_coefficient = 0.001` and `surface_tension_coefficient = 2.0` for no wetting
+
+# Preserve the pairwise cohesion and adhesion strengths configured with the previous
+# 3D-normalized 2D kernels at the default compact-support radius of 0.005 m.
+akinci_reference_support_radius = 0.005
+akinci_cohesion_migration = 627 / (790 * akinci_reference_support_radius)
+akinci_adhesion_migration = 42 / (65 * akinci_reference_support_radius)
+# Use `adhesion_coefficient = 1.0 * akinci_adhesion_migration` and
+# `surface_tension_coefficient = 0.01 * akinci_cohesion_migration` for perfect wetting.
+# Use `adhesion_coefficient = 0.001 * akinci_adhesion_migration` and
+# `surface_tension_coefficient = 2.0 * akinci_cohesion_migration` for no wetting.
+surface_tension_coefficient = 2.0 * akinci_cohesion_migration
+adhesion_coefficient = 0.001 * akinci_adhesion_migration
viscosity = ArtificialViscosityMonaghan(; alpha, beta=0.0)
+surface_tension = SurfaceTensionAkinci(; surface_tension_coefficient)
sphere_surface_tension = WeaklyCompressibleSPHSystem(sphere1;
smoothing_kernel=fluid_smoothing_kernel,
smoothing_length=fluid_smoothing_length,
density_calculator=ContinuityDensity(),
state_equation, viscosity,
acceleration=(0.0, -gravity),
- surface_tension=SurfaceTensionAkinci(surface_tension_coefficient=2.0),
+ surface_tension,
correction=AkinciFreeSurfaceCorrection(fluid_density),
reference_particle_spacing=fluid_particle_spacing)
trixi_include(@__MODULE__, joinpath(examples_dir(), "fluid", "falling_water_spheres_2d.jl");
- sphere=nothing, sphere1, adhesion_coefficient=0.001, wall_viscosity=4.0 * nu,
+ sphere1, sphere2=nothing, adhesion_coefficient, wall_viscosity=4.0 * nu,
alpha, sound_speed, fluid_density, nu, fluid_particle_spacing, tspan,
tank_size, fluid_smoothing_length, sphere_surface_tension)
diff --git a/examples/fsi/dam_break_gate_2d.jl b/examples/fsi/dam_break_gate_2d.jl
index 49d72f2aee..9cc9e66271 100644
--- a/examples/fsi/dam_break_gate_2d.jl
+++ b/examples/fsi/dam_break_gate_2d.jl
@@ -120,20 +120,15 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid; smoothing_kernel, smoothi
boundary_density_calculator = AdamiPressureExtrapolation()
# Clip negative boundary pressure values to avoid sticking artifacts at the boundary.
-boundary_model_tank = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+tank_boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- smoothing_kernel, smoothing_length;
- state_equation,
clip_negative_pressure=true)
-
-boundary_model_gate = BoundaryModelDummyParticles(gate.density, gate.mass,
+gate_boundary_model = BoundaryModelDummyParticles(gate; fluid_system=fluid_system,
boundary_density_calculator,
- smoothing_kernel, smoothing_length;
- state_equation,
clip_negative_pressure=true)
-boundary_system_tank = WallBoundarySystem(tank.boundary, boundary_model_tank)
-boundary_system_gate = WallBoundarySystem(gate, boundary_model_gate,
+boundary_system_tank = WallBoundarySystem(tank.boundary, tank_boundary_model)
+boundary_system_gate = WallBoundarySystem(gate, gate_boundary_model,
prescribed_motion=gate_movement)
# ==========================================================================================
diff --git a/examples/fsi/dam_break_plate_2d.jl b/examples/fsi/dam_break_plate_2d.jl
index 00edc5c216..2bfd4f609e 100644
--- a/examples/fsi/dam_break_plate_2d.jl
+++ b/examples/fsi/dam_break_plate_2d.jl
@@ -94,10 +94,8 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid; smoothing_kernel, smoothi
boundary_density_calculator = AdamiPressureExtrapolation()
# Clip negative boundary pressure values to avoid sticking artifacts at the boundary.
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- smoothing_kernel, smoothing_length;
- state_equation,
clip_negative_pressure=true)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model)
diff --git a/examples/fsi/falling_rigid_spheres_2d.jl b/examples/fsi/falling_rigid_spheres_2d.jl
index 7eaa2b4dcb..59c05ce448 100644
--- a/examples/fsi/falling_rigid_spheres_2d.jl
+++ b/examples/fsi/falling_rigid_spheres_2d.jl
@@ -70,10 +70,8 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid;
boundary_density_calculator = AdamiPressureExtrapolation()
# Clip negative boundary pressure values to avoid sticking artifacts at the boundary.
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- fluid_smoothing_kernel, fluid_smoothing_length;
- state_equation,
clip_negative_pressure=true)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model)
diff --git a/examples/fsi/falling_rotating_rigid_squares_2d.jl b/examples/fsi/falling_rotating_rigid_squares_2d.jl
index 4a14905cf0..0ab46fa9fd 100644
--- a/examples/fsi/falling_rotating_rigid_squares_2d.jl
+++ b/examples/fsi/falling_rotating_rigid_squares_2d.jl
@@ -86,10 +86,9 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid;
boundary_density_calculator = AdamiPressureExtrapolation()
# Clip negative boundary pressure values to avoid sticking artifacts at the boundary.
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- fluid_smoothing_kernel, fluid_smoothing_length;
- state_equation, clip_negative_pressure=true)
+ clip_negative_pressure=true)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model)
diff --git a/examples/fsi/falling_spheres_2d.jl b/examples/fsi/falling_spheres_2d.jl
index 035f9ad3da..136161624f 100644
--- a/examples/fsi/falling_spheres_2d.jl
+++ b/examples/fsi/falling_spheres_2d.jl
@@ -74,10 +74,8 @@ fluid_system = WeaklyCompressibleSPHSystem(tank.fluid;
boundary_density_calculator = BernoulliPressureExtrapolation()
# Clip negative boundary pressure values to avoid sticking artifacts at the boundary.
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- fluid_smoothing_kernel, fluid_smoothing_length;
- state_equation,
clip_negative_pressure=true)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model)
diff --git a/examples/fsi/hydrostatic_water_column_2d.jl b/examples/fsi/hydrostatic_water_column_2d.jl
index 9fd7e15a36..3815a28d68 100644
--- a/examples/fsi/hydrostatic_water_column_2d.jl
+++ b/examples/fsi/hydrostatic_water_column_2d.jl
@@ -113,9 +113,8 @@ else
damping_coefficient=0.05))
end
-boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass,
+boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system,
boundary_density_calculator,
- smoothing_kernel, smoothing_length_fluid;
state_equation)
boundary_system = WallBoundarySystem(tank.boundary, boundary_model)
boundary_model_structure = BoundaryModelDummyParticles(hydrodynamic_densities,
diff --git a/examples/preprocessing/complex_shape_2d.jl b/examples/preprocessing/complex_shape_2d.jl
index fa2b762988..62de84ad49 100644
--- a/examples/preprocessing/complex_shape_2d.jl
+++ b/examples/preprocessing/complex_shape_2d.jl
@@ -7,8 +7,8 @@
# 3. Utilize the Winding Number algorithm to determine if points are inside or outside.
# 4. Visualize the sampled particles and the winding number field.
#
-# The example uses an "inverted_open_curve" geometry, where standard inside/outside
-# definitions might be ambiguous without a robust point-in-polygon test like winding numbers.
+# The example uses a polygonal star geometry, where standard inside/outside
+# definitions benefit from a robust point-in-polygon test like winding numbers.
# ==========================================================================================
using TrixiParticles
@@ -16,7 +16,7 @@ using Plots
particle_spacing = 0.05
-filename = "inverted_open_curve"
+filename = "star"
file = joinpath("examples", "preprocessing", "data", filename * ".asc")
geometry = load_geometry(file)
@@ -24,7 +24,6 @@ geometry = load_geometry(file)
trixi2vtk(geometry)
point_in_geometry_algorithm = WindingNumberJacobson(; geometry,
- winding_number_factor=0.4,
hierarchical_winding=true)
# Returns `InitialCondition`
diff --git a/examples/preprocessing/data/coastline_profile_2d.asc b/examples/preprocessing/data/coastline_profile_2d.asc
new file mode 100644
index 0000000000..c250c87be4
--- /dev/null
+++ b/examples/preprocessing/data/coastline_profile_2d.asc
@@ -0,0 +1,20 @@
+# ASCII
+0.15 -0.12 0
+2.68 -0.12 0
+2.68 1.08 0
+2.62 0.66 0
+2.53 0.52 0
+2.42 0.40 0
+2.30 0.42 0
+2.18 0.33 0
+2.05 0.24 0
+1.92 0.26 0
+1.78 0.18 0
+1.62 0.11 0
+1.46 0.14 0
+1.28 0.06 0
+1.05 0.02 0
+0.82 0.05 0
+0.55 0.03 0
+0.15 0.03 0
+0.15 -0.12 0
diff --git a/examples/preprocessing/data/curved_pipe_channel_2d.asc b/examples/preprocessing/data/curved_pipe_channel_2d.asc
new file mode 100644
index 0000000000..76a1d7b8fe
--- /dev/null
+++ b/examples/preprocessing/data/curved_pipe_channel_2d.asc
@@ -0,0 +1,20 @@
+# ASCII
+0.00 0.12 0
+0.60 0.12 0
+0.72423 0.13646 0
+0.84000 0.18431 0
+0.93941 0.26059 0
+1.01569 0.36000 0
+1.06354 0.47577 0
+1.08 0.60 0
+1.08 1.20 0
+0.72 1.20 0
+0.72 0.60 0
+0.71591 0.56894 0
+0.70392 0.54000 0
+0.68485 0.51515 0
+0.66000 0.49608 0
+0.63106 0.48409 0
+0.60 0.48 0
+0.00 0.48 0
+0.00 0.12 0
diff --git a/examples/preprocessing/data/curved_pipe_outer_2d.asc b/examples/preprocessing/data/curved_pipe_outer_2d.asc
new file mode 100644
index 0000000000..1b26afac92
--- /dev/null
+++ b/examples/preprocessing/data/curved_pipe_outer_2d.asc
@@ -0,0 +1,14 @@
+# ASCII
+0.00 0.00 0
+0.60 0.00 0
+0.75529 0.02044 0
+0.90000 0.08038 0
+1.02426 0.17574 0
+1.11962 0.30000 0
+1.17956 0.44471 0
+1.20 0.60 0
+1.20 1.20 0
+0.60 1.20 0
+0.60 0.60 0
+0.00 0.60 0
+0.00 0.00 0
diff --git a/examples/preprocessing/packing_2d.jl b/examples/preprocessing/packing_2d.jl
index fcf0d40c64..7055a2dd98 100644
--- a/examples/preprocessing/packing_2d.jl
+++ b/examples/preprocessing/packing_2d.jl
@@ -71,7 +71,8 @@ packing_system = ParticlePackingSystem(shape_sampled; smoothing_length,
boundary_system = ParticlePackingSystem(boundary_sampled; smoothing_length,
is_boundary=true, signed_distance_field,
- place_on_shell, boundary_compress_factor=0.8,
+ place_on_shell, boundary_thickness,
+ boundary_compress_factor=0.8,
background_pressure)
# ==========================================================================================
diff --git a/ext/TrixiParticlesMakieExt.jl b/ext/TrixiParticlesMakieExt.jl
new file mode 100644
index 0000000000..17702e66e1
--- /dev/null
+++ b/ext/TrixiParticlesMakieExt.jl
@@ -0,0 +1,90 @@
+module TrixiParticlesMakieExt
+
+using Makie
+using TrixiParticles
+
+const TP = TrixiParticles
+
+function default_system_color(system, system_index)
+ if system isa TP.AbstractFluidSystem
+ return Makie.RGBf(0.02, 0.32, 0.85)
+ elseif system isa TP.AbstractBoundarySystem
+ return Makie.RGBf(0.62, 0.66, 0.72)
+ elseif system isa TP.AbstractStructureSystem
+ return Makie.RGBf(0.95, 0.48, 0.08)
+ elseif system isa TP.OpenBoundarySystem
+ return Makie.RGBf(0.30, 0.58, 0.82)
+ end
+
+ return Makie.wong_colors()[mod1(system_index, length(Makie.wong_colors()))]
+end
+
+function default_marker_size_scale(system, system_index)
+ if system isa TP.AbstractBoundarySystem || system isa TP.OpenBoundarySystem
+ return 0.55
+ end
+
+ return 0.9
+end
+
+@inline function style_value(style::Function, system, system_index)
+ return style(system, system_index)
+end
+
+@inline function style_value(style::AbstractVector, system, system_index)
+ return style[system_index]
+end
+
+@inline style_value(style, system, system_index) = style
+
+function TP.trixi2makie(scene, solution::TP.TrixiParticlesODESolution;
+ frame=lastindex(solution.u), kwargs...)
+ v_ode, u_ode = solution.u[frame].x
+ semi = solution.prob.p.semi
+
+ return TP.trixi2makie(scene, v_ode, u_ode, semi; kwargs...)
+end
+
+function TP.trixi2makie(scene, v_ode::AbstractArray, u_ode::AbstractArray,
+ semi::TP.Semidiscretization;
+ system_indices=eachindex(semi.systems),
+ system_colors=default_system_color,
+ marker_size_scales=default_marker_size_scale,
+ kwargs...)
+ plots = Any[]
+ marker = Makie.Sphere(Makie.Point3f(0), 0.5f0)
+ for system_index in system_indices
+ system = semi.systems[system_index]
+ particles = TP.eachparticle(system)
+ isempty(particles) && continue
+
+ u = TP.wrap_u(u_ode, system, semi)
+ coordinates = Array(TP.active_coordinates(u, system))
+ points = makie_points(coordinates)
+ spacing = TP.particle_spacing(system, first(particles))
+ color = style_value(system_colors, system, system_index)
+ marker_size_scale = style_value(marker_size_scales, system, system_index)
+
+ plot = Makie.meshscatter!(scene, points; marker,
+ markersize=marker_size_scale * spacing,
+ color, kwargs...)
+ push!(plots, plot)
+ end
+
+ return plots
+end
+
+function makie_points(coordinates)
+ if size(coordinates, 1) == 2
+ return [Makie.Point3f(coordinates[1, particle], coordinates[2, particle], 0)
+ for particle in axes(coordinates, 2)]
+ elseif size(coordinates, 1) == 3
+ return [Makie.Point3f(coordinates[1, particle], coordinates[2, particle],
+ coordinates[3, particle])
+ for particle in axes(coordinates, 2)]
+ end
+
+ throw(ArgumentError("Makie visualization is only supported in two or three dimensions"))
+end
+
+end # module
diff --git a/src/TrixiParticles.jl b/src/TrixiParticles.jl
index 7101be8e49..f0ebd37ad5 100644
--- a/src/TrixiParticles.jl
+++ b/src/TrixiParticles.jl
@@ -16,12 +16,13 @@ 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
using RecipesBase: RecipesBase, @series
-using Random: seed!
+using Random: MersenneTwister
using SciMLBase: SciMLBase, CallbackSet, DiscreteCallback, DynamicalODEProblem,
derivative_discontinuity!, get_tmp_cache, set_proposed_dt!,
ODESolution, ODEProblem, terminate!, add_tstop!
@@ -64,6 +65,7 @@ include("preprocessing/preprocessing.jl")
include("io/io.jl")
include("general/restart.jl")
include("visualization/recipes_plots.jl")
+include("visualization/makie.jl")
export Semidiscretization, semidiscretize, restart_with!
export InitialCondition, apply_angular_velocity
@@ -80,6 +82,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,
@@ -88,7 +91,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,
@@ -99,10 +102,11 @@ export PrescribedMotion, OscillatingMotion2D
export RCRWindkesselModel
export examples_dir, validation_dir
export trixi2vtk, vtk2trixi
+export trixi2makie
export RectangularTank, RectangularShape, SphereShape, ComplexShape
export ParticlePackingSystem, SignedDistanceField
export WindingNumberHormann, WindingNumberJacobson
-export VoxelSphere, RoundSphere, reset_wall!, extrude_geometry, load_geometry,
+export VoxelSphere, RoundSphere, reset_wall!, extrude_geometry, load_geometry, delete_faces,
sample_boundary, planar_geometry_to_face
export SourceTermDamping
export ShepardKernelCorrection, KernelCorrection, AkinciFreeSurfaceCorrection,
@@ -112,9 +116,10 @@ export available_data, kinetic_energy, total_mass, max_pressure, min_pressure, a
max_density, min_density, avg_density
export interpolate_line, interpolate_points, interpolate_plane_3d, interpolate_plane_2d,
interpolate_plane_2d_vtk
-export SurfaceTensionAkinci, CohesionForceAkinci, SurfaceTensionMorris,
+export SurfaceTensionAkinci, CohesionForceAkinci,
+ SurfaceTensionAkinciCohesionPhysical, SurfaceTensionMorris,
SurfaceTensionMomentumMorris
-export ColorfieldSurfaceNormal
+export ColorfieldSurfaceNormal, CorrectedCSFSurfaceNormal, WettedAreaContactAngle
export SymplecticPositionVerlet
export coordinates_eltype
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/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/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/general/corrections.jl b/src/general/corrections.jl
index db82309881..2f9f9a4093 100644
--- a/src/general/corrections.jl
+++ b/src/general/corrections.jl
@@ -3,12 +3,12 @@
AkinciFreeSurfaceCorrection(rho0)
Free surface correction according to [Akinci et al. (2013)](@cite Akinci2013).
-At a free surface, the mean density is typically lower than the reference density,
-resulting in reduced surface tension and viscosity forces.
-The free surface correction adjusts the viscosity, pressure, and surface tension forces
-near free surfaces to counter this effect.
-It's important to note that this correlation is unphysical and serves as an approximation.
-The computation time added by this method is about 2--3%.
+At a free surface, particle-neighborhood deficiency reduces the pairwise surface tension and
+viscosity forces. This correction applies the symmetrized factor from Equation 4 to the combined
+cohesion and curvature force in Equation 5 and, as specified in Section 4, to viscosity. It does
+not modify pressure forces.
+The published implementation reports about 2--3% overhead for evaluating the correction from
+an already available density estimate.
Mathematically the idea is quite simple. If we have an SPH particle in the middle of a volume
at rest, its density will be identical to the rest density ``\rho_0``. If we now consider an SPH
@@ -18,7 +18,13 @@ the surface, which will result in a lower density. If we calculate the correctio
k = \rho_0/\rho_\text{mean},
```
this value will be about ~1.5 for particles at the free surface and can then be used to increase
-the pressure and viscosity accordingly.
+the surface tension and viscosity forces accordingly.
+
+With [`SummationDensity`](@ref), the correction uses the current density directly. When used with
+[`ContinuityDensity`](@ref) in a [`WeaklyCompressibleSPHSystem`](@ref), TrixiParticles.jl
+reconstructs an auxiliary summation density for this correction while pressure continues to use
+the integrated density. This preserves the particle-neighborhood-deficiency behavior of the
+published model at the cost of one additional density summation per update stage.
# Arguments
- `rho0`: Rest density.
@@ -40,7 +46,7 @@ end
rho_mean = (rho_a + rho_b) / 2
k = correction.rho0 / rho_mean
- # Viscosity, pressure, surface_tension
+ # Equation 5 applies `k` to surface tension; Section 4 also applies it to viscosity.
return k, 1, k
end
@@ -434,6 +440,10 @@ end
create_cache_correction(correction, density, NDIMS, nparticles) = (;)
+function create_cache_correction(::AkinciFreeSurfaceCorrection, density, NDIMS, n_particles)
+ return (; kernel_summation_density=similar(density))
+end
+
function create_cache_correction(::ShepardKernelCorrection, density, NDIMS, n_particles)
return (; kernel_correction_coefficient=similar(density))
end
diff --git a/src/general/custom_quantities.jl b/src/general/custom_quantities.jl
index 3f1f74c637..0b28b48bdb 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/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
diff --git a/src/io/io.jl b/src/io/io.jl
index 692dfd8528..f1d65911fb 100644
--- a/src/io/io.jl
+++ b/src/io/io.jl
@@ -315,11 +315,32 @@ function add_system_data!(system_data,
system_data["surface_tension"]["surface_tension_coefficient"] = surface_tension.surface_tension_coefficient
end
+function add_system_data!(system_data,
+ surface_tension::SurfaceTensionAkinciCohesionPhysical)
+ system_data["surface_tension"] = Dict{String, Any}()
+ system_data["surface_tension"]["model"] = type2string(surface_tension)
+ system_data["surface_tension"]["surface_tension_coefficient"] = surface_tension.surface_tension_coefficient
+ system_data["surface_tension"]["reference_density"] = surface_tension.reference_density
+end
+
function add_system_data!(system_data, surface_normal_method::ColorfieldSurfaceNormal)
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
+ boundary_contact_threshold = surface_normal_method.boundary_contact_threshold
+ system_data["surface_normal_method"]["boundary_contact_threshold"] = isfinite(boundary_contact_threshold) ?
+ boundary_contact_threshold :
+ string(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
+ contact_model = surface_normal_method.contact_model
+ system_data["surface_normal_method"]["contact_model"] = isnothing(contact_model) ?
+ nothing :
+ type2string(contact_model)
+ system_data["surface_normal_method"]["contact_angle"] = isnothing(contact_model) ?
+ nothing :
+ contact_model.contact_angle
end
function add_system_data!(system_data, boundary_zone::BoundaryZone, indice)
@@ -358,6 +379,9 @@ 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)
+ if !isnothing(shifting_technique.free_surface_treatment)
+ system_data["shifting_technique"]["free_surface_treatment"] = type2string(shifting_technique.free_surface_treatment)
+ end
end
function add_system_data!(system_data, viscosity::ViscosityCarreauYasuda)
diff --git a/src/io/write_vtk.jl b/src/io/write_vtk.jl
index fa3fb80985..2c9cd9afe3 100644
--- a/src/io/write_vtk.jl
+++ b/src/io/write_vtk.jl
@@ -339,33 +339,55 @@ 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
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
- 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/preprocessing/geometries/geometries.jl b/src/preprocessing/geometries/geometries.jl
index c1c0948522..5336c10c67 100644
--- a/src/preprocessing/geometries/geometries.jl
+++ b/src/preprocessing/geometries/geometries.jl
@@ -4,6 +4,70 @@ include("io.jl")
@inline eachface(mesh) = Base.OneTo(nfaces(mesh))
+"""
+ is_closed_geometry(geometry)
+
+Return `true` if a polygon or triangle mesh forms a closed region or surface.
+"""
+function is_closed_geometry(polygon::Polygon)
+ vertex_degrees = polygon_vertex_degrees(polygon)
+
+ return !isempty(vertex_degrees) && all(==(2), values(vertex_degrees))
+end
+
+function polygon_vertex_degrees(polygon)
+ VERTEX = typeof(first(first(polygon.edge_vertices)))
+ vertex_degrees = Dict{VERTEX, Int}()
+
+ for edge in polygon.edge_vertices
+ for vertex in edge
+ vertex_degrees[vertex] = get(vertex_degrees, vertex, 0) + 1
+ end
+ end
+
+ return vertex_degrees
+end
+
+function is_closed_geometry(mesh::TriangleMesh)
+ return all(==(2), edge_face_counts(mesh))
+end
+
+function require_closed_geometry(geometry, operation)
+ is_closed_geometry(geometry) && return nothing
+
+ msg = "`$operation` requires a closed geometry. " *
+ closure_error_detail(geometry)
+
+ throw(ArgumentError(msg))
+end
+
+function closure_error_detail(polygon::Polygon)
+ invalid_vertices = count(!=(2), values(polygon_vertex_degrees(polygon)))
+
+ return "Found $invalid_vertices polygon vertices with an incident-edge count " *
+ "different from 2. If the vertices already trace a complete 2D boundary, " *
+ "construct or load the geometry with `close_curve=true`; otherwise provide " *
+ "a closed boundary."
+end
+
+function closure_error_detail(mesh::TriangleMesh)
+ invalid_edges = count(!=(2), edge_face_counts(mesh))
+
+ return "Found $invalid_edges mesh edges with an incident-face count different from 2."
+end
+
+function edge_face_counts(mesh::TriangleMesh)
+ edge_face_counts = zeros(Int, length(mesh.edge_vertices_ids))
+
+ for face_edges in mesh.face_edges_ids
+ edge_face_counts[face_edges[1]] += 1
+ edge_face_counts[face_edges[2]] += 1
+ edge_face_counts[face_edges[3]] += 1
+ end
+
+ return edge_face_counts
+end
+
function Base.setdiff(initial_condition::InitialCondition,
geometries::Union{Polygon, TriangleMesh}...)
geometry = first(geometries)
@@ -11,6 +75,7 @@ function Base.setdiff(initial_condition::InitialCondition,
if ndims(geometry) != ndims(initial_condition)
throw(ArgumentError("all passed geometries must have the same dimensionality as the initial condition"))
end
+ require_closed_geometry(geometry, "setdiff")
coords = reinterpret(reshape,
SVector{ndims(geometry), eltype(initial_condition.coordinates)},
@@ -41,6 +106,7 @@ function Base.intersect(initial_condition::InitialCondition,
if ndims(geometry) != ndims(initial_condition)
throw(ArgumentError("all passed geometries must have the same dimensionality as the initial condition"))
end
+ require_closed_geometry(geometry, "intersect")
coords = reinterpret(reshape,
SVector{ndims(geometry), eltype(initial_condition.coordinates)},
diff --git a/src/preprocessing/geometries/io.jl b/src/preprocessing/geometries/io.jl
index bbccc276ec..a7c6106827 100644
--- a/src/preprocessing/geometries/io.jl
+++ b/src/preprocessing/geometries/io.jl
@@ -1,5 +1,5 @@
"""
- load_geometry(filename; element_type=Float64)
+ load_geometry(filename; element_type=Float64, close_curve=true)
Load file and return corresponding type for [`ComplexShape`](@ref).
Supported file formats are `.stl`, `.asc` and `dxf`.
@@ -18,16 +18,20 @@ For comprehensive information about the supported file formats, refer to the doc
# Keywords
- `element_type`: Element type (default is `Float64`)
+- `close_curve`: Close 2D `.asc` and `.dxf` curves by appending the first point
+ when it is not already repeated. This assumes the vertices already
+ trace a complete, ordered boundary. Set this to `false` for intentional
+ open curves. Region sampling and classification reject open geometries.
"""
-function load_geometry(filename; element_type=Float64)
+function load_geometry(filename; element_type=Float64, close_curve=true)
ELTYPE = element_type
file_extension = splitext(filename)[end]
if file_extension == ".asc"
- geometry = load_ascii(filename; ELTYPE, skipstart=1)
+ geometry = load_ascii(filename; ELTYPE, skipstart=1, close_curve)
elseif file_extension == ".dxf"
- geometry = load_dxf(filename; ELTYPE)
+ geometry = load_dxf(filename; ELTYPE, close_curve)
elseif file_extension == ".stl"
geometry = load(FileIO.query(filename); ELTYPE)
else
@@ -37,21 +41,21 @@ function load_geometry(filename; element_type=Float64)
return geometry
end
-function load_ascii(filename; ELTYPE=Float64, skipstart=1)
+function load_ascii(filename; ELTYPE=Float64, skipstart=1, close_curve=true)
# Read the data from the ASCII file in as a matrix of coordinates.
# Ignore the first `skipstart` lines of the file (e.g. headers).
points = DelimitedFiles.readdlm(filename, ' ', ELTYPE, '\n'; skipstart)[:, 1:2]
- return Polygon(copy(points'))
+ return Polygon(copy(points'); close_curve)
end
-function load_dxf(filename; ELTYPE=Float64)
+function load_dxf(filename; ELTYPE=Float64, close_curve=true)
points = Tuple{ELTYPE, ELTYPE}[]
load_dxf!(points, filename)
- return Polygon(stack(points))
+ return Polygon(stack(points); close_curve)
end
function load_dxf!(points::Vector{Tuple{T, T}}, filename) where {T}
diff --git a/src/preprocessing/geometries/polygon.jl b/src/preprocessing/geometries/polygon.jl
index c56315b729..c2614f4571 100644
--- a/src/preprocessing/geometries/polygon.jl
+++ b/src/preprocessing/geometries/polygon.jl
@@ -8,21 +8,30 @@ struct Polygon{NDIMS, ELTYPE}
min_corner :: SVector{NDIMS, ELTYPE}
max_corner :: SVector{NDIMS, ELTYPE}
- function Polygon(vertices)
+ function Polygon(vertices; close_curve=true)
NDIMS = size(vertices, 1)
- return Polygon{NDIMS}(vertices)
+ return Polygon{NDIMS}(vertices; close_curve)
end
# Function barrier to make `NDIMS` static and therefore `SVector`s type-stable
- function Polygon{NDIMS}(vertices_) where {NDIMS}
- n_vertices = size(vertices_, 2)
+ function Polygon{NDIMS}(vertices_; close_curve=true) where {NDIMS}
ELTYPE = eltype(vertices_)
- min_corner = SVector{NDIMS}(minimum(vertices_, dims=2))
- max_corner = SVector{NDIMS}(maximum(vertices_, dims=2))
+ vertices = collect(reinterpret(reshape, SVector{NDIMS, ELTYPE}, vertices_))
- vertices = reinterpret(reshape, SVector{NDIMS, ELTYPE}, vertices_)
+ if length(vertices) < 3
+ throw(ArgumentError("polygon requires at least three vertices"))
+ end
+
+ if close_curve && !isapprox(first(vertices), last(vertices))
+ push!(vertices, first(vertices))
+ end
+
+ n_vertices = length(vertices)
+
+ min_corner = SVector([minimum(v[i] for v in vertices) for i in 1:NDIMS]...)
+ max_corner = SVector([maximum(v[i] for v in vertices) for i in 1:NDIMS]...)
# Sum over all the edges and determine if the vertices are in clockwise order
# to make sure that all normals pointing outwards.
@@ -63,6 +72,10 @@ struct Polygon{NDIMS, ELTYPE}
push!(edge_normals, edge_normal)
end
+ if length(edge_vertices) < 3
+ throw(ArgumentError("polygon requires at least three non-degenerate edges"))
+ end
+
vertex_normals = Vector{NTuple{2, SVector{NDIMS, ELTYPE}}}()
# Calculate vertex pseudo-normals.
@@ -95,6 +108,63 @@ struct Polygon{NDIMS, ELTYPE}
return new{NDIMS, ELTYPE}(vertices, edge_vertices, vertex_normals, edge_normals,
edge_vertices_ids, min_corner, max_corner)
end
+
+ function Polygon{NDIMS, ELTYPE}(vertices, edge_vertices, vertex_normals,
+ edge_normals, edge_vertices_ids,
+ min_corner, max_corner) where {NDIMS, ELTYPE}
+ return new{NDIMS, ELTYPE}(vertices, edge_vertices, vertex_normals, edge_normals,
+ edge_vertices_ids, min_corner, max_corner)
+ end
+end
+
+function vertex_normals_from_edges(edge_vertices, edge_normals)
+ VERTEX = typeof(first(first(edge_vertices)))
+ normal_sums = Dict{VERTEX, VERTEX}()
+
+ for (edge, edge_normal) in zip(edge_vertices, edge_normals)
+ for vertex in edge
+ normal_sums[vertex] = get(normal_sums, vertex, zero(edge_normal)) + edge_normal
+ end
+ end
+
+ return map(edge_vertices, edge_normals) do edge, edge_normal
+ normals = map(edge) do vertex
+ normal_sum = normal_sums[vertex]
+ normal_norm = norm(normal_sum)
+
+ return iszero(normal_norm) ? edge_normal : normal_sum / normal_norm
+ end
+
+ return Tuple(normals)
+ end
+end
+
+function rebuild_polygon_from_edges(edge_vertices, edge_normals)
+ NDIMS = length(first(edge_normals))
+ ELTYPE = eltype(first(edge_normals))
+ vertices = SVector{NDIMS, ELTYPE}[]
+ vertex_ids = Dict{SVector{NDIMS, ELTYPE}, Int}()
+
+ edge_vertices_ids = map(edge_vertices) do edge
+ v1, v2 = edge
+ id1 = get!(vertex_ids, v1) do
+ push!(vertices, v1)
+ return length(vertices)
+ end
+ id2 = get!(vertex_ids, v2) do
+ push!(vertices, v2)
+ return length(vertices)
+ end
+
+ return (id1, id2)
+ end
+
+ min_corner = SVector([minimum(v[i] for v in vertices) for i in 1:NDIMS]...)
+ max_corner = SVector([maximum(v[i] for v in vertices) for i in 1:NDIMS]...)
+ vertex_normals = vertex_normals_from_edges(edge_vertices, edge_normals)
+
+ return Polygon{NDIMS, ELTYPE}(vertices, edge_vertices, vertex_normals, edge_normals,
+ edge_vertices_ids, min_corner, max_corner)
end
function Base.show(io::IO, geometry::Polygon)
@@ -119,14 +189,23 @@ end
@inline Base.eltype(::Polygon{NDIMS, ELTYPE}) where {NDIMS, ELTYPE} = ELTYPE
-@inline function Base.deleteat!(polygon::Polygon, indices)
- (; edge_vertices, edge_normals, edge_vertices_ids) = polygon
+"""
+ delete_faces(geometry, indices)
+
+Return a geometry with the faces at `indices` removed and derived geometry data rebuilt.
+"""
+@inline function delete_faces(polygon::Polygon, indices)
+ edge_vertices = copy(polygon.edge_vertices)
+ edge_normals = copy(polygon.edge_normals)
deleteat!(edge_vertices, indices)
- deleteat!(edge_vertices_ids, indices)
deleteat!(edge_normals, indices)
- return polygon
+ if isempty(edge_vertices)
+ throw(ArgumentError("cannot delete all polygon edges"))
+ end
+
+ return rebuild_polygon_from_edges(edge_vertices, edge_normals)
end
@inline nfaces(mesh::Polygon) = length(mesh.edge_normals)
diff --git a/src/preprocessing/geometries/triangle_mesh.jl b/src/preprocessing/geometries/triangle_mesh.jl
index ede02d4682..7677784854 100644
--- a/src/preprocessing/geometries/triangle_mesh.jl
+++ b/src/preprocessing/geometries/triangle_mesh.jl
@@ -129,19 +129,16 @@ struct TriangleMesh{NDIMS, ELTYPE}
min_corner = SVector([minimum(v[i] for v in vertices) for i in 1:NDIMS]...)
max_corner = SVector([maximum(v[i] for v in vertices) for i in 1:NDIMS]...)
- for i in eachindex(edge_normals)
- # Skip zero normals, which would be normalized to `NaN` vectors.
- # The edge normals are only used for the `SignedDistanceField`, which is
- # essential for the packing.
- # Zero normals are caused by exactly or nearly duplicated faces.
- if !iszero(norm(edge_normals[i]))
- edge_normals[i] = normalize(edge_normals[i])
+ for normals in (edge_normals, vertex_normals)
+ for i in eachindex(normals)
+ normals_norm = norm(normals[i])
+ !iszero(normals_norm) && (normals[i] = normals[i] / normals_norm)
end
end
return new{NDIMS, ELTYPE}(vertices, face_vertices, face_vertices_ids,
face_edges_ids, edge_vertices_ids,
- normalize.(vertex_normals), edge_normals,
+ vertex_normals, edge_normals,
face_normals, min_corner, max_corner)
end
end
@@ -171,15 +168,19 @@ end
@inline face_normal(triangle, geometry::TriangleMesh) = geometry.face_normals[triangle]
-@inline function Base.deleteat!(mesh::TriangleMesh, indices)
- (; face_vertices, face_vertices_ids, face_edges_ids, face_normals) = mesh
+@inline function delete_faces(mesh::TriangleMesh, indices)
+ face_vertices = copy(mesh.face_vertices)
+ face_normals = copy(mesh.face_normals)
deleteat!(face_vertices, indices)
- deleteat!(face_vertices_ids, indices)
- deleteat!(face_edges_ids, indices)
deleteat!(face_normals, indices)
- return mesh
+ if isempty(face_vertices)
+ throw(ArgumentError("cannot delete all triangle mesh faces"))
+ end
+
+ vertices = collect(Iterators.flatten(face_vertices))
+ return TriangleMesh(face_vertices, face_normals, vertices)
end
@inline nfaces(mesh::TriangleMesh) = length(mesh.face_normals)
diff --git a/src/preprocessing/particle_packing/signed_distance.jl b/src/preprocessing/particle_packing/signed_distance.jl
index 01e862365f..2ef2c5eae4 100644
--- a/src/preprocessing/particle_packing/signed_distance.jl
+++ b/src/preprocessing/particle_packing/signed_distance.jl
@@ -16,11 +16,15 @@ to this surface.
distance of `abs(max_signed_distance)` to the surface of the shape
will be sampled.
- `points`: Points on which the signed distance is computed.
+ Pass a collection of static vectors or an `NDIMS`-by-`N` matrix with
+ one point per column.
When set to `nothing` (default), the bounding box of the shape will be
sampled with a uniform grid of points.
- `use_for_boundary_packing`: Set to `true` if [`SignedDistanceField`] is used to pack
a boundary [`ParticlePackingSystem`](@ref).
Use the default of `false` when packing without a boundary.
+ This requires a closed geometry, since boundary packing
+ needs a well-defined outside region.
"""
struct SignedDistanceField{ELTYPE, P, N, D}
positions :: P
@@ -38,6 +42,11 @@ function SignedDistanceField(geometry, particle_spacing;
NDIMS = ndims(geometry)
ELTYPE = eltype(particle_spacing)
+ if use_for_boundary_packing
+ require_closed_geometry(geometry,
+ "SignedDistanceField with `use_for_boundary_packing=true`")
+ end
+
sdf_factor = use_for_boundary_packing ? 2 : 1
search_radius = sdf_factor * max_signed_distance
@@ -62,9 +71,11 @@ function SignedDistanceField(geometry, particle_spacing;
min_corner; place_on_shell=true)
points = reinterpret(reshape, SVector{NDIMS, eltype(grid)}, grid)
+ else
+ points = wrap_points(points, Val(NDIMS))
end
- positions = copy(points)
+ positions = collect(points)
# This gives a performance boost for large geometries
delete_positions_in_empty_cells!(positions, nhs)
diff --git a/src/preprocessing/particle_packing/system.jl b/src/preprocessing/particle_packing/system.jl
index a4bb7e9aa7..d48688b4da 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}
@@ -100,12 +101,16 @@ struct ParticlePackingSystem{S, F, NDIMS, ELTYPE <: Real, PR, C, AV,
end
end
+@inline surface_tension_model(::ParticlePackingSystem) = nothing
+@inline surface_normal_method(::ParticlePackingSystem) = nothing
+
function ParticlePackingSystem(shape::InitialCondition;
signed_distance_field::Union{SignedDistanceField, Nothing},
smoothing_kernel=SchoenbergQuinticSplineKernel{ndims(shape)}(),
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 +152,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
+
+ offset = place_on_shell ? shape.particle_spacing : shape.particle_spacing / 2
- shift_length = -boundary_compress_factor *
- signed_distance_field.max_signed_distance - offset
+ shift_length = -boundary_compress_factor *
+ boundary_thickness_ - offset
+ end
else
shift_length = place_on_shell ? zero(ELTYPE) : shape.particle_spacing / 2
end
diff --git a/src/preprocessing/point_in_poly/winding_number_hormann.jl b/src/preprocessing/point_in_poly/winding_number_hormann.jl
index a93cf789c4..9f9b849acb 100644
--- a/src/preprocessing/point_in_poly/winding_number_hormann.jl
+++ b/src/preprocessing/point_in_poly/winding_number_hormann.jl
@@ -14,6 +14,7 @@ struct WindingNumberHormann end
# https://doi.org/10.1016/S0925-7721(01)00012-8
function (point_in_poly::WindingNumberHormann)(geometry, points; store_winding_number=false)
(; edge_vertices) = geometry
+ points = wrap_points(points, Val(ndims(geometry)))
# We cannot use a `BitVector` here, as writing to a `BitVector` is not thread-safe
inpoly = fill(false, length(points))
diff --git a/src/preprocessing/point_in_poly/winding_number_jacobson.jl b/src/preprocessing/point_in_poly/winding_number_jacobson.jl
index 145a6d9a28..0f7385ac41 100644
--- a/src/preprocessing/point_in_poly/winding_number_jacobson.jl
+++ b/src/preprocessing/point_in_poly/winding_number_jacobson.jl
@@ -51,14 +51,16 @@ end
"""
WindingNumberJacobson(; geometry=nothing, winding_number_factor=sqrt(eps()),
- hierarchical_winding=false)
+ hierarchical_winding=!isnothing(geometry))
Algorithm for inside-outside segmentation of a complex geometry proposed by [Jacobson2013](@cite).
# Keywords
- `geometry`: Complex geometry returned by [`load_geometry`](@ref) and is only required when using
`hierarchical_winding=true`.
- `hierarchical_winding`: If set to `true`, an optimized hierarchical approach will be used,
- which gives a significant speedup. For further information see [Hierarchical Winding](@ref hierarchical_winding).
+ which gives a significant speedup. It defaults to `true` when `geometry`
+ is passed and `false` otherwise. For further information see
+ [Hierarchical Winding](@ref hierarchical_winding).
- `winding_number_factor`: For leaky geometries, a factor of `0.4` will give a better inside-outside segmentation.
!!! warning "Experimental Implementation"
@@ -69,7 +71,7 @@ struct WindingNumberJacobson{ELTYPE, W}
winding :: W
function WindingNumberJacobson(; geometry=nothing, winding_number_factor=sqrt(eps()),
- hierarchical_winding=true)
+ hierarchical_winding=!isnothing(geometry))
if hierarchical_winding && geometry isa Nothing
throw(ArgumentError("`geometry` must be of type `Polygon` (2D) or `TriangleMesh` (3D) when using hierarchical winding"))
end
@@ -104,6 +106,7 @@ end
function (point_in_poly::WindingNumberJacobson)(geometry, points;
store_winding_number=false)
(; winding_number_factor, winding) = point_in_poly
+ points = wrap_points(points, Val(ndims(geometry)))
# We cannot use a `BitVector` here, as writing to a `BitVector` is not thread-safe
inpoly = fill(false, length(points))
diff --git a/src/preprocessing/preprocessing.jl b/src/preprocessing/preprocessing.jl
index c538280bb4..1f29263cae 100644
--- a/src/preprocessing/preprocessing.jl
+++ b/src/preprocessing/preprocessing.jl
@@ -1,3 +1,19 @@
+function wrap_points(points, ::Val{NDIMS}) where {NDIMS}
+ if points isa AbstractMatrix
+ if size(points, 1) != NDIMS
+ throw(ArgumentError("point matrix must have $NDIMS rows"))
+ end
+
+ # Interpret an `NDIMS`-by-`N` matrix as one static vector per column. Constructing
+ # the vectors explicitly also supports non-contiguous matrix views.
+ return map(eachcol(points)) do point
+ return SVector{NDIMS, eltype(points)}(point)
+ end
+ end
+
+ return points
+end
+
include("geometries/geometries.jl")
include("point_in_poly/point_in_poly.jl")
include("particle_packing/particle_packing.jl")
diff --git a/src/schemes/boundary/open_boundary/boundary_zones.jl b/src/schemes/boundary/open_boundary/boundary_zones.jl
index c7f29b1e36..9d29d23483 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,11 +465,22 @@ function spanning_vectors(face_vertices::NTuple{3}, zone_width)
edge1 = face_vertices[2] - face_vertices[1]
edge2 = face_vertices[3] - face_vertices[1]
+ edge1_norm = norm(edge1)
+ edge2_norm = norm(edge2)
+ edge_tolerance = sqrt(eps(typeof(edge1_norm * edge2_norm))) * edge1_norm *
+ edge2_norm
+
# Check if the edges are linearly dependent (to avoid degenerate planes)
- if isapprox(norm(cross(edge1, edge2)), 0.0; atol=eps())
+ cross_norm = norm(cross(edge1, edge2))
+ if isapprox(cross_norm, zero(cross_norm); atol=edge_tolerance)
throw(ArgumentError("the vectors `AB` and `AC` must not be collinear"))
end
+ edge_dot = dot(edge1, edge2)
+ if !isapprox(edge_dot, zero(edge_dot); atol=edge_tolerance)
+ throw(ArgumentError("the vectors `AB` and `AC` must be orthogonal"))
+ end
+
# Calculate normal vector of `boundary_face`
c = Vector(normalize(cross(edge2, edge1)) * zone_width)
diff --git a/src/schemes/boundary/open_boundary/method_of_characteristics.jl b/src/schemes/boundary/open_boundary/method_of_characteristics.jl
index ce04478a0c..5023b9023f 100644
--- a/src/schemes/boundary/open_boundary/method_of_characteristics.jl
+++ b/src/schemes/boundary/open_boundary/method_of_characteristics.jl
@@ -169,6 +169,7 @@ function evaluate_characteristics!(system, v, u, v_ode, u_ode, semi, t)
# Particle is outside of the influence of fluid particles.
# `volume` is in the order of 1 / h^d, so volume * h^d is in the order of 1.
if volume[particle] * smoothing_length^ndims(system) < eps(eltype(smoothing_length))
+ zone_id = system.boundary_zone_indices[particle]
# Using the average of the values at the previous time step for particles which
# are outside of the influence of fluid particles.
@@ -178,6 +179,8 @@ function evaluate_characteristics!(system, v, u, v_ode, u_ode, semi, t)
counter = 0
for neighbor in each_integrated_particle(system)
+ system.boundary_zone_indices[neighbor] == zone_id || continue
+
# Make sure that only neighbors in the influence of
# the fluid particles are used.
# `volume` is in the order of 1 / h^d, so volume * h^d is in the order of 1.
diff --git a/src/schemes/boundary/open_boundary/system.jl b/src/schemes/boundary/open_boundary/system.jl
index 9da559481e..92c9b4d2d6 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 ?
@@ -807,9 +826,9 @@ function check_configuration(system::OpenBoundarySystem, systems, neighborhood_s
system.fluid_system_index[] = fluid_system_index
if boundary_model isa BoundaryModelCharacteristicsLastiwka &&
- any(zone -> isnothing(zone.flow_direction), boundary_zones)
- throw(ArgumentError("`BoundaryModelCharacteristicsLastiwka` needs a specific flow direction. " *
- "Please specify `InFlow()` and `OutFlow()`."))
+ any(zone -> zone.is_bidirectional, boundary_zones)
+ throw(ArgumentError("`BoundaryModelCharacteristicsLastiwka` needs a directed boundary zone. " *
+ "Please specify `InFlow()` or `OutFlow()` instead of `BidirectionalFlow()`."))
end
if first(PointNeighbors.requires_update(neighborhood_search))
diff --git a/src/schemes/boundary/wall_boundary/dummy_particles.jl b/src/schemes/boundary/wall_boundary/dummy_particles.jl
index 55aa20226b..ad2032a4ad 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).
@@ -33,7 +34,10 @@ Boundary model for [`WallBoundarySystem`](@ref).
in areas of low pressure, against which the particle
shifting technique is fighting.
- `reference_particle_spacing`: The reference particle spacing used for weighting values at the boundary,
- which currently is only needed when using surface tension.
+ which is needed when using a surface-normal method.
+- `surface_measure=nothing`: Optional nonnegative per-particle surface quadrature weights.
+ These are required by [`WettedAreaContactAngle`](@ref), where zero
+ marks particles below the physical contact surface.
# Examples
```jldoctest; output = false, setup = :(densities = [1.0, 2.0, 3.0]; masses = [0.1, 0.2, 0.3]; smoothing_kernel = SchoenbergCubicSplineKernel{2}(); smoothing_length = 0.1)
# Free-slip condition
@@ -76,12 +80,55 @@ struct BoundaryModelDummyParticles{DC, SE, CLIP, ELTYPE <: Real, VECTOR, K, V, C
end
end
+@doc raw"""
+ BoundaryModelDummyParticles(initial_condition;
+ fluid_system::AbstractFluidSystem,
+ initial_density=initial_condition.density,
+ hydrodynamic_mass=initial_condition.mass,
+ boundary_density_calculator=AdamiPressureExtrapolation(),
+ smoothing_kernel=system_smoothing_kernel(fluid_system),
+ smoothing_length=initial_smoothing_length(fluid_system),
+ viscosity=nothing,
+ state_equation=system_state_equation(fluid_system),
+ correction=system_correction(fluid_system),
+ clip_negative_pressure=false,
+ reference_particle_spacing=default_reference_particle_spacing(fluid_system),
+ 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
+`fluid_system`.
+"""
+function BoundaryModelDummyParticles(initial_condition;
+ fluid_system::AbstractFluidSystem,
+ initial_density=initial_condition.density,
+ hydrodynamic_mass=initial_condition.mass,
+ boundary_density_calculator=AdamiPressureExtrapolation(),
+ smoothing_kernel=system_smoothing_kernel(fluid_system),
+ smoothing_length=initial_smoothing_length(fluid_system),
+ viscosity=nothing,
+ state_equation=system_state_equation(fluid_system),
+ correction=system_correction(fluid_system),
+ clip_negative_pressure=false,
+ reference_particle_spacing=default_reference_particle_spacing(fluid_system),
+ 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, surface_measure)
+end
+
+# The default constructor needs to be accessible for Adapt.jl to work with this struct.
+# See the comments in general/gpu.jl for more details.
function BoundaryModelDummyParticles(initial_density, hydrodynamic_mass,
density_calculator, smoothing_kernel,
- smoothing_length; viscosity=nothing,
- state_equation=nothing, correction=nothing,
- clip_negative_pressure=false,
- reference_particle_spacing=0.0)
+ smoothing_length; viscosity=nothing,
+ state_equation=nothing, correction=nothing,
+ clip_negative_pressure=false,
+ reference_particle_spacing=0.0,
+ surface_measure=nothing)
pressure = initial_boundary_pressure(initial_density, density_calculator,
state_equation)
NDIMS = ndims(smoothing_kernel)
@@ -90,8 +137,9 @@ function BoundaryModelDummyParticles(initial_density, hydrodynamic_mass,
n_particles = length(initial_density)
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(initial_density, density_calculator, NDIMS)...,
+ create_cache_model(correction, initial_density, NDIMS, n_particles)...,
+ create_cache_wetted_area(surface_measure, ELTYPE, NDIMS, n_particles)...)
# If the `reference_density_spacing` is set calculate the `ideal_neighbor_count`
if reference_particle_spacing > 0
@@ -109,6 +157,37 @@ function BoundaryModelDummyParticles(initial_density, hydrodynamic_mass,
clip_negative_pressure)
end
+@inline create_cache_wetted_area(::Nothing, ELTYPE, NDIMS, n_particles) = (;)
+
+function create_cache_wetted_area(surface_measure, ELTYPE, NDIMS, 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"))
+
+ wetted_area_surface_measure = collect(ELTYPE, surface_measure)
+ wetted_area_weight = zeros(ELTYPE, n_particles)
+ wetted_area_flooded_reference = zeros(ELTYPE, n_particles)
+ wetted_area_reaction = zeros(ELTYPE, NDIMS, n_particles)
+ wetted_area_reaction_buffer = zeros(ELTYPE, NDIMS, n_particles,
+ Threads.nthreads())
+ wetted_area_active = Ref(false)
+ return (; wetted_area_surface_measure, wetted_area_weight,
+ wetted_area_flooded_reference, wetted_area_reaction,
+ wetted_area_reaction_buffer, wetted_area_active)
+end
+
+@inline function default_reference_particle_spacing(fluid_system)
+ if hasproperty(fluid_system, :cache) &&
+ hasproperty(fluid_system.cache, :reference_particle_spacing)
+ return fluid_system.cache.reference_particle_spacing
+ end
+
+ return zero(eltype(fluid_system))
+end
+
@inline function Base.ndims(boundary_model::BoundaryModelDummyParticles)
return ndims(boundary_model.smoothing_kernel)
end
diff --git a/src/schemes/boundary/wall_boundary/rhs.jl b/src/schemes/boundary/wall_boundary/rhs.jl
index 63811938e5..289f2ab49a 100644
--- a/src/schemes/boundary/wall_boundary/rhs.jl
+++ b/src/schemes/boundary/wall_boundary/rhs.jl
@@ -3,7 +3,6 @@ function interact!(dv, v_particle_system, u_particle_system,
v_neighbor_system, u_neighbor_system,
particle_system::Union{AbstractBoundarySystem, OpenBoundarySystem},
neighbor_system, semi)
- # TODO Solids and moving boundaries should be considered in the continuity equation
return dv
end
@@ -14,8 +13,6 @@ function interact!(dv, v_particle_system, u_particle_system,
neighbor_system::Union{AbstractFluidSystem,
OpenBoundarySystem{<:BoundaryModelDynamicalPressureZhang}},
semi)
- (; boundary_model) = particle_system
-
system_coords = current_coordinates(u_particle_system, particle_system)
neighbor_coords = current_coordinates(u_neighbor_system, neighbor_system)
diff --git a/src/schemes/boundary/wall_boundary/system.jl b/src/schemes/boundary/wall_boundary/system.jl
index f9864ecc05..679d9e2e56 100644
--- a/src/schemes/boundary/wall_boundary/system.jl
+++ b/src/schemes/boundary/wall_boundary/system.jl
@@ -231,6 +231,36 @@ function update_boundary_interpolation!(system::WallBoundarySystem, v, u, v_ode,
return system
end
+function reset_interaction_caches!(system::WallBoundarySystem)
+ boundary_cache = wetted_area_boundary_cache(system)
+ if !isnothing(boundary_cache) && boundary_cache.wetted_area_active[]
+ set_zero!(boundary_cache.wetted_area_reaction)
+ set_zero!(boundary_cache.wetted_area_reaction_buffer)
+ end
+ return system
+end
+
+function finalize_interaction!(system::WallBoundarySystem,
+ dv, v, u, dv_ode, v_ode, u_ode, semi)
+ boundary_cache = wetted_area_boundary_cache(system)
+ if isnothing(boundary_cache) || !boundary_cache.wetted_area_active[]
+ return system
+ end
+
+ reaction = boundary_cache.wetted_area_reaction
+ reaction_buffer = boundary_cache.wetted_area_reaction_buffer
+ @threaded semi for particle in eachparticle(system)
+ for dim in 1:ndims(system)
+ value = zero(eltype(system))
+ for thread in axes(reaction_buffer, 3)
+ @inbounds value += reaction_buffer[dim, particle, thread]
+ end
+ @inbounds reaction[dim, particle] = value
+ end
+ end
+ return system
+end
+
function write_u0!(u0, ::WallBoundarySystem)
return u0
end
diff --git a/src/schemes/fluid/entropically_damped_sph/rhs.jl b/src/schemes/fluid/entropically_damped_sph/rhs.jl
index 2ea1e4dc4d..0bc1e8d6e5 100644
--- a/src/schemes/fluid/entropically_damped_sph/rhs.jl
+++ b/src/schemes/fluid/entropically_damped_sph/rhs.jl
@@ -10,6 +10,7 @@ function interact!(dv, v_particle_system, u_particle_system,
surface_tension_a = surface_tension_model(particle_system)
surface_tension_b = surface_tension_model(neighbor_system)
+ surface_normal_method_a = surface_normal_method(particle_system)
# For `distance == 0`, the analytical gradient is zero, but the unsafe gradient
# and the density diffusion divide by zero.
@@ -20,6 +21,22 @@ 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)
+ acceleration += contact_angle_acceleration(surface_tension_a, particle_system,
+ surface_normal_method_a, 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;
@@ -85,10 +102,23 @@ function interact!(dv, v_particle_system, u_particle_system,
particle, neighbor, pos_diff, distance,
rho_a, rho_b, grad_kernel, 1)
+ dv_particle[] += wetted_area_density_acceleration(surface_normal_method_a,
+ particle_system,
+ neighbor_system, particle,
+ neighbor, rho_a, rho_b, m_b,
+ grad_kernel)
+
@inbounds adhesion_force!(dv_particle, surface_tension_a, particle_system,
neighbor_system,
particle, neighbor, pos_diff, distance)
+ dv_particle[] += wetted_area_explicit_acceleration(surface_tension_a,
+ surface_normal_method_a,
+ particle_system,
+ neighbor_system, particle,
+ neighbor, m_a, rho_a,
+ grad_kernel)
+
for i in 1:ndims(particle_system)
@inbounds dv[i, particle] += dv_particle[][i]
end
diff --git a/src/schemes/fluid/entropically_damped_sph/system.jl b/src/schemes/fluid/entropically_damped_sph/system.jl
index 2b085d3ebb..43029c68b8 100644
--- a/src/schemes/fluid/entropically_damped_sph/system.jl
+++ b/src/schemes/fluid/entropically_damped_sph/system.jl
@@ -51,9 +51,10 @@ See [Entropically Damped Artificial Compressibility for SPH](@ref edac) for more
gravity-like source terms.
- `surface_tension`: Surface tension model used for this SPH system. (default: no surface tension)
- `surface_normal_method`: The surface normal method to be used for this SPH system.
- (default: no surface normal method or `ColorfieldSurfaceNormal()` if a surface_tension model is used)
+ (default: no surface normal method or `ColorfieldSurfaceNormal()`
+ if the surface tension model requires normals)
- `reference_particle_spacing`: The reference particle spacing used for weighting values at the boundary,
- which currently is only needed when using surface tension.
+ which is needed when using a surface-normal method.
- `color_value`: Integer label used for calculation of surface normals.
Currently this is only used together with [`BoundaryModelDummyParticles`](@ref) and
[`ColorfieldSurfaceNormal`](@ref): fluid-boundary normal evaluation
@@ -119,12 +120,17 @@ function EntropicallyDampedSPHSystem(initial_condition; smoothing_kernel, smooth
throw(ArgumentError("`acceleration` must be of length $NDIMS for a $(NDIMS)D problem"))
end
- if surface_tension !== nothing && surface_normal_method === nothing
- surface_normal_method = ColorfieldSurfaceNormal()
- end
+ surface_normal_method = default_surface_normal_method(surface_tension,
+ surface_normal_method)
+ 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 `ColorfieldSurfaceNormal` or a surface tension model"))
+ throw(ArgumentError("`reference_particle_spacing` must be set to a positive value when using a surface-normal method"))
end
if correction isa ShepardKernelCorrection &&
@@ -237,6 +243,8 @@ end
return ELTYPE
end
+@inline wetted_area_supported_fluid(::EntropicallyDampedSPHSystem) = true
+
@inline function v_nvariables(system::EntropicallyDampedSPHSystem)
return v_nvariables(system, system.density_calculator)
end
@@ -299,9 +307,51 @@ 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;
@@ -310,7 +360,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
@@ -381,6 +430,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/src/schemes/fluid/fluid.jl b/src/schemes/fluid/fluid.jl
index fb49a65359..4b289e8ced 100644
--- a/src/schemes/fluid/fluid.jl
+++ b/src/schemes/fluid/fluid.jl
@@ -227,12 +227,22 @@ function calculate_dt(v_ode, u_ode, cfl_number, system::AbstractFluidSystem, sem
# Eq. 28 in Morris (2000)
dt = min(dt_viscosity, dt_acceleration, dt_sound_speed)
- if surface_tension isa SurfaceTensionMorris ||
- surface_tension isa SurfaceTensionMomentumMorris
- v = wrap_v(v_ode, system, semi)
- dt_surface_tension = sqrt(current_density(v, system, 1) * smoothing_length_^3 /
- (2 * pi * surface_tension.surface_tension_coefficient))
- dt = min(dt, dt_surface_tension)
+ if surface_tension isa SurfaceTensionAkinciCohesionPhysical
+ coefficient = surface_tension.surface_tension_coefficient
+ if !iszero(coefficient)
+ dt_surface_tension = sqrt(surface_tension.reference_density *
+ smoothing_length_^3 / (2 * pi * coefficient))
+ dt = min(dt, dt_surface_tension)
+ end
+ elseif surface_tension isa SurfaceTensionMorris ||
+ surface_tension isa SurfaceTensionMomentumMorris
+ coefficient = surface_tension.surface_tension_coefficient
+ if !iszero(coefficient)
+ v = wrap_v(v_ode, system, semi)
+ dt_surface_tension = sqrt(current_density(v, system, 1) * smoothing_length_^3 /
+ (2 * pi * coefficient))
+ dt = min(dt, dt_surface_tension)
+ end
end
return dt
@@ -297,11 +307,19 @@ function restart_v(system::AbstractFluidSystem, data)
end
function check_configuration(fluid_system::AbstractFluidSystem, systems, nhs)
- if !(fluid_system isa ParticlePackingSystem) && !isnothing(fluid_system.surface_tension)
+ check_wetted_area_configuration!(fluid_system,
+ surface_normal_method(fluid_system), systems)
+ check_corrected_csf_boundary_configuration!(fluid_system,
+ surface_normal_method(fluid_system),
+ systems)
+
+ if !(fluid_system isa ParticlePackingSystem) &&
+ (!isnothing(fluid_system.surface_tension) ||
+ !isnothing(fluid_system.surface_normal_method))
foreach_system(systems) do neighbor
- if neighbor isa AbstractFluidSystem &&
- isnothing(fluid_system.surface_tension) &&
- isnothing(fluid_system.surface_normal_method)
+ if neighbor isa AbstractFluidSystem && !(neighbor isa ParticlePackingSystem) &&
+ isnothing(neighbor.surface_tension) &&
+ isnothing(neighbor.surface_normal_method)
throw(ArgumentError("either none or all fluid systems in a simulation need " *
"to use a surface tension model or a surface normal method."))
end
diff --git a/src/schemes/fluid/implicit_incompressible_sph/system.jl b/src/schemes/fluid/implicit_incompressible_sph/system.jl
index 9b8fedc288..c7df1ce185 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
@@ -487,7 +489,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,
@@ -574,11 +577,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
@@ -736,9 +741,11 @@ end
function check_configuration(system::ImplicitIncompressibleSPHSystem, systems, nhs)
(; time_step, omega) = system
foreach_system(systems) do neighbor
- if neighbor isa WeaklyCompressibleSPHSystem
- throw(ArgumentError("`ImplicitIncompressibleSPHSystem` cannot be used together with
- `WeaklyCompressibleSPHSystem`"))
+ if neighbor isa WeaklyCompressibleSPHSystem ||
+ neighbor isa EntropicallyDampedSPHSystem
+ neighbor_name = neighbor |> typeof |> nameof
+ throw(ArgumentError("`ImplicitIncompressibleSPHSystem` cannot be used " *
+ "together with `$neighbor_name`"))
end
if neighbor isa WallBoundarySystem
if (neighbor.boundary_model isa BoundaryModelDummyParticles &&
diff --git a/src/schemes/fluid/pressure_acceleration.jl b/src/schemes/fluid/pressure_acceleration.jl
index b6114c0bc1..70a871c775 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,63 @@ 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`, unclipped pressure, no kernel-gradient
+correction, and an interface-activity-providing surface-normal method: either a
+[`ColorfieldSurfaceNormal`](@ref) with Morris/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`"))
+ isnothing(correction) ||
+ throw(ArgumentError("`InterfaceAwareTensileInstabilityControl` does not support kernel-gradient corrections"))
+ supports_interface_aware_tic(surface_normal_method, surface_tension) ||
+ throw(ArgumentError("`InterfaceAwareTensileInstabilityControl` requires " *
+ "`ColorfieldSurfaceNormal` with Morris or CSS surface tension " *
+ "or `CorrectedCSFSurfaceNormal` with `SurfaceTensionMorris`"))
+ if !isnothing(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)
+ controlled = tensile_instability_control(m_a, m_b, rho_a, rho_b, p_a, p_b, W_a)
+ interface_activity = clamp(max(activity_a, activity_b), zero(activity_a),
+ one(activity_a))
+ 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.
@@ -123,6 +180,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`"))
+ isnothing(correction) ||
+ throw(ArgumentError("`InterfaceAwareTensileInstabilityControl` does not support kernel-gradient corrections"))
+ return control
+end
+
function choose_pressure_acceleration_formulation(pressure_acceleration::Nothing,
density_calculator::SummationDensity,
NDIMS, ELTYPE,
@@ -143,14 +210,50 @@ 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 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 = surface_interface_activity(particle_system, particle)
+ activity_b = surface_interface_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/shifting_techniques.jl b/src/schemes/fluid/shifting_techniques.jl
index 9e2c1f23bc..4bd191fcbe 100644
--- a/src/schemes/fluid/shifting_techniques.jl
+++ b/src/schemes/fluid/shifting_techniques.jl
@@ -58,13 +58,46 @@ end
return v_diff
end
+"""
+ FreeSurfaceTangentialShifting()
+
+Use the color-field interface normal to remove the surface-normal component of the particle
+shifting velocity. 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`. Interface activity identifies
+the free surface. 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)
+ update_everystage=false,
+ modify_continuity_equation=true,
+ 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 +166,34 @@ 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) together with a
+ [`ColorfieldSurfaceNormal`](@ref) and Morris/CSS surface tension
+ to remove the interface-normal component of the shifting velocity.
!!! 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 and cannot be used in a
+ free-surface simulation. Free-surface use requires the explicit
+ `FreeSurfaceTangentialShifting()` treatment with Morris/CSS smooth interface activity.
"""
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 +230,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 +244,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 +272,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 +289,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 +318,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 +342,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 +558,36 @@ 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
+ 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)
+ 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
@@ -715,5 +798,5 @@ function update_shifting!(system, shifting::TransportVelocityAdami, v, u, v_ode,
return system
end
-# TODO: Implement free surface detection to disable shifting close to free surfaces
+# TVF and PST without an explicit surface treatment retain their closed-system behavior.
@inline modify_shifting_at_free_surfaces!(system, u, semi) = system
diff --git a/src/schemes/fluid/surface_normal_sph.jl b/src/schemes/fluid/surface_normal_sph.jl
index 4db94ea763..36c01ca812 100644
--- a/src/schemes/fluid/surface_normal_sph.jl
+++ b/src/schemes/fluid/surface_normal_sph.jl
@@ -1,36 +1,742 @@
+abstract type AbstractContactAngleModel end
+
+function validate_contact_angle(contact_angle)
+ if !(contact_angle isa Real) || !isfinite(contact_angle) ||
+ !(0 <= contact_angle <= 180)
+ throw(ArgumentError("`contact_angle` must be a finite real number in [0, 180] degrees"))
+ end
+
+ return contact_angle
+end
+
+@doc raw"""
+ WettedAreaContactAngle(contact_angle)
+
+Apply Young's wall energy through a corrected wetted-area quadrature. The model is an explicit
+opt-in for [`ColorfieldSurfaceNormal`](@ref); constructing `ColorfieldSurfaceNormal()` without a
+contact model remains unchanged.
+
+The validated production configuration is currently restricted to three-dimensional
+`ContinuityDensity` fluids using `WendlandC2Kernel{3}` with `h/dx=1.4`. Contact boundaries must be
+dummy-particle wall or rigid-body systems built with per-particle `surface_measure` values and
+`InitialCondition.normals`. Each boundary system represents one connected disk-like wetted patch.
+Angles at exactly 0 and 180 degrees are not supported by the canonical edge correction; at
+90 degrees the wall energy and force are exactly zero.
+"""
+struct WettedAreaContactAngle{ELTYPE <: Real} <: AbstractContactAngleModel
+ contact_angle::ELTYPE
+
+ function WettedAreaContactAngle(contact_angle)
+ angle = validate_contact_angle(contact_angle)
+ 0 < angle < 180 ||
+ throw(ArgumentError("`WettedAreaContactAngle` requires `contact_angle` in (0, 180) degrees"))
+ new{typeof(angle)}(angle)
+ end
+end
+
+@inline convert_contact_model(::Nothing, ELTYPE) = nothing
+
+@inline function convert_contact_model(contact_model::WettedAreaContactAngle, ELTYPE)
+ return WettedAreaContactAngle(convert(ELTYPE, contact_model.contact_angle))
+end
+
+function convert_contact_model(contact_model, ELTYPE)
+ throw(ArgumentError("`contact_model` must be `nothing` or `WettedAreaContactAngle`"))
+end
+
@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, contact_model=nothing,
+ normal_smoothing=false)
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`: Assume particles are inside if their continuous kernel-support
+ moment is above this fraction of complete support. Zero disables
+ this filter.
+- `interface_taper_start=0.8`: Start the smooth interface activation at this fraction of
+ `interface_threshold`.
+- `support_taper_width=0.025`: Width of the smooth transition above
+ `ideal_density_threshold`.
+- `contact_model=nothing`: Optional contact-angle model. The validated explicit choice is
+ [`WettedAreaContactAngle`](@ref).
+- `normal_smoothing=false`: Apply one activity-weighted Shepard smoothing pass to unit
+ normals before curvature or CSS stress evaluation.
"""
-struct ColorfieldSurfaceNormal{ELTYPE}
+struct ColorfieldSurfaceNormal{ELTYPE, CONTACT_MODEL}
boundary_contact_threshold::ELTYPE
interface_threshold::ELTYPE
ideal_density_threshold::ELTYPE
+ interface_taper_start::ELTYPE
+ support_taper_width::ELTYPE
+ contact_model::CONTACT_MODEL
+ normal_smoothing::Bool
end
-function ColorfieldSurfaceNormal(; boundary_contact_threshold=0.1, interface_threshold=0.01,
- ideal_density_threshold=0.0)
- return ColorfieldSurfaceNormal(boundary_contact_threshold, interface_threshold,
+@doc raw"""
+ CorrectedCSFSurfaceNormal(; contact_angle=nothing)
+
+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.
+
+With `contact_angle=nothing`, this explicit opt-in implements the single-fluid free-surface core
+(equations 15--25) with [`SurfaceTensionMorris`](@ref). A finite `contact_angle` enables the
+planar boundary-integral geometry terms and contact-normal correction from equations 41--50.
+Contact boundaries require a three-dimensional Wendland C2 kernel, explicit surface measures,
+and normal offset vectors. These terms correct interface geometry only; hydrodynamic wall
+interactions remain those of the configured boundary model.
+"""
+struct CorrectedCSFSurfaceNormal{CONTACT_ANGLE}
+ contact_angle::CONTACT_ANGLE
+end
+
+function CorrectedCSFSurfaceNormal(; contact_angle=nothing)
+ isnothing(contact_angle) && return CorrectedCSFSurfaceNormal(nothing)
+ return CorrectedCSFSurfaceNormal(float(validate_contact_angle(contact_angle)))
+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)
+ surface_tension isa SurfaceTensionMorris ||
+ throw(ArgumentError("`CorrectedCSFSurfaceNormal` requires `SurfaceTensionMorris`"))
+ return nothing
+end
+
+# Interface-aware TIC needs interface activity from one of these surface-normal methods;
+# anything else cannot gate the tensile correction.
+@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
+
+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, contact_model=nothing,
+ 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"))
+ 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
+ normal_smoothing isa Bool ||
+ throw(ArgumentError("`normal_smoothing` must be `true` or `false`"))
+
+ 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)
+
+ contact_model_ = convert_contact_model(contact_model, ELTYPE)
+ return ColorfieldSurfaceNormal(thresholds..., taper_start, taper_width, contact_model_,
+ normal_smoothing)
+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_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(::Union{SurfaceTensionMorris,
+ SurfaceTensionMomentumMorris},
+ 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
+
+@inline function default_surface_normal_method(surface_tension, surface_normal_method)
+ if isnothing(surface_normal_method) && requires_surface_normal(surface_tension)
+ if surface_tension isa SurfaceTensionAkinci
+ # Akinci et al. (2013), Equation 2, sums fluid neighbors only. Wall adhesion is
+ # a separate pair force and must not alter the surface-area normal implicitly.
+ return ColorfieldSurfaceNormal(; boundary_contact_threshold=Inf)
+ end
+ return ColorfieldSurfaceNormal()
+ end
+
+ return surface_normal_method
+end
+
function create_cache_surface_normal(surface_normal_method, ELTYPE, NDIMS, nparticles)
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)
+ 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)
+ ccsf_boundary_normal = Array{ELTYPE, 2}(undef, NDIMS, nparticles)
+ ccsf_boundary_distance = 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,
+ ccsf_boundary_normal, ccsf_boundary_distance)
+end
+
+function create_cache_surface_normal(method::ColorfieldSurfaceNormal{<:Any,
+ <:WettedAreaContactAngle},
+ ELTYPE, NDIMS, nparticles)
+ cache = create_cache_surface_normal(ColorfieldSurfaceNormal(;
+ normal_smoothing=method.normal_smoothing),
+ ELTYPE, NDIMS, nparticles)
+ wetted_area_density_conjugate = zeros(ELTYPE, nparticles)
+ wetted_area_energy = Ref(zero(ELTYPE))
+ wetted_area_raw_area = Ref(zero(ELTYPE))
+ wetted_area = Ref(zero(ELTYPE))
+ wetted_area_normalized_edge_shift = Ref(convert(ELTYPE, NaN))
+ wetted_area_evaluations = Ref(0)
+ return (; cache..., wetted_area_density_conjugate,
+ wetted_area_energy, wetted_area_raw_area, wetted_area,
+ wetted_area_normalized_edge_shift, wetted_area_evaluations)
+end
+
+@inline wetted_area_smoothstep_derivative(value) = 6value * (1 - value)
+
+@inline function wetted_area_contact_cosine(contact_model::WettedAreaContactAngle)
+ contact_model.contact_angle == 90 && return zero(contact_model.contact_angle)
+ return cosd(contact_model.contact_angle)
+end
+
+@inline function wetted_area_coefficient(surface_tension,
+ contact_model::WettedAreaContactAngle)
+ contact_cosine = wetted_area_contact_cosine(contact_model)
+ iszero(contact_cosine) && return zero(surface_tension.surface_tension_coefficient)
+ return surface_tension.surface_tension_coefficient * contact_cosine
+end
+
+function wetted_area_halfspace_reference(::WendlandC2Kernel{3}, normalized_distance)
+ distance = clamp(normalized_distance, zero(normalized_distance),
+ convert(typeof(normalized_distance), 2))
+ distance >= 2 && return zero(distance)
+
+ # Integrate the normalized three-dimensional kernel over a spherical cap:
+ # 2pi * integral_d^2 W(r) * r * (r - d) dr.
+ coefficients = (one(distance), zero(distance), -5one(distance) / 2,
+ 5one(distance) / 2, -15one(distance) / 16,
+ one(distance) / 8)
+ upper = convert(typeof(distance), 2)
+ integral = zero(distance)
+ for power in 0:5
+ coefficient = coefficients[power + 1]
+ integral += coefficient *
+ ((upper^(power + 3) - distance^(power + 3)) / (power + 3) -
+ distance * (upper^(power + 2) - distance^(power + 2)) /
+ (power + 2))
+ end
+ return 21integral / 8
+end
+
+function canonical_wetted_area_edge_shift(smoothing_kernel, cells_per_h, contact_angle;
+ quadrature_cells_per_h=64)
+ contact_sine = sind(contact_angle)
+ abs(contact_sine) > sqrt(eps(typeof(contact_sine))) ||
+ return zero(contact_sine)
+ contact_cotangent = cosd(contact_angle) / contact_sine
+ lattice_spacing = inv(convert(typeof(cells_per_h), quadrature_cells_per_h))
+ support = compact_support(smoothing_kernel, one(cells_per_h))
+ search_radius = ceil(Int, support / lattice_spacing)
+ boundary_distance = inv(2cells_per_h)
+ thresholds = typeof(cells_per_h)[]
+ weights = typeof(cells_per_h)[]
+
+ for z_offset in (-search_radius):search_radius,
+ x_offset in (-search_radius):search_radius
+ planar_distance2 = lattice_spacing^2 * (x_offset^2 + z_offset^2)
+ planar_distance2 < support^2 || continue
+ reduced_kernel = zero(cells_per_h)
+ for tangent_offset in (-search_radius):search_radius
+ distance = lattice_spacing *
+ sqrt(x_offset^2 + tangent_offset^2 + z_offset^2)
+ distance < support || continue
+ reduced_kernel += lattice_spacing * kernel(smoothing_kernel, distance,
+ one(cells_per_h))
+ end
+ source_z = -boundary_distance - z_offset * lattice_spacing
+ source_z > 0 || continue
+ push!(thresholds, x_offset * lattice_spacing + contact_cotangent * source_z)
+ push!(weights, lattice_spacing^2 * reduced_kernel)
+ end
+
+ order = sortperm(thresholds)
+ thresholds = thresholds[order]
+ weights = weights[order]
+ reference = sum(weights)
+ reference > eps(reference) || return zero(reference)
+ breaks = sort!(unique!([thresholds; zero(cells_per_h)]))
+ cumulative = zero(reference)
+ event = 1
+ shift = zero(reference)
+ for interval in 1:(length(breaks) - 1)
+ left = breaks[interval]
+ right = breaks[interval + 1]
+ while event <= length(thresholds) && thresholds[event] <= left
+ cumulative += weights[event]
+ event += 1
+ end
+ fraction = clamp(cumulative / reference, 0, 1)
+ step = (left + right) / 2 > 0 ? one(reference) : zero(reference)
+ shift += (right - left) * (cubic_smoothstep(fraction) - step)
+ end
+ return shift
+end
+
+@inline function wetted_area_boundary_cache(system)
+ hasproperty(system, :boundary_model) || return nothing
+ model = system.boundary_model
+ hasproperty(model, :cache) || return nothing
+ haskey(model.cache, :wetted_area_surface_measure) || return nothing
+ return model.cache
+end
+
+@inline wetted_area_supported_fluid(system) = false
+
+@inline function check_corrected_csf_boundary_configuration!(system,
+ surface_normal_method,
+ systems)
+ return system
+end
+
+function check_corrected_csf_boundary_configuration!(system::AbstractFluidSystem,
+ method::CorrectedCSFSurfaceNormal{<:Real},
+ systems)
+ system_smoothing_kernel(system) isa WendlandC2Kernel{3} ||
+ throw(ArgumentError("C-CSF planar BIM geometry currently requires `WendlandC2Kernel{3}`"))
+ fluid_count = 0
+ boundary_count = 0
+ foreach_system(systems) do candidate
+ if candidate isa AbstractFluidSystem
+ fluid_count += 1
+ return
+ end
+ valid_boundary = candidate isa AbstractBoundarySystem &&
+ hasproperty(candidate, :boundary_model) &&
+ candidate.boundary_model isa BoundaryModelDummyParticles
+ valid_boundary ||
+ throw(ArgumentError("C-CSF boundary geometry supports only dummy-particle boundaries"))
+ cache = candidate.boundary_model.cache
+ haskey(cache, :wetted_area_surface_measure) ||
+ throw(ArgumentError("C-CSF boundary geometry requires per-particle `surface_measure` values"))
+ isnothing(candidate.initial_condition.normals) &&
+ throw(ArgumentError("C-CSF boundary geometry requires boundary normal offset vectors"))
+ any(>(zero(eltype(cache.wetted_area_surface_measure))),
+ cache.wetted_area_surface_measure) ||
+ throw(ArgumentError("C-CSF boundary geometry requires a positive surface measure"))
+ boundary_count += 1
+ end
+ fluid_count == 1 ||
+ throw(ArgumentError("C-CSF boundary geometry requires exactly one fluid system"))
+ boundary_count > 0 ||
+ throw(ArgumentError("C-CSF boundary geometry requires at least one boundary"))
+ return system
+end
+
+@inline function check_wetted_area_configuration!(system, surface_normal_method, systems)
+ return system
+end
+
+function check_wetted_area_configuration!(system::AbstractFluidSystem,
+ surface_normal_method::ColorfieldSurfaceNormal{<:Any,
+ <:WettedAreaContactAngle},
+ systems)
+ ndims(system) == 3 ||
+ throw(ArgumentError("`WettedAreaContactAngle` currently supports only 3D fluids"))
+ wetted_area_supported_fluid(system) ||
+ throw(ArgumentError("`WettedAreaContactAngle` currently supports only WCSPH and EDAC fluids"))
+ density_calculator(system) isa ContinuityDensity ||
+ throw(ArgumentError("`WettedAreaContactAngle` requires `ContinuityDensity`"))
+ system.smoothing_kernel isa WendlandC2Kernel{3} ||
+ throw(ArgumentError("`WettedAreaContactAngle` requires `WendlandC2Kernel{3}`"))
+ system.surface_tension isa SurfaceTensionMomentumMorris ||
+ throw(ArgumentError("`WettedAreaContactAngle` requires `SurfaceTensionMomentumMorris`"))
+ isfinite(surface_normal_method.boundary_contact_threshold) ||
+ throw(ArgumentError("`WettedAreaContactAngle` requires a finite `boundary_contact_threshold`"))
+ system.cache.color == 1 ||
+ throw(ArgumentError("`WettedAreaContactAngle` requires the fluid `color_value` to be 1"))
+
+ particle_spacing = system.cache.reference_particle_spacing
+ cells_per_h = initial_smoothing_length(system) / particle_spacing
+ isapprox(cells_per_h, convert(typeof(cells_per_h), 1.4);
+ rtol=100eps(typeof(cells_per_h)), atol=zero(cells_per_h)) ||
+ throw(ArgumentError("`WettedAreaContactAngle` requires `smoothing_length / reference_particle_spacing == 1.4`"))
+
+ fluid_count = 0
+ boundary_count = 0
+ foreach_system(systems) do candidate
+ if candidate isa AbstractFluidSystem
+ fluid_count += 1
+ return
+ end
+
+ valid_boundary = (candidate isa WallBoundarySystem ||
+ candidate isa RigidBodySystem) &&
+ hasproperty(candidate, :boundary_model) &&
+ candidate.boundary_model isa BoundaryModelDummyParticles
+ valid_boundary ||
+ throw(ArgumentError("`WettedAreaContactAngle` supports only dummy-particle wall and rigid-body neighbors"))
+ candidate.cache.color == 0 ||
+ throw(ArgumentError("`WettedAreaContactAngle` requires contact-boundary `color_value` to be 0"))
+ boundary_count += 1
+ initialize_wetted_area_boundary!(system, candidate)
+ end
+ fluid_count == 1 ||
+ throw(ArgumentError("`WettedAreaContactAngle` requires exactly one fluid system"))
+ boundary_count > 0 ||
+ throw(ArgumentError("`WettedAreaContactAngle` requires at least one contact boundary"))
+
+ cache = system.cache
+ if isnan(cache.wetted_area_normalized_edge_shift[])
+ cache.wetted_area_normalized_edge_shift[] = canonical_wetted_area_edge_shift(system.smoothing_kernel,
+ cells_per_h,
+ surface_normal_method.contact_model.contact_angle)
+ end
+ return system
+end
+
+function initialize_wetted_area_boundary!(fluid_system, boundary_system)
+ cache = wetted_area_boundary_cache(boundary_system)
+ isnothing(cache) &&
+ throw(ArgumentError("contact boundaries require explicit per-particle `surface_measure` values"))
+ haskey(cache, :initial_colorfield) ||
+ throw(ArgumentError("contact boundaries require a positive `reference_particle_spacing`"))
+
+ normals = boundary_system.initial_condition.normals
+ isnothing(normals) &&
+ throw(ArgumentError("contact boundaries require `InitialCondition.normals`"))
+ surface_measure = cache.wetted_area_surface_measure
+ contact_model = fluid_system.surface_normal_method.contact_model
+ cache.wetted_area_active[] = !iszero(wetted_area_coefficient(fluid_system.surface_tension,
+ contact_model))
+ active_particles = findall(>(zero(eltype(surface_measure))), surface_measure)
+ isempty(active_particles) &&
+ throw(ArgumentError("each contact boundary requires at least one positive `surface_measure`"))
+ validate_wetted_area_patch_connectivity(boundary_system.initial_condition,
+ surface_measure, active_particles)
+
+ particle_spacing = fluid_system.cache.reference_particle_spacing
+ particle_volume = fluid_system.initial_condition.mass[first(eachparticle(fluid_system))] /
+ fluid_system.initial_condition.density[first(eachparticle(fluid_system))]
+ volume_scale = particle_volume / particle_spacing^3
+ for particle in eachparticle(fluid_system)
+ volume = fluid_system.initial_condition.mass[particle] /
+ fluid_system.initial_condition.density[particle]
+ isapprox(volume / particle_spacing^3, volume_scale;
+ rtol=100eps(typeof(volume_scale)), atol=zero(volume_scale)) ||
+ throw(ArgumentError("`WettedAreaContactAngle` requires uniform reference fluid particle volumes"))
+ end
+
+ smoothing_length = initial_smoothing_length(fluid_system)
+ support = compact_support(fluid_system.smoothing_kernel, one(smoothing_length))
+ set_zero!(cache.wetted_area_flooded_reference)
+ for particle in active_particles
+ normal = extract_svector(normals, boundary_system, particle)
+ all(isfinite, normal) ||
+ throw(ArgumentError("contact-boundary normals must be finite"))
+ normalized_offset = norm(normal) / smoothing_length
+ 0 < normalized_offset < support ||
+ throw(ArgumentError("the magnitude of each active contact-boundary normal must place the physical surface inside the kernel support"))
+ reference = volume_scale *
+ wetted_area_halfspace_reference(fluid_system.smoothing_kernel,
+ normalized_offset)
+ reference > eps(reference) ||
+ throw(ArgumentError("contact-boundary flooded colorfield references must be positive"))
+ cache.wetted_area_flooded_reference[particle] = reference
+ end
+ return boundary_system
+end
+
+function validate_wetted_area_patch_connectivity(initial_condition, surface_measure,
+ active_particles)
+ length(active_particles) == 1 && return initial_condition
+ spacing = initial_condition.particle_spacing
+ area_spacing = sqrt(maximum(surface_measure))
+ length_scale = max(spacing > 0 ? spacing : zero(spacing), area_spacing)
+ length_scale > 0 ||
+ throw(ArgumentError("positive contact surface measures must define a finite patch scale"))
+ connection_radius2 = (1.75length_scale)^2
+ coordinates = initial_condition.coordinates
+ visited = falses(length(surface_measure))
+ queue = [first(active_particles)]
+ visited[first(queue)] = true
+ next_particle = 1
+ while next_particle <= length(queue)
+ particle = queue[next_particle]
+ next_particle += 1
+ for neighbor in active_particles
+ visited[neighbor] && continue
+ distance2 = zero(eltype(coordinates))
+ for dim in axes(coordinates, 1)
+ distance2 += (coordinates[dim, particle] -
+ coordinates[dim, neighbor])^2
+ end
+ distance2 <= connection_radius2 || continue
+ visited[neighbor] = true
+ push!(queue, neighbor)
+ end
+ end
+ all(visited[active_particles]) ||
+ throw(ArgumentError("each contact boundary must contain one connected wetted-area patch"))
+ return initial_condition
+end
+
+@inline function prepare_wetted_area_boundary!(system, neighbor_system,
+ surface_normal_method)
+ return system
+end
+
+function prepare_wetted_area_boundary!(system::AbstractFluidSystem, neighbor_system,
+ surface_normal_method::ColorfieldSurfaceNormal{<:Any,
+ <:WettedAreaContactAngle})
+ boundary_cache = wetted_area_boundary_cache(neighbor_system)
+ isnothing(boundary_cache) && return system
+ (; wetted_area_surface_measure, wetted_area_flooded_reference,
+ wetted_area_weight, colorfield) = boundary_cache
+ set_zero!(wetted_area_weight)
+
+ raw_area = zero(eltype(system))
+ for particle in eachparticle(neighbor_system)
+ surface_measure = wetted_area_surface_measure[particle]
+ iszero(surface_measure) && continue
+ reference = wetted_area_flooded_reference[particle]
+ fraction = clamp(colorfield[particle] / reference, zero(reference), one(reference))
+ raw_area += surface_measure * cubic_smoothstep(fraction)
+ end
+
+ pi_ = convert(eltype(system), pi)
+ raw_radius = sqrt(raw_area / pi_)
+ edge_shift = system.cache.wetted_area_normalized_edge_shift[] *
+ initial_smoothing_length(system)
+ corrected_radius = max(raw_radius - edge_shift, zero(raw_radius))
+ corrected_area = pi_ * corrected_radius^2
+ area_derivative = raw_radius > eps(raw_radius) ? corrected_radius / raw_radius :
+ zero(raw_radius)
+ system.cache.wetted_area_raw_area[] += raw_area
+ system.cache.wetted_area[] += corrected_area
+
+ coefficient = wetted_area_coefficient(surface_tension_model(system),
+ surface_normal_method.contact_model)
+ iszero(coefficient) && return system
+ for particle in eachparticle(neighbor_system)
+ surface_measure = wetted_area_surface_measure[particle]
+ iszero(surface_measure) && continue
+ reference = wetted_area_flooded_reference[particle]
+ fraction = colorfield[particle] / reference
+ 0 < fraction < 1 || continue
+ wetted_area_weight[particle] = area_derivative * surface_measure / reference *
+ wetted_area_smoothstep_derivative(fraction)
+ end
+ return system
+end
+
+@inline function accumulate_wetted_area_density_conjugate!(system, neighbor_system,
+ surface_normal_method,
+ particle, neighbor, distance)
+ return system
+end
+
+@inline function accumulate_wetted_area_density_conjugate!(system::AbstractFluidSystem,
+ neighbor_system,
+ ::ColorfieldSurfaceNormal{<:Any,
+ <:WettedAreaContactAngle},
+ particle, neighbor, distance)
+ boundary_cache = wetted_area_boundary_cache(neighbor_system)
+ isnothing(boundary_cache) && return system
+ weight = @inbounds boundary_cache.wetted_area_weight[neighbor]
+ iszero(weight) && return system
+ kernel_value = smoothing_kernel(system, distance, particle)
+ @inbounds system.cache.wetted_area_density_conjugate[particle] += weight *
+ kernel_value
+ return system
+end
+
+@inline function finalize_wetted_area_contact!(system, surface_normal_method, v)
+ return system
+end
+
+function finalize_wetted_area_contact!(system::AbstractFluidSystem,
+ surface_normal_method::ColorfieldSurfaceNormal{<:Any,
+ <:WettedAreaContactAngle},
+ v)
+ coefficient = wetted_area_coefficient(surface_tension_model(system),
+ surface_normal_method.contact_model)
+ area = system.cache.wetted_area[]
+ system.cache.wetted_area_energy[] = iszero(coefficient) ? zero(coefficient) :
+ -coefficient * area
+ if iszero(coefficient)
+ set_zero!(system.cache.wetted_area_density_conjugate)
+ else
+ for particle in each_integrated_particle(system)
+ density = current_density(v, system, particle)
+ @inbounds system.cache.wetted_area_density_conjugate[particle] *= coefficient /
+ density^2
+ end
+ end
+ system.cache.wetted_area_evaluations[] += 1
+ return system
+end
+
+@inline function wetted_area_density_acceleration(surface_normal_method, particle_system,
+ neighbor_system, particle, neighbor,
+ rho_a, rho_b, m_b, grad_kernel)
+ return zero(grad_kernel)
+end
+
+@inline function wetted_area_density_acceleration(surface_normal_method::ColorfieldSurfaceNormal{<:Any,
+ <:WettedAreaContactAngle},
+ particle_system::AbstractFluidSystem,
+ neighbor_system::AbstractFluidSystem,
+ particle, neighbor, rho_a, rho_b, m_b,
+ grad_kernel)
+ particle_system === neighbor_system || return zero(grad_kernel)
+ conjugate_a = @inbounds particle_system.cache.wetted_area_density_conjugate[particle]
+ conjugate_b = @inbounds neighbor_system.cache.wetted_area_density_conjugate[neighbor]
+ pair_coefficient = conjugate_a * rho_a / rho_b + conjugate_b * rho_b / rho_a
+ iszero(pair_coefficient) && return zero(grad_kernel)
+ return -m_b * pair_coefficient * grad_kernel
+end
+
+@inline function wetted_area_explicit_acceleration(surface_tension,
+ surface_normal_method,
+ particle_system, neighbor_system,
+ particle, neighbor, m_a, rho_a,
+ grad_kernel)
+ return zero(grad_kernel)
+end
+
+@inline function wetted_area_explicit_acceleration(surface_tension,
+ surface_normal_method::ColorfieldSurfaceNormal{<:Any,
+ <:WettedAreaContactAngle},
+ particle_system::AbstractFluidSystem,
+ neighbor_system, particle, neighbor,
+ m_a, rho_a, grad_kernel)
+ boundary_cache = wetted_area_boundary_cache(neighbor_system)
+ isnothing(boundary_cache) && return zero(grad_kernel)
+ weight = @inbounds boundary_cache.wetted_area_weight[neighbor]
+ iszero(weight) && return zero(grad_kernel)
+ coefficient = wetted_area_coefficient(surface_tension,
+ surface_normal_method.contact_model)
+ iszero(coefficient) && return zero(grad_kernel)
+ acceleration = coefficient / rho_a * weight * grad_kernel
+ if neighbor_system isa WallBoundarySystem
+ thread = Threads.threadid()
+ reaction_buffer = boundary_cache.wetted_area_reaction_buffer
+ for dim in eachindex(acceleration)
+ @inbounds reaction_buffer[dim, neighbor, thread] -= m_a * acceleration[dim]
+ end
+ end
+ return acceleration
end
@inline function surface_normal(particle_system::AbstractFluidSystem, particle)
@@ -38,6 +744,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)
@@ -67,6 +789,9 @@ 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_divergence_correction!(system, surface_tension_model(system),
+ particle, m_b / density_neighbor,
+ pos_diff, grad_kernel)
cache.neighbor_count[particle] += 1
end
@@ -74,11 +799,38 @@ function calc_normal!(system::AbstractFluidSystem, neighbor_system::AbstractFlui
return system
end
+@inline function accumulate_surface_divergence_correction!(system, surface_tension,
+ particle, volume, pos_diff,
+ grad_kernel)
+ return system
+end
+
+@inline function accumulate_surface_divergence_correction!(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_surface_divergence_correction!(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
+
# 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)
+ surface_normal_method.boundary_contact_threshold == Inf && return system
+
(; cache) = system
(; colorfield, initial_colorfield) = neighbor_system.boundary_model.cache
(; boundary_contact_threshold) = surface_normal_method
@@ -103,19 +855,34 @@ function calc_boundary_normal!(system::AbstractFluidSystem, neighbor_system, u_s
end
maximum_colorfield = maximum(colorfield)
+ prepare_wetted_area_boundary!(system, neighbor_system, surface_normal_method)
foreach_point_neighbor(system, neighbor_system,
system_coords, neighbor_system_coords,
semi) do particle, neighbor, pos_diff, distance
+ m_b = hydrodynamic_mass(neighbor_system, neighbor)
+ density_neighbor = current_density(v_neighbor_system, neighbor_system, neighbor)
+ fluid_volume = hydrodynamic_mass(system, particle) /
+ current_density(v, system, particle)
+ grad_kernel = smoothing_kernel_grad(system, pos_diff, distance, particle)
+ accumulate_wetted_area_density_conjugate!(system, neighbor_system,
+ surface_normal_method, particle,
+ neighbor, distance)
+
+ # Boundary particles complete the quadrature stencil for the continuum-stress
+ # divergence, even though the solid itself carries no capillary stress.
+ accumulate_surface_divergence_correction!(system, surface_tension_model(system),
+ particle, m_b / density_neighbor,
+ pos_diff, grad_kernel)
+
# 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
- m_b = hydrodynamic_mass(system, particle)
- density_neighbor = current_density(v, system, particle)
- grad_kernel = smoothing_kernel_grad(system, pos_diff, distance, particle)
for i in 1:ndims(system)
- cache.surface_normal[i, particle] += m_b / density_neighbor * grad_kernel[i]
+ cache.surface_normal[i, particle] += fluid_volume * grad_kernel[i]
end
+ accumulate_boundary_normal!(system, surface_normal_method, particle,
+ fluid_volume, grad_kernel)
cache.neighbor_count[particle] += 1
end
end
@@ -123,11 +890,60 @@ function calc_boundary_normal!(system::AbstractFluidSystem, neighbor_system, u_s
return system
end
+@inline function accumulate_boundary_normal!(system, surface_normal_method, particle,
+ volume, grad_kernel)
+ return system
+end
+
+@inline function accumulate_boundary_normal!(system,
+ ::ColorfieldSurfaceNormal{<:Any,
+ <:AbstractContactAngleModel},
+ particle, volume, grad_kernel)
+ for i in 1:ndims(system)
+ @inbounds system.cache.boundary_normal[i, particle] += volume * grad_kernel[i]
+ end
+ return system
+end
+
+@inline function accumulate_boundary_normal!(system,
+ ::ColorfieldSurfaceNormal{<:Any,
+ <:WettedAreaContactAngle},
+ particle, volume, grad_kernel)
+ return system
+end
+
+@inline reset_boundary_normal!(system, surface_normal_method) = system
+
+@inline function reset_boundary_normal!(system,
+ surface_normal_method::ColorfieldSurfaceNormal{<:Any,
+ <:AbstractContactAngleModel})
+ set_zero!(system.cache.boundary_normal)
+ reset_contact_angle_data!(system, surface_normal_method.contact_model)
+ return system
+end
+
+@inline function reset_boundary_normal!(system,
+ surface_normal_method::ColorfieldSurfaceNormal{<:Any,
+ <:WettedAreaContactAngle})
+ reset_contact_angle_data!(system, surface_normal_method.contact_model)
+ return system
+end
+
+@inline reset_contact_angle_data!(system, contact_model) = system
+
+@inline function reset_contact_angle_data!(system, ::WettedAreaContactAngle)
+ set_zero!(system.cache.wetted_area_density_conjugate)
+ system.cache.wetted_area_energy[] = zero(eltype(system))
+ system.cache.wetted_area_raw_area[] = zero(eltype(system))
+ system.cache.wetted_area[] = zero(eltype(system))
+ return system
+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,
@@ -151,40 +967,111 @@ function remove_invalid_normals!(system::AbstractFluidSystem,
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
+ support_radius = compact_support(smoothing_kernel, initial_smoothing_length(system))
for particle in each_integrated_particle(system)
+ store_surface_delta!(system, surface_tension, particle, zero(eltype(system)))
+ store_surface_activity!(system, surface_tension, particle, zero(eltype(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]
+ 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)
-
- # See eq. 21
- if norm2 > normal_condition2
- cache.surface_normal[1:ndims(system),
- particle] = particle_surface_normal / sqrt(norm2)
- else
+ normal_norm = sqrt(norm2)
+ gradient_activity = gradient_interface_activity(normal_norm, support_radius,
+ surface_normal_method)
+ support_moment = surface_support_moment(system, surface_tension, particle)
+ support_activity = support_interface_activity(support_moment,
+ surface_normal_method)
+ activity = gradient_activity * support_activity
+ if iszero(activity)
cache.surface_normal[1:ndims(system), particle] .= 0
+ continue
+ end
+
+ store_surface_activity!(system, surface_tension, particle, activity)
+ store_surface_delta!(system, surface_tension, particle, normal_norm * activity)
+ cache.surface_normal[1:ndims(system),
+ particle] = particle_surface_normal / normal_norm
+ 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
+ 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
+
+@inline store_surface_delta!(system, surface_tension, particle, value) = system
+@inline function store_surface_delta!(system,
+ ::Union{SurfaceTensionMorris,
+ SurfaceTensionMomentumMorris},
+ particle, value)
+ # Only the fluid half of the symmetric, kernel-smoothed interface is sampled in a
+ # one-phase free-surface simulation. Multiplying by two gives a surface delta whose
+ # integral through the represented half-interface is one.
+ @inbounds system.cache.delta_s[particle] = 2 * value
+ return system
+end
+
+@inline store_surface_activity!(system, surface_tension, particle, value) = system
+
+@inline function store_surface_activity!(system,
+ ::Union{SurfaceTensionMorris,
+ SurfaceTensionMomentumMorris},
+ particle, value)
+ @inbounds system.cache.interface_activity[particle] = value
return system
end
@@ -200,6 +1087,8 @@ function compute_surface_normal!(system::AbstractFluidSystem,
# Reset surface normal
set_zero!(cache.surface_normal)
set_zero!(cache.neighbor_count)
+ reset_surface_divergence_correction!(system, surface_tension)
+ reset_boundary_normal!(system, surface_normal_method_)
# 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
@@ -210,8 +1099,338 @@ function compute_surface_normal!(system::AbstractFluidSystem,
u_neighbor_system, semi, surface_normal_method_,
surface_normal_method(neighbor_system))
end
+ finalize_wetted_area_contact!(system, surface_normal_method_, v)
remove_invalid_normals!(system, surface_tension, surface_normal_method_)
+ smooth_surface_normals!(system, surface_normal_method_, v, u, semi)
+ compute_contact_angle_cache!(system, surface_normal_method_, v, u, v_ode, u_ode,
+ semi)
+
+ 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 ccsf_eigenvalue_moment(moment, gamma,
+ ::CorrectedCSFSurfaceNormal{Nothing}) = moment
+
+@inline function ccsf_eigenvalue_moment(moment, gamma,
+ ::CorrectedCSFSurfaceNormal{<:Real})
+ gamma > eps(gamma) || return moment
+ return moment / gamma
+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
+
+@inline ccsf_boundary_gamma(system, particle,
+ ::CorrectedCSFSurfaceNormal{Nothing}) = one(eltype(system))
+@inline function ccsf_boundary_gamma(system, particle,
+ ::CorrectedCSFSurfaceNormal{<:Real})
+ distance = @inbounds system.cache.ccsf_boundary_distance[particle]
+ isfinite(distance) || return one(eltype(system))
+ normalized_distance = distance / smoothing_length(system, particle)
+ outside_fraction = wetted_area_halfspace_reference(system_smoothing_kernel(system),
+ normalized_distance)
+ return one(outside_fraction) - outside_fraction
+end
+
+@inline ccsf_has_boundary_geometry(::CorrectedCSFSurfaceNormal{Nothing}) = false
+@inline ccsf_has_boundary_geometry(::CorrectedCSFSurfaceNormal{<:Real}) = true
+
+@inline function ccsf_boundary_cache(system)
+ hasproperty(system, :boundary_model) || return nothing
+ cache = system.boundary_model.cache
+ haskey(cache, :wetted_area_surface_measure) || return nothing
+ return cache
+end
+
+@inline function ccsf_face_geometry(system, neighbor_system, particle, neighbor,
+ pos_diff)
+ boundary_cache = ccsf_boundary_cache(neighbor_system)
+ isnothing(boundary_cache) && return nothing
+ surface_measure = @inbounds boundary_cache.wetted_area_surface_measure[neighbor]
+ surface_measure > zero(surface_measure) || return nothing
+ normals = neighbor_system.initial_condition.normals
+ isnothing(normals) && return nothing
+ offset = extract_svector(normals, neighbor_system, neighbor)
+ offset_norm = norm(offset)
+ offset_norm > eps(offset_norm) || return nothing
+ wall_normal = offset / offset_norm # Points from the physical face into the wall.
+ face_diff = -pos_diff - offset # x_face - x_fluid
+ kernel_distance = norm(face_diff)
+ kernel_distance < compact_support(system, neighbor_system) || return nothing
+ boundary_distance = abs(dot(face_diff, wall_normal))
+ return (; surface_measure, wall_normal, face_diff, kernel_distance,
+ boundary_distance)
+end
+
+@inline function accumulate_ccsf_boundary_moments!(system, neighbor_system, v, u,
+ v_neighbor, u_neighbor, semi,
+ method)
+ return system
+end
+
+function accumulate_ccsf_boundary_moments!(system::AbstractFluidSystem,
+ neighbor_system::AbstractBoundarySystem,
+ v, u, v_neighbor, u_neighbor, semi,
+ ::CorrectedCSFSurfaceNormal{<:Real})
+ cache = system.cache
+ system_coordinates = current_coordinates(u, system)
+ neighbor_coordinates = current_coordinates(u_neighbor, neighbor_system)
+ foreach_point_neighbor(system, neighbor_system, system_coordinates,
+ neighbor_coordinates, semi;
+ points=each_integrated_particle(system)) do particle, neighbor,
+ pos_diff, distance
+ geometry = ccsf_face_geometry(system, neighbor_system, particle, neighbor,
+ pos_diff)
+ isnothing(geometry) && return
+ (; surface_measure, wall_normal, face_diff, kernel_distance,
+ boundary_distance) = geometry
+ kernel = smoothing_kernel(system, kernel_distance, particle)
+ weight = surface_measure * kernel
+ moment = weight * face_diff * permutedims(wall_normal)
+ for column in 1:ndims(system), row in 1:ndims(system)
+ @inbounds cache.ccsf_correction_matrix[row, column,
+ particle] += moment[row, column]
+ end
+ for dimension in 1:ndims(system)
+ @inbounds cache.ccsf_color_gradient[dimension,
+ particle] += weight * wall_normal[dimension]
+ end
+ if boundary_distance < @inbounds(cache.ccsf_boundary_distance[particle])
+ @inbounds cache.ccsf_boundary_distance[particle] = boundary_distance
+ for dimension in 1:ndims(system)
+ @inbounds cache.ccsf_boundary_normal[dimension,
+ particle] = -wall_normal[dimension]
+ end
+ end
+ end
+ return system
+end
+
+@inline function accumulate_ccsf_boundary_lambda_gradient!(system, neighbor_system, u,
+ u_neighbor, semi, method)
+ return system
+end
+
+function accumulate_ccsf_boundary_lambda_gradient!(system::AbstractFluidSystem,
+ neighbor_system::AbstractBoundarySystem,
+ u, u_neighbor, semi,
+ ::CorrectedCSFSurfaceNormal{<:Real})
+ cache = system.cache
+ coordinates = current_coordinates(u, system)
+ neighbor_coordinates = current_coordinates(u_neighbor, neighbor_system)
+ foreach_point_neighbor(system, neighbor_system, coordinates, neighbor_coordinates, semi;
+ points=each_integrated_particle(system)) do particle, neighbor,
+ pos_diff, distance
+ geometry = ccsf_face_geometry(system, neighbor_system, particle, neighbor,
+ pos_diff)
+ isnothing(geometry) && return
+ (; surface_measure, wall_normal, kernel_distance) = geometry
+ weight = surface_measure * smoothing_kernel(system, kernel_distance, particle)
+ lambda_i = @inbounds cache.ccsf_minimum_eigenvalue[particle]
+ renormalization = extract_smatrix(cache.ccsf_correction_matrix, system, particle)
+ coefficient = ccsf_lambda_difference(lambda_i, one(lambda_i))
+ contribution = coefficient * weight * renormalization * wall_normal
+ for dimension in 1:ndims(system)
+ @inbounds cache.ccsf_lambda_gradient[dimension,
+ particle] += contribution[dimension]
+ end
+ end
+ return system
+end
+
+function apply_ccsf_contact_normal!(system, method::CorrectedCSFSurfaceNormal{<:Real})
+ cache = system.cache
+ support = compact_support(system_smoothing_kernel(system),
+ initial_smoothing_length(system))
+ target_angle = deg2rad(convert(eltype(system), method.contact_angle))
+ for particle in each_integrated_particle(system)
+ distance = @inbounds cache.ccsf_boundary_distance[particle]
+ distance < support || continue
+ normal = surface_normal(system, particle)
+ dot(normal, normal) > eps(eltype(normal)) || continue
+ boundary_normal = extract_svector(cache.ccsf_boundary_normal, system, particle)
+ tangent = normal - dot(normal, boundary_normal) * boundary_normal
+ tangent_norm = norm(tangent)
+ tangent_norm > eps(tangent_norm) || continue
+ tangent /= tangent_norm
+ current_angle = acos(clamp(dot(normal, boundary_normal), -one(eltype(system)),
+ one(eltype(system))))
+ corrected_angle = target_angle +
+ (current_angle - target_angle) *
+ (distance / support)^2
+ corrected = cos(corrected_angle) * boundary_normal +
+ sin(corrected_angle) * tangent
+ for dimension in 1:ndims(system)
+ @inbounds cache.surface_normal[dimension, particle] = corrected[dimension]
+ end
+ end
+ return system
+end
+
+@inline apply_ccsf_contact_normal!(system, method) = system
+
+function compute_surface_normal!(system::AbstractFluidSystem,
+ method::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_gradient)
+ set_zero!(color_gradient)
+ set_zero!(shepard_sum)
+ set_zero!(cache.ccsf_boundary_normal)
+ fill!(cache.ccsf_boundary_distance, typemax(eltype(cache.ccsf_boundary_distance)))
+
+ @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
+
+ if ccsf_has_boundary_geometry(surface_normal_method(system))
+ foreach_system(semi) do neighbor_system
+ v_neighbor = wrap_v(v_ode, neighbor_system, semi)
+ u_neighbor = wrap_u(u_ode, neighbor_system, semi)
+ accumulate_ccsf_boundary_moments!(system, neighbor_system, v, u,
+ v_neighbor, u_neighbor, semi,
+ surface_normal_method(system))
+ end
+ end
+
+ @threaded semi for particle in each_integrated_particle(system)
+ inverse_renormalization = extract_smatrix(matrix_cache, system, particle)
+ gamma = ccsf_boundary_gamma(system, particle, method)
+ eigenvalue_moment = ccsf_eigenvalue_moment(inverse_renormalization, gamma,
+ method)
+ @inbounds lambda[particle] = ccsf_minimum_eigenvalue(eigenvalue_moment)
+ renormalization = abs(det(inverse_renormalization)) < 1.0f-9 ?
+ one(inverse_renormalization) : inv(inverse_renormalization)
+ ccsf_store_matrix!(matrix_cache, system, particle, renormalization)
+ end
+
+ if ccsf_has_boundary_geometry(surface_normal_method(system))
+ foreach_system(semi) do neighbor_system
+ u_neighbor = wrap_u(u_ode, neighbor_system, semi)
+ accumulate_ccsf_boundary_lambda_gradient!(system, neighbor_system, u,
+ u_neighbor, semi,
+ surface_normal_method(system))
+ end
+ 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]
+ gamma = ccsf_boundary_gamma(system, particle, method)
+ correction = shepard > eps(shepard) ?
+ max(one(shepard), gamma / (2shepard)) : one(shepard)
+ @inbounds cache.delta_s[particle] = 2correction / gamma * norm(raw_gradient)
+ @inbounds cache.support_moment[particle] = lambda_i
+ end
+ apply_ccsf_contact_normal!(system, surface_normal_method(system))
+
+ return system
+end
+
+@inline function compute_contact_angle_cache!(system, surface_normal_method, v, u,
+ v_ode, u_ode, semi)
+ return system
+end
+
+@inline reset_surface_divergence_correction!(system, surface_tension) = system
+
+@inline function reset_surface_divergence_correction!(system,
+ ::SurfaceTensionMomentumMorris)
+ set_zero!(system.cache.divergence_correction)
+ return system
+end
+
+@inline function reset_surface_divergence_correction!(system, ::SurfaceTensionMorris)
+ set_zero!(system.cache.support_moment)
return system
end
@@ -231,38 +1450,117 @@ 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
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)
- # 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
- # Eq. 23
- for particle in each_integrated_particle(system)
- curvature[particle] /= (correction_factor[particle] + eps())
+ 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
+function calc_curvature!(system::AbstractFluidSystem,
+ neighbor_system::AbstractBoundarySystem,
+ u_system, v, v_neighbor_system, u_neighbor_system, semi,
+ method::CorrectedCSFSurfaceNormal{<:Real},
+ neighbor_surface_normal_method)
+ cache = system.cache
+ coordinates = current_coordinates(u_system, system)
+ neighbor_coordinates = current_coordinates(u_neighbor_system, neighbor_system)
+ target_angle = deg2rad(convert(eltype(system), method.contact_angle))
+ cosine_threshold = -inv(convert(eltype(system), ndims(system)))
+ foreach_point_neighbor(system, neighbor_system, coordinates, neighbor_coordinates, semi;
+ points=each_integrated_particle(system)) do particle, neighbor,
+ pos_diff, distance
+ geometry = ccsf_face_geometry(system, neighbor_system, particle, neighbor,
+ pos_diff)
+ isnothing(geometry) && return
+ (; surface_measure, wall_normal, kernel_distance) = geometry
+ normal = surface_normal(system, particle)
+ dot(normal, normal) > eps(eltype(normal)) || return
+ boundary_normal = -wall_normal
+ tangent = normal - dot(normal, boundary_normal) * boundary_normal
+ tangent_norm = norm(tangent)
+ tangent_norm > eps(tangent_norm) || return
+ contact_normal = cos(target_angle) * boundary_normal +
+ sin(target_angle) * tangent / tangent_norm
+ dot(normal, contact_normal) >= cosine_threshold || return
+ renormalization = extract_smatrix(cache.ccsf_correction_matrix, system, particle)
+ weight = surface_measure * smoothing_kernel(system, kernel_distance, particle)
+ @inbounds cache.curvature[particle] += weight *
+ ccsf_corrected_divergence(contact_normal -
+ normal,
+ renormalization,
+ wall_normal)
+ end
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 normalized_surface_curvature(curvature_numerator, denominator)
+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 +1569,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..77d672e9c5 100644
--- a/src/schemes/fluid/surface_tension.jl
+++ b/src/schemes/fluid/surface_tension.jl
@@ -1,25 +1,97 @@
abstract type AbstractSurfaceTension end
abstract type AkinciTypeSurfaceTension <: AbstractSurfaceTension end
+function validate_surface_tension_coefficient(surface_tension_coefficient)
+ if !(surface_tension_coefficient isa Real) ||
+ !isfinite(surface_tension_coefficient) || surface_tension_coefficient < 0
+ throw(ArgumentError("`surface_tension_coefficient` must be a finite, non-negative real number"))
+ end
+
+ return surface_tension_coefficient
+end
+
@doc raw"""
CohesionForceAkinci(surface_tension_coefficient=1.0)
This model only implements the cohesion force of the Akinci [Akinci2013](@cite) surface tension model.
+It does not require a surface-normal method.
+
+The three-dimensional cohesion kernel uses the normalization published by Akinci et al. In two
+dimensions, TrixiParticles.jl uses an integral-matched extension that is independent of particle
+resolution.
See [`surface_tension`](@ref) for more details.
# Keywords
-- `surface_tension_coefficient=1.0`: Modifies the intensity of the surface tension-induced force,
- enabling the tuning of the fluid's surface tension properties within the simulation.
+- `surface_tension_coefficient=1.0`: Finite, non-negative coefficient modifying the
+ fluid-fluid cohesion force. Zero disables this force; wall adhesion is controlled by the
+ boundary's `adhesion_coefficient`.
"""
-struct CohesionForceAkinci{ELTYPE} <: AkinciTypeSurfaceTension
+struct CohesionForceAkinci{ELTYPE <: Real} <: AkinciTypeSurfaceTension
surface_tension_coefficient::ELTYPE
function CohesionForceAkinci(; surface_tension_coefficient=1.0)
- new{typeof(surface_tension_coefficient)}(surface_tension_coefficient)
+ coefficient = validate_surface_tension_coefficient(surface_tension_coefficient)
+ new{typeof(coefficient)}(coefficient)
+ end
+end
+
+const AKINCI_COHESION_SURFACE_ENERGY_FACTOR_3D = 21 / 7040
+
+@doc raw"""
+ SurfaceTensionAkinciCohesionPhysical(; surface_tension_coefficient,
+ reference_density)
+
+Three-dimensional, cohesion-only Akinci model with a physical surface tension coefficient.
+The model uses the central pair force of [`CohesionForceAkinci`](@ref), but converts the
+surface tension ``\sigma`` in N/m to the internal Akinci coefficient at the current compact
+support radius ``h_c`` according to
+
+```math
+\gamma = \frac{7040\sigma}{21\rho_0^2h_c^2}.
+```
+
+This conversion follows from the continuum surface energy of a planar interface. It removes
+the support-radius dependence of the original coefficient, requires no surface normals, and
+preserves the pair force's exact linear- and angular-momentum conservation.
+
+For wall interaction, the boundary's `adhesion_coefficient` is a dimensionless multiplier of
+the same cohesion kernel. The Young-Dupre mapping for a desired contact angle ``\theta`` is
+`adhesion_coefficient = (1 + cosd(theta)) / 2`. Thus, zero represents ``180^\circ`` and one
+represents ``0^\circ``. Values outside this range can be used for empirical tuning.
+
+This model is only supported in three dimensions. The original [`CohesionForceAkinci`](@ref)
+remains available when an empirical coefficient or a two-dimensional model is desired.
+
+# Keywords
+- `surface_tension_coefficient`: Finite, non-negative physical surface tension ``\sigma`` in
+ N/m. Zero disables fluid-fluid cohesion.
+- `reference_density`: Finite, positive rest density ``\rho_0`` in kg/m^3.
+"""
+struct SurfaceTensionAkinciCohesionPhysical{ELTYPE <: Real} <:
+ AkinciTypeSurfaceTension
+ surface_tension_coefficient :: ELTYPE
+ reference_density :: ELTYPE
+
+ function SurfaceTensionAkinciCohesionPhysical(; surface_tension_coefficient,
+ reference_density)
+ coefficient = validate_surface_tension_coefficient(surface_tension_coefficient)
+ if !(reference_density isa Real) || !isfinite(reference_density) ||
+ reference_density <= 0
+ throw(ArgumentError("`reference_density` must be a finite, positive real number"))
+ end
+
+ coefficient_, reference_density_ = promote(coefficient, reference_density)
+ new{typeof(coefficient_)}(coefficient_, reference_density_)
end
end
+@inline function akinci_physical_cohesion_coefficient(surface_tension, support_radius)
+ factor = oftype(support_radius, AKINCI_COHESION_SURFACE_ENERGY_FACTOR_3D)
+ return surface_tension.surface_tension_coefficient /
+ (factor * surface_tension.reference_density^2 * support_radius^2)
+end
+
@doc raw"""
SurfaceTensionAkinci(surface_tension_coefficient=1.0)
@@ -28,18 +100,22 @@ principles outlined by Akinci [Akinci2013](@cite). This model is instrumental in
behaviors of fluid surfaces, such as droplet formation and the dynamics of merging or
separation, by utilizing intra-particle forces.
+The three-dimensional cohesion and adhesion kernels use the normalizations published by Akinci
+et al. In two dimensions, TrixiParticles.jl uses integral-matched extensions that are independent
+of particle resolution.
+
See [`surface_tension`](@ref) for more details.
# Keywords
-- `surface_tension_coefficient=1.0`: A parameter to adjust the magnitude of
- surface tension forces, facilitating the fine-tuning of how surface tension phenomena
- are represented in the simulation.
+- `surface_tension_coefficient=1.0`: Finite, non-negative coefficient adjusting the
+ magnitude of surface tension forces. Zero disables the fluid-fluid force.
"""
-struct SurfaceTensionAkinci{ELTYPE} <: AkinciTypeSurfaceTension
+struct SurfaceTensionAkinci{ELTYPE <: Real} <: AkinciTypeSurfaceTension
surface_tension_coefficient::ELTYPE
function SurfaceTensionAkinci(; surface_tension_coefficient=1.0)
- new{typeof(surface_tension_coefficient)}(surface_tension_coefficient)
+ coefficient = validate_surface_tension_coefficient(surface_tension_coefficient)
+ new{typeof(coefficient)}(coefficient)
end
end
@@ -51,18 +127,24 @@ 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 normalized surface delta. The local
+continuum-surface-force acceleration is evaluated once per particle as
+``-sigma * kappa * delta_s * n_hat / rho``. Smooth interface activity is shared with
+[`SurfaceTensionMomentumMorris`](@ref), avoiding discrete normal and curvature-stencil switches.
+
See [`surface_tension`](@ref) for more details.
# Keywords
-- `surface_tension_coefficient=1.0`: Adjusts the magnitude of the surface tension
- forces, enabling tuning of fluid surface behaviors in simulations.
+- `surface_tension_coefficient=1.0`: Finite, non-negative physical surface tension in N/m.
+ Zero disables the force.
"""
-struct SurfaceTensionMorris{ELTYPE} <: AbstractSurfaceTension
+struct SurfaceTensionMorris{ELTYPE <: Real} <: AbstractSurfaceTension
surface_tension_coefficient::ELTYPE
function SurfaceTensionMorris(; surface_tension_coefficient=1.0)
- new{typeof(surface_tension_coefficient)}(surface_tension_coefficient)
+ coefficient = validate_surface_tension_coefficient(surface_tension_coefficient)
+ new{typeof(coefficient)}(coefficient)
end
end
@@ -70,44 +152,90 @@ function create_cache_surface_tension(surface_tension, ELTYPE, NDIMS, nparticles
return (;)
end
+function create_cache_surface_tension(::SurfaceTensionAkinciCohesionPhysical, ELTYPE,
+ NDIMS, nparticles)
+ if NDIMS != 3
+ throw(ArgumentError("`SurfaceTensionAkinciCohesionPhysical` is only supported in three dimensions"))
+ end
+
+ return (;)
+end
+
+function create_cache_surface_tension(::AkinciTypeSurfaceTension, ELTYPE, NDIMS,
+ nparticles)
+ if NDIMS != 2 && NDIMS != 3
+ throw(ArgumentError("Akinci surface tension is only supported in two and three dimensions"))
+ end
+
+ return (;)
+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)
+ support_moment = Array{ELTYPE, 1}(undef, nparticles)
+ return (; curvature, delta_s, interface_activity, support_moment)
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.
+
+This is a one-phase free-surface formulation. Validated wetted-wall energy can be enabled
+explicitly with
+`ColorfieldSurfaceNormal(contact_model=WettedAreaContactAngle(theta))`; omitting the contact model
+preserves the no-wetting default.
See [`surface_tension`](@ref) for more details.
# Keywords
-- `surface_tension_coefficient=1.0`: A parameter to adjust the strength of surface tension
- forces, allowing fine-tuning to replicate physical behavior.
+- `surface_tension_coefficient=1.0`: Finite, non-negative physical surface tension in N/m.
+ Zero disables the force.
"""
-struct SurfaceTensionMomentumMorris{ELTYPE} <: AbstractSurfaceTension
+struct SurfaceTensionMomentumMorris{ELTYPE <: Real} <: AbstractSurfaceTension
surface_tension_coefficient::ELTYPE
function SurfaceTensionMomentumMorris(; surface_tension_coefficient=1.0)
- new{typeof(surface_tension_coefficient)}(surface_tension_coefficient)
+ coefficient = validate_surface_tension_coefficient(surface_tension_coefficient)
+ new{typeof(coefficient)}(coefficient)
end
end
+# Surface-model capabilities are expressed through dispatch so that constructors and update
+# stages do not need to duplicate concrete model checks.
+@inline requires_surface_normal(::Nothing) = false
+@inline requires_surface_normal(::CohesionForceAkinci) = false
+@inline requires_surface_normal(::SurfaceTensionAkinciCohesionPhysical) = false
+@inline requires_surface_normal(::Any) = true
+
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)
+ interface_activity = Array{ELTYPE, 1}(undef, nparticles)
+ divergence_correction = Array{ELTYPE, 1}(undef, nparticles)
+ return (; delta_s, interface_activity, divergence_correction)
end
-@inline function stress_tensor(particle_system::AbstractFluidSystem, particle)
- return extract_smatrix(particle_system.cache.stress_tensor, particle_system, particle)
+# `surface_normal` stores the unscaled colorfield gradient, which is also used by the Morris
+# models. Equation 3 in Akinci et al. uses the dimensionless normal from their equation 2,
+# whose prefactor `h` is the compact-support radius, not the kernel smoothing length.
+@inline function akinci_surface_normal(particle_system::AbstractFluidSystem, particle)
+ support_radius = compact_support(system_smoothing_kernel(particle_system),
+ smoothing_length(particle_system, particle))
+ return support_radius * surface_normal(particle_system, particle)
end
# Note that `floating_point_number^integer_literal` is lowered to `Base.literal_pow`.
@@ -117,11 +245,29 @@ end
# By using the `@fastpow` macro, we are consciously trading off some precision in the result
# for enhanced computational speed. This is especially useful in scenarios where performance
# is a higher priority than exact precision.
+@fastpow @inline function cohesion_kernel_normalization_akinci(support_radius, ::Val{2})
+ return oftype(support_radius, 25280 / (627 * pi)) / support_radius^8
+end
+
+@fastpow @inline function cohesion_kernel_normalization_akinci(support_radius, ::Val{3})
+ return oftype(support_radius, 32 / pi) / support_radius^9
+end
+
+@inline function adhesion_kernel_normalization_akinci(support_radius, ::Val{2})
+ return oftype(support_radius, 13 / 1200) /
+ (support_radius^2 * sqrt(sqrt(support_radius)))
+end
+
+@inline function adhesion_kernel_normalization_akinci(support_radius, ::Val{3})
+ return oftype(support_radius, 0.007) /
+ (support_radius^3 * sqrt(sqrt(support_radius)))
+end
+
@fastpow @inline function cohesion_force_akinci(surface_tension, support_radius, m_b,
- pos_diff, distance)
+ pos_diff, distance, dimensions)
(; surface_tension_coefficient) = surface_tension
- # Eq. 2
+ # Eq. 2, using the published normalization in 3D and an integral-matched one in 2D.
# We only reach this function when `sqrt(eps()) < distance <= support_radius`
if distance > 0.5 * support_radius
# Attractive force
@@ -131,7 +277,7 @@ end
# Repulsive force
C = 2 * (support_radius - distance)^3 * distance^3 - support_radius^6 / 64.0
end
- C *= 32.0 / (pi * support_radius^9)
+ C *= cohesion_kernel_normalization_akinci(support_radius, dimensions)
# Eq. 1 in acceleration form
cohesion_force = -surface_tension_coefficient * m_b * C * pos_diff / distance
@@ -140,19 +286,17 @@ end
end
@inline function adhesion_force_akinci(surface_tension, support_radius, m_b, pos_diff,
- distance, adhesion_coefficient)
-
- # The neighborhood search has an `<=` check, but for `distance == support_radius`
- # the term inside the parentheses might be very slightly negative, causing an error with `^0.25`.
- # TODO Change this in the neighborhood search?
- # See https://github.com/trixi-framework/PointNeighbors.jl/issues/19
+ distance, adhesion_coefficient, dimensions)
distance >= support_radius && return zero(pos_diff)
distance <= 0.5 * support_radius && return zero(pos_diff)
- # Eq. 7
- A = 0.007 / support_radius^3.25 *
- (-4 * distance^2 / support_radius + 6 * distance - 2 * support_radius)^0.25
+ # Eq. 7. The factored radicand avoids cancellation close to the support boundary.
+ radicand = 2 * (2 * distance - support_radius) *
+ (support_radius - distance) / support_radius
+ fourth_root = sqrt(sqrt(max(zero(radicand), radicand)))
+ normalization = adhesion_kernel_normalization_akinci(support_radius, dimensions)
+ A = normalization * fourth_root
# Eq. 6 in acceleration form with `m_b` being the boundary mass calculated as
# `m_b = rho_0 * volume` (Akinci boundary condition treatment)
@@ -188,7 +332,30 @@ end
dv_particle[] += surface_tension_correction *
cohesion_force_akinci(surface_tension_a, support_radius, m_b,
- pos_diff, distance)
+ pos_diff, distance, Val(ndims(particle_system)))
+
+ return dv_particle
+end
+
+@inline function surface_tension_force!(dv_particle,
+ surface_tension_a::SurfaceTensionAkinciCohesionPhysical,
+ surface_tension_b::SurfaceTensionAkinciCohesionPhysical,
+ particle_system::AbstractFluidSystem,
+ neighbor_system::AbstractFluidSystem,
+ particle, neighbor, pos_diff, distance, rho_a,
+ rho_b, grad_kernel,
+ surface_tension_correction)
+ distance^2 < eps(initial_smoothing_length(particle_system)^2) && return dv_particle
+
+ support_radius = compact_support(system_smoothing_kernel(particle_system),
+ smoothing_length(particle_system, particle))
+ coefficient = akinci_physical_cohesion_coefficient(surface_tension_a,
+ support_radius)
+ cohesion = (; surface_tension_coefficient=coefficient)
+ m_b = hydrodynamic_mass(neighbor_system, neighbor)
+ dv_particle[] += surface_tension_correction *
+ cohesion_force_akinci(cohesion, support_radius, m_b, pos_diff,
+ distance, Val(ndims(particle_system)))
return dv_particle
end
@@ -204,20 +371,20 @@ end
(; smoothing_kernel) = particle_system
(; surface_tension_coefficient) = surface_tension_a
- smoothing_length_ = smoothing_length(particle_system, particle)
# 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
m_b = hydrodynamic_mass(neighbor_system, neighbor)
- n_a = surface_normal(particle_system, particle)
- n_b = surface_normal(neighbor_system, neighbor)
- support_radius = compact_support(smoothing_kernel, smoothing_length_)
+ n_a = akinci_surface_normal(particle_system, particle)
+ n_b = akinci_surface_normal(neighbor_system, neighbor)
+ support_radius = compact_support(smoothing_kernel,
+ smoothing_length(particle_system, particle))
dv_particle[] += surface_tension_correction *
cohesion_force_akinci(surface_tension_a, support_radius, m_b,
- pos_diff, distance)
+ pos_diff, distance, Val(ndims(particle_system)))
dv_particle[] -= surface_tension_correction * surface_tension_coefficient *
- (n_a - n_b) * smoothing_length_
+ (n_a - n_b)
return dv_particle
end
@@ -229,71 +396,50 @@ 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
-
- n_a = surface_normal(particle_system, particle)
- curvature_a = curvature(particle_system, particle)
-
- dv_particle[] -= surface_tension_correction * surface_tension_coefficient / rho_a *
- curvature_a * n_a
-
+ # Morris CSF is a particle-local continuum force. It is added once outside the
+ # neighbor loop by `surface_tension_acceleration`.
return dv_particle
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
+@inline function surface_tension_acceleration(surface_tension, particle_system, particle,
+ rho_a, vector_template)
+ return zero(vector_template)
+end
+
+@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 system
+ 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
end
-function compute_surface_delta_function!(system, surface_tension, semi)
- return system
+@inline function contact_angle_acceleration(surface_tension, particle_system,
+ surface_normal_method, particle, rho_a,
+ vector_template)
+ return zero(vector_template)
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
+@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)
- set_zero!(delta_s)
+ normal = surface_tension_normal(particle_system, particle)
+ return delta_s * (grad_kernel - normal * dot(normal, grad_kernel))
+end
- @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,
@@ -309,13 +455,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
@@ -339,11 +491,46 @@ end
support_radius = compact_support(particle_system.smoothing_kernel,
smoothing_length(particle_system, particle))
dv_particle[] += adhesion_force_akinci(surface_tension, support_radius, m_b, pos_diff,
- distance, adhesion_coefficient)
+ distance, adhesion_coefficient,
+ Val(ndims(particle_system)))
return dv_particle
end
+@inline function akinci_physical_wall_cohesion_force!(dv_particle,
+ surface_tension::SurfaceTensionAkinciCohesionPhysical,
+ particle_system::AbstractFluidSystem,
+ neighbor_system,
+ particle, neighbor, pos_diff,
+ distance)
+ wall_ratio = neighbor_system.adhesion_coefficient
+ iszero(wall_ratio) && return dv_particle
+ distance^2 < eps(initial_smoothing_length(particle_system)^2) && return dv_particle
+
+ support_radius = compact_support(system_smoothing_kernel(particle_system),
+ smoothing_length(particle_system, particle))
+ distance >= support_radius && return dv_particle
+
+ coefficient = wall_ratio *
+ akinci_physical_cohesion_coefficient(surface_tension, support_radius)
+ wall_cohesion = (; surface_tension_coefficient=coefficient)
+ m_b = hydrodynamic_mass(neighbor_system, neighbor)
+ dv_particle[] += cohesion_force_akinci(wall_cohesion, support_radius, m_b, pos_diff,
+ distance, Val(ndims(particle_system)))
+
+ return dv_particle
+end
+
+@inline function adhesion_force!(dv_particle,
+ surface_tension::SurfaceTensionAkinciCohesionPhysical,
+ particle_system::AbstractFluidSystem,
+ neighbor_system::AbstractBoundarySystem,
+ particle, neighbor, pos_diff, distance)
+ return akinci_physical_wall_cohesion_force!(dv_particle, surface_tension,
+ particle_system, neighbor_system,
+ particle, neighbor, pos_diff, distance)
+end
+
@inline function adhesion_force!(dv_particle, surface_tension, particle_system,
neighbor_system, particle, neighbor, pos_diff, distance)
return dv_particle
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]
diff --git a/src/schemes/fluid/weakly_compressible_sph/rhs.jl b/src/schemes/fluid/weakly_compressible_sph/rhs.jl
index 836063538b..04437858e0 100644
--- a/src/schemes/fluid/weakly_compressible_sph/rhs.jl
+++ b/src/schemes/fluid/weakly_compressible_sph/rhs.jl
@@ -11,6 +11,7 @@ function interact!(dv, v_particle_system, u_particle_system,
surface_tension_a = surface_tension_model(particle_system)
surface_tension_b = surface_tension_model(neighbor_system)
+ surface_normal_method_a = surface_normal_method(particle_system)
system_coords = current_coordinates(u_particle_system, particle_system)
neighbor_system_coords = current_coordinates(u_neighbor_system, neighbor_system)
@@ -40,6 +41,14 @@ 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)
+ dv_particle[] += contact_angle_acceleration(surface_tension_a, particle_system,
+ surface_normal_method_a, particle,
+ rho_a, v_a)
+ end
# Loop over all neighbors within the kernel cutoff
@inbounds foreach_neighbor(system_coords, neighbor_system_coords,
@@ -69,10 +78,15 @@ function interact!(dv, v_particle_system, u_particle_system,
# Determine correction factors.
# This can usually be ignored, as these are all 1 when no correction is used.
+ correction_rho_a = correction_density(correction, particle_system, particle,
+ rho_a)
+ correction_rho_b = correction_density(correction, neighbor_system, neighbor,
+ rho_b)
(viscosity_correction, pressure_correction,
surface_tension_correction) = free_surface_correction(correction,
particle_system,
- rho_a, rho_b)
+ correction_rho_a,
+ correction_rho_b)
# For `ContinuityDensity` without correction, this is equivalent to
# dv_pressure = -m_b * (p_a + p_b) / (rho_a * rho_b) * grad_kernel
@@ -101,12 +115,25 @@ function interact!(dv, v_particle_system, u_particle_system,
particle_system, neighbor_system,
particle, neighbor, pos_diff, distance,
rho_a, rho_b, grad_kernel,
- surface_tension_correction)
+ surface_tension_correction)
+
+ dv_particle[] += wetted_area_density_acceleration(surface_normal_method_a,
+ particle_system,
+ neighbor_system, particle,
+ neighbor, rho_a, rho_b, m_b,
+ grad_kernel)
@inbounds adhesion_force!(dv_particle, surface_tension_a, particle_system,
neighbor_system,
particle, neighbor, pos_diff, distance)
+ dv_particle[] += wetted_area_explicit_acceleration(surface_tension_a,
+ surface_normal_method_a,
+ particle_system,
+ neighbor_system, particle,
+ neighbor, m_a, rho_a,
+ grad_kernel)
+
# TODO If variable smoothing_length is used, this should use the neighbor smoothing length
# Propagate `@inbounds` to the continuity equation, which accesses particle data
@inbounds continuity_equation!(drho_particle, density_calculator,
@@ -124,6 +151,19 @@ function interact!(dv, v_particle_system, u_particle_system,
return dv
end
+@inline function correction_density(::AkinciFreeSurfaceCorrection,
+ system::WeaklyCompressibleSPHSystem,
+ particle, density)
+ if system.density_calculator isa ContinuityDensity &&
+ haskey(system.cache, :kernel_summation_density)
+ return @inbounds system.cache.kernel_summation_density[particle]
+ end
+
+ return density
+end
+
+@inline correction_density(correction, system, particle, density) = density
+
@propagate_inbounds function neighbor_pressure(v_neighbor_system, neighbor_system,
neighbor, p_a)
return current_pressure(v_neighbor_system, neighbor_system, neighbor)
diff --git a/src/schemes/fluid/weakly_compressible_sph/system.jl b/src/schemes/fluid/weakly_compressible_sph/system.jl
index eea0607d7d..03aeaf5a6b 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/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.
@@ -54,9 +56,10 @@ See [Weakly Compressible SPH](@ref wcsph) for more details on the method.
gravity-like source terms.
- `surface_tension`: Surface tension model used for this SPH system. (default: no surface tension)
- `surface_normal_method`: The surface normal method to be used for this SPH system.
- (default: no surface normal method or `ColorfieldSurfaceNormal()` if a surface_tension model is used)
+ (default: no surface normal method or `ColorfieldSurfaceNormal()`
+ if the surface tension model requires normals)
- `reference_particle_spacing`: The reference particle spacing used for weighting values at the boundary,
- which currently is only needed when using surface tension.
+ which is needed when using a surface-normal method.
- `color_value`: Integer label used for calculation of surface normals.
Currently this is only used together with [`BoundaryModelDummyParticles`](@ref) and
[`ColorfieldSurfaceNormal`](@ref): fluid-boundary normal evaluation
@@ -130,12 +133,17 @@ function WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel,
throw(ArgumentError("`ShepardKernelCorrection` cannot be used with `ContinuityDensity`"))
end
- if surface_tension !== nothing && surface_normal_method === nothing
- surface_normal_method = ColorfieldSurfaceNormal()
- end
+ surface_normal_method = default_surface_normal_method(surface_tension,
+ surface_normal_method)
+ 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 `ColorfieldSurfaceNormal` or a surface tension model"))
+ throw(ArgumentError("`reference_particle_spacing` must be set to a positive value when using a surface-normal method"))
end
pressure_acceleration = choose_pressure_acceleration_formulation(pressure_acceleration,
@@ -229,6 +237,8 @@ end
@inline Base.eltype(::WeaklyCompressibleSPHSystem{<:Any, ELTYPE}) where {ELTYPE} = ELTYPE
+@inline wetted_area_supported_fluid(::WeaklyCompressibleSPHSystem) = true
+
@inline function v_nvariables(system::WeaklyCompressibleSPHSystem)
return v_nvariables(system, system.density_calculator)
end
@@ -321,7 +331,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)
@@ -334,7 +344,22 @@ 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
+
+function compute_correction_values!(system::WeaklyCompressibleSPHSystem,
+ ::AkinciFreeSurfaceCorrection, u,
+ v_ode, u_ode, semi)
+ compute_akinci_correction_density!(system, system.density_calculator, u, u_ode, semi)
+ return system
+end
+
+function compute_akinci_correction_density!(system, ::ContinuityDensity, u, u_ode, semi)
+ summation_density!(system, semi, u, u_ode, system.cache.kernel_summation_density)
+ return system
+end
+
+function compute_akinci_correction_density!(system, ::SummationDensity, u, u_ode, semi)
return system
end
@@ -344,7 +369,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/src/schemes/structure/rigid_body/system.jl b/src/schemes/structure/rigid_body/system.jl
index 033ea9f1da..2152977c8d 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,
@@ -302,11 +303,22 @@ end
smoothing_length(particle_system, particle))
dv_particle[] += adhesion_force_akinci(surface_tension, support_radius, m_b,
- pos_diff, distance, adhesion_coefficient)
+ pos_diff, distance, adhesion_coefficient,
+ Val(ndims(particle_system)))
return dv_particle
end
+@inline function adhesion_force!(dv_particle,
+ surface_tension::SurfaceTensionAkinciCohesionPhysical,
+ particle_system::AbstractFluidSystem,
+ neighbor_system::RigidBodySystem,
+ particle, neighbor, pos_diff, distance)
+ return akinci_physical_wall_cohesion_force!(dv_particle, surface_tension,
+ particle_system, neighbor_system,
+ particle, neighbor, pos_diff, distance)
+end
+
function write_u0!(u0, system::RigidBodySystem)
(; initial_condition) = system
@@ -379,6 +391,8 @@ end
function reset_interaction_caches!(system::RigidBodySystem)
set_zero!(system.force_per_particle)
+ boundary_cache = wetted_area_boundary_cache(system)
+ isnothing(boundary_cache) || set_zero!(boundary_cache.wetted_area_reaction)
system.cache.contact_count[] = 0
system.cache.max_contact_penetration[] = zero(eltype(system))
diff --git a/src/schemes/structure/structure.jl b/src/schemes/structure/structure.jl
index dd7e8d38ea..128b99e07e 100644
--- a/src/schemes/structure/structure.jl
+++ b/src/schemes/structure/structure.jl
@@ -88,6 +88,10 @@ function interact_structure_fluid!(dv, v_particle_system, u_particle_system,
adhesion_force!(dv_particle, surface_tension, neighbor_system, particle_system,
neighbor, particle, pos_diff, distance)
+ accumulate_wetted_area_structure_reaction!(dv_particle, particle_system,
+ neighbor_system, particle, neighbor,
+ rho_b, m_b, grad_kernel)
+
accumulate_structure_fluid_pair!(dv, dv_particle[], particle_system, particle, m_b)
drho_particle = Ref(zero(rho_a))
@@ -101,6 +105,40 @@ function interact_structure_fluid!(dv, v_particle_system, u_particle_system,
return dv
end
+@inline function accumulate_wetted_area_structure_reaction!(dv_particle, particle_system,
+ fluid_system, particle,
+ fluid_particle, fluid_density,
+ fluid_mass, grad_kernel)
+ return dv_particle
+end
+
+@inline function accumulate_wetted_area_structure_reaction!(dv_particle,
+ particle_system::RigidBodySystem,
+ fluid_system::AbstractFluidSystem,
+ particle, fluid_particle,
+ fluid_density, fluid_mass,
+ grad_kernel)
+ surface_normal_method_ = surface_normal_method(fluid_system)
+ surface_normal_method_ isa ColorfieldSurfaceNormal{<:Any,
+ <:WettedAreaContactAngle} ||
+ return dv_particle
+ boundary_cache = wetted_area_boundary_cache(particle_system)
+ isnothing(boundary_cache) && return dv_particle
+ weight = @inbounds boundary_cache.wetted_area_weight[particle]
+ iszero(weight) && return dv_particle
+ coefficient = wetted_area_coefficient(surface_tension_model(fluid_system),
+ surface_normal_method_.contact_model)
+ iszero(coefficient) && return dv_particle
+
+ reaction_acceleration = coefficient / fluid_density * weight * grad_kernel
+ dv_particle[] += reaction_acceleration
+ reaction = fluid_mass * reaction_acceleration
+ for dim in eachindex(reaction)
+ @inbounds boundary_cache.wetted_area_reaction[dim, particle] += reaction[dim]
+ end
+ return dv_particle
+end
+
@inline function continuity_equation!(drho_particle,
particle_system::AbstractStructureSystem,
neighbor_system::AbstractFluidSystem,
diff --git a/src/setups/complex_shape.jl b/src/setups/complex_shape.jl
index 6a78b412e2..d0da406f7d 100644
--- a/src/setups/complex_shape.jl
+++ b/src/setups/complex_shape.jl
@@ -52,6 +52,8 @@ function ComplexShape(geometry; particle_spacing, density,
throw(ArgumentError("`WindingNumberHormann` only supports 2D geometries"))
end
+ require_closed_geometry(geometry, "ComplexShape")
+
if grid_offset < 0.0
throw(ArgumentError("only a positive `grid_offset` is supported"))
end
@@ -91,8 +93,10 @@ of the geometry.
- `boundary_density`: Density of each boundary particle.
- `place_on_shell`: When `place_on_shell=true`, boundary particles will be placed
one particle spacing from the surface of the geometry.
- Otherwise when `place_on_shell=true` (simulating fluid particles),
+ Otherwise when `place_on_shell=false` (simulating fluid particles),
boundary particles will be placed half particle spacing away from the surface.
+ Thus, `boundary_thickness` must be at least one particle spacing
+ for `place_on_shell=true` and half a particle spacing otherwise.
# Examples
@@ -111,7 +115,7 @@ boundary_sampled = sample_boundary(signed_distance_field; boundary_density=1.0,
โ InitialCondition โ
โ โโโโโโโโโโโโโโโโ โ
โ #dimensions: โฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆ 2 โ
-โ #particles: โฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆ 889 โ
+โ #particles: โฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆ 677 โ
โ particle spacing: โฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆ 0.03 โ
โ eltype: โฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆ Float64 โ
โ coordinate eltype: โฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆโฆ Float64 โ
@@ -133,10 +137,22 @@ function sample_boundary(signed_distance_field;
end
# Only keep the required part of the signed distance field
- distance_to_boundary = zero(particle_spacing)
- keep_indices = (distance_to_boundary .< distances .<= max_signed_distance)
+ distance_to_boundary = place_on_shell ? particle_spacing : particle_spacing / 2
+ if boundary_thickness < distance_to_boundary
+ throw(ArgumentError("`boundary_thickness` must be at least " *
+ "`particle_spacing` for `place_on_shell=true` and " *
+ "half `particle_spacing` for `place_on_shell=false`."))
+ end
+
+ keep_indices = (distance_to_boundary .<= distances .<= boundary_thickness)
+ boundary_positions = positions[keep_indices]
+
+ if isempty(boundary_positions)
+ throw(ArgumentError("No boundary particles were sampled. Increase " *
+ "`boundary_thickness` or generate a denser `SignedDistanceField`."))
+ end
- boundary_coordinates = stack(positions[keep_indices])
+ boundary_coordinates = stack(boundary_positions)
return InitialCondition(; coordinates=boundary_coordinates, density=boundary_density,
particle_spacing)
end
diff --git a/src/setups/extrude_geometry.jl b/src/setups/extrude_geometry.jl
index 460583c38e..43455678bb 100644
--- a/src/setups/extrude_geometry.jl
+++ b/src/setups/extrude_geometry.jl
@@ -87,6 +87,14 @@ shape = extrude_geometry(shape; direction, particle_spacing=0.1, n_extrude=4, de
function extrude_geometry(geometry; particle_spacing=-1, direction, n_extrude::Integer,
velocity=zeros(length(direction)), place_on_shell=false,
mass=nothing, density=nothing, pressure=0.0)
+ if all(iszero, direction)
+ throw(ArgumentError("`direction` needs to be non-zero"))
+ end
+
+ if n_extrude < 1
+ throw(ArgumentError("`n_extrude` needs to be positive"))
+ end
+
direction_ = normalize(direction)
NDIMS = length(direction_)
@@ -105,6 +113,11 @@ function extrude_geometry(geometry; particle_spacing=-1, direction, n_extrude::I
face_coords = sample_plane(geometry, particle_spacing; place_on_shell)
+ if size(face_coords, 1) != NDIMS
+ throw(ArgumentError("`direction` must be of length $(size(face_coords, 1)) " *
+ "for the sampled geometry"))
+ end
+
coords = (face_coords .+ i * particle_spacing * direction_ for i in 0:(n_extrude - 1))
# In this context, `stack` is faster than `hcat(coords...)`
@@ -235,6 +248,10 @@ end
function shift_plane_corners(plane_points::NTuple{2}, direction, particle_spacing,
place_on_shell)
+ if length(direction) != 2
+ throw(ArgumentError("`direction` must be 2D when extruding 2D points"))
+ end
+
# With `place_on_shell`, particles need to be AT the min coordinates and not half a particle
# spacing away from it.
(place_on_shell) && (return plane_points)
@@ -254,6 +271,10 @@ end
function shift_plane_corners(plane_points::NTuple{3}, direction, particle_spacing,
place_on_shell)
+ if length(direction) != 3
+ throw(ArgumentError("`direction` must be 3D when extruding 3D points"))
+ end
+
# With `place_on_shell`, particles need to be AT the min coordinates and not half a particle
# spacing away from it.
(place_on_shell) && (return plane_points)
diff --git a/src/setups/rectangular_shape.jl b/src/setups/rectangular_shape.jl
index c9043a35e6..a1daff091d 100644
--- a/src/setups/rectangular_shape.jl
+++ b/src/setups/rectangular_shape.jl
@@ -92,7 +92,7 @@ function RectangularShape(particle_spacing, n_particles_per_dimension, min_coord
throw(ArgumentError("`min_coordinates` must be of length $NDIMS for a $(NDIMS)D problem"))
end
- if density !== nothing && any(density .< eps())
+ if density !== nothing && !(density isa Function) && any(density .< eps())
throw(ArgumentError("`density` needs to be positive and larger than $(eps())"))
end
@@ -105,34 +105,50 @@ function RectangularShape(particle_spacing, n_particles_per_dimension, min_coord
place_on_shell, loop_order)
if !isnothing(coordinates_perturbation)
- seed!(1)
amplitude = coordinates_perturbation * particle_spacing
- coordinates .+= rand((-amplitude):(particle_spacing * 1e-3):(amplitude),
+ coordinates .+= rand(MersenneTwister(1),
+ (-amplitude):(particle_spacing * 1e-3):(amplitude),
NDIMS, n_particles)
end
# Allow zero acceleration with state equation, but interpret `nothing` acceleration
# with state equation as a likely mistake.
if acceleration isa AbstractVector || acceleration isa Tuple
+ if length(acceleration) != NDIMS
+ throw(ArgumentError("`acceleration` must be of length $NDIMS for a $(NDIMS)D problem"))
+ end
+
if pressure != 0.0
throw(ArgumentError("`pressure` cannot be used together with `acceleration` " *
"and `state_equation` (hydrostatic pressure gradient)"))
end
if state_equation === nothing
- density_fun = pressure -> density
+ if density === nothing
+ throw(ArgumentError("`density` must be specified when using " *
+ "`acceleration` without `state_equation`"))
+ end
else
if density !== nothing
throw(ArgumentError("`density` cannot be used together with `acceleration` " *
"and `state_equation` (hydrostatic pressure gradient)"))
end
- density_fun = pressure -> inverse_state_equation(state_equation, pressure)
end
# Initialize hydrostatic pressure
pressure = Vector{ELTYPE}(undef, n_particles)
- initialize_pressure!(pressure, particle_spacing, acceleration,
- density_fun, n_particles_per_dimension, loop_order)
+ if state_equation === nothing && density isa Function
+ initialize_pressure_with_coordinate_density!(pressure, particle_spacing,
+ acceleration, density,
+ coordinates,
+ n_particles_per_dimension,
+ loop_order)
+ else
+ density_fun = state_equation === nothing ? (pressure -> density) :
+ (pressure -> inverse_state_equation(state_equation, pressure))
+ initialize_pressure!(pressure, particle_spacing, acceleration,
+ density_fun, n_particles_per_dimension, loop_order)
+ end
if state_equation !== nothing
# Weakly compressible case: get density from inverse state equation
@@ -223,15 +239,23 @@ function rectangular_shape_coords(particle_spacing, n_particles_per_dimension,
return coordinates
end
-function initialize_pressure!(pressure, particle_spacing, acceleration, density_fun,
- n_particles_per_dimension, loop_order)
+function acceleration_dimension(acceleration)
if count(a -> abs(a) > eps(), acceleration) > 1
throw(ArgumentError("hydrostatic pressure calculation is not supported with " *
"diagonal acceleration"))
end
+ return findfirst(a -> abs(a) > eps(), acceleration)
+end
+
+function initialize_pressure!(pressure, particle_spacing, acceleration, density_fun,
+ n_particles_per_dimension, loop_order)
# Dimension in which the acceleration is acting
- accel_dim = findfirst(a -> abs(a) > eps(), acceleration)
+ accel_dim = acceleration_dimension(acceleration)
+ if accel_dim === nothing
+ fill!(pressure, zero(eltype(pressure)))
+ return pressure
+ end
# Compute 1D pressure gradient with explicit Euler method
factor = particle_spacing * abs(acceleration[accel_dim])
@@ -265,3 +289,72 @@ function initialize_pressure!(pressure, particle_spacing, acceleration, density_
pressure[particle] = pressure_1d[index_in_accel_dim]
end
end
+
+function particle_indices_by_cartesian_index(n_particles_per_dimension, loop_order)
+ NDIMS = length(n_particles_per_dimension)
+ particle_indices = Array{Int}(undef, n_particles_per_dimension)
+ cartesian_indices = CartesianIndices(n_particles_per_dimension)
+ permutation = loop_permutation(loop_order, Val(NDIMS))
+ permuted_indices = permutedims(cartesian_indices, permutation)
+
+ for particle in eachindex(permuted_indices)
+ particle_indices[permuted_indices[particle]] = particle
+ end
+
+ return particle_indices
+end
+
+# This is needed for `density = coords -> ...`. The pressure-dependent path above can reuse
+# one 1D pressure profile for every column. Coordinate-dependent density may vary between
+# columns, so each gravity-aligned column needs its own explicit Euler integration.
+function initialize_pressure_with_coordinate_density!(pressure, particle_spacing,
+ acceleration, density_fun,
+ coordinates,
+ n_particles_per_dimension,
+ loop_order)
+ # Dimension in which the acceleration is acting
+ accel_dim = acceleration_dimension(acceleration)
+ if accel_dim === nothing
+ fill!(pressure, zero(eltype(pressure)))
+ return pressure
+ end
+
+ NDIMS = length(n_particles_per_dimension)
+ factor = particle_spacing * abs(acceleration[accel_dim])
+ particle_indices = particle_indices_by_cartesian_index(n_particles_per_dimension,
+ loop_order)
+
+ accel_indices = if sign(acceleration[accel_dim]) < 0
+ n_particles_per_dimension[accel_dim]:-1:1
+ else
+ 1:n_particles_per_dimension[accel_dim]
+ end
+ surface_index = first(accel_indices)
+ column_starts = ntuple(dim -> dim == accel_dim ? (surface_index:surface_index) :
+ axes(particle_indices, dim), Val(NDIMS))
+
+ for column_start in CartesianIndices(column_starts)
+ pressure_prev = zero(eltype(pressure))
+ density_prev = zero(eltype(pressure))
+ for (i, accel_index) in enumerate(accel_indices)
+ index = ntuple(dim -> dim == accel_dim ? accel_index : column_start[dim],
+ Val(NDIMS))
+ particle = particle_indices[index...]
+ coords = SVector{NDIMS, eltype(coordinates)}(ntuple(dim -> coordinates[dim,
+ particle],
+ Val(NDIMS)))
+ density = density_fun(coords)
+
+ if i == 1
+ pressure[particle] = 0.5factor * density
+ else
+ pressure[particle] = pressure_prev + factor * density_prev
+ end
+
+ pressure_prev = pressure[particle]
+ density_prev = density
+ end
+ end
+
+ return pressure
+end
diff --git a/src/setups/rectangular_tank.jl b/src/setups/rectangular_tank.jl
index 40abd3a9ca..155dece92f 100644
--- a/src/setups/rectangular_tank.jl
+++ b/src/setups/rectangular_tank.jl
@@ -117,10 +117,27 @@ struct RectangularTank{NDIMS, NDIMSt2, ELTYPE <: Real, F, B}
throw(ArgumentError("`fluid_density` needs to be positive and larger than $(eps())."))
end
+ if any(<(0), fluid_size_)
+ throw(ArgumentError("`fluid_size` dimensions need to be non-negative"))
+ end
+
+ if !(n_layers isa Integer) || n_layers < 1
+ throw(ArgumentError("`n_layers` needs to be a positive integer"))
+ end
+ n_layers = Int(n_layers)
+
+ if spacing_ratio < eps()
+ throw(ArgumentError("`spacing_ratio` needs to be positive and larger than $(eps())."))
+ end
+
if length(tank_size) != NDIMS
throw(ArgumentError("`tank_size` must be of length $NDIMS for a $(NDIMS)D problem"))
end
+ if any(<(0), tank_size_)
+ throw(ArgumentError("`tank_size` dimensions need to be non-negative"))
+ end
+
# Fluid particle data
n_particles_per_dim,
fluid_size_ = fluid_particles_per_dimension(fluid_size_, particle_spacing)
@@ -168,7 +185,7 @@ struct RectangularTank{NDIMS, NDIMSt2, ELTYPE <: Real, F, B}
# Move the tank corner in the negative coordinate directions to the desired position
boundary.coordinates .+= min_coordinates
- if norm(fluid_size) > eps()
+ if all(>(0), n_particles_per_dim)
if state_equation !== nothing
# Use hydrostatic pressure gradient and calculate density from inverse state
# equation, so don't pass fluid density.
@@ -480,14 +497,20 @@ function check_tank_overlap(fluid_size::NTuple{2}, tank_size, particle_spacing,
fluid_size_x, fluid_size_y = fluid_size
if tank_size[1] < fluid_size[1] - 1e-5 * particle_spacing
- n_particles_x -= 1
+ n_particles_x = max(0,
+ floor(Int,
+ (tank_size[1] + 1e-5 * particle_spacing) /
+ particle_spacing))
fluid_size_x = n_particles_x * particle_spacing
@info "The fluid was overlapping.\n New fluid length in x-direction is set to $fluid_size_x."
end
if tank_size[2] < fluid_size[2] - 1e-5 * particle_spacing
- n_particles_y -= 1
+ n_particles_y = max(0,
+ floor(Int,
+ (tank_size[2] + 1e-5 * particle_spacing) /
+ particle_spacing))
fluid_size_y = n_particles_y * particle_spacing
@info "The fluid was overlapping.\n New fluid length in y-direction is set to $fluid_size_y."
@@ -502,21 +525,30 @@ function check_tank_overlap(fluid_size::NTuple{3}, tank_size, particle_spacing,
fluid_size_x, fluid_size_y, fluid_size_z = fluid_size
if tank_size[1] < fluid_size[1] - 1e-5 * particle_spacing
- n_particles_x -= 1
+ n_particles_x = max(0,
+ floor(Int,
+ (tank_size[1] + 1e-5 * particle_spacing) /
+ particle_spacing))
fluid_size_x = n_particles_x * particle_spacing
@info "The fluid was overlapping.\n New fluid length in x-direction is set to $fluid_size_x."
end
if tank_size[2] < fluid_size[2] - 1e-5 * particle_spacing
- n_particles_y -= 1
+ n_particles_y = max(0,
+ floor(Int,
+ (tank_size[2] + 1e-5 * particle_spacing) /
+ particle_spacing))
fluid_size_y = n_particles_y * particle_spacing
@info "The fluid was overlapping.\n New fluid length in y-direction is set to $fluid_size_y."
end
if tank_size[3] < fluid_size[3] - 1e-5 * particle_spacing
- n_particles_z -= 1
+ n_particles_z = max(0,
+ floor(Int,
+ (tank_size[3] + 1e-5 * particle_spacing) /
+ particle_spacing))
fluid_size_z = n_particles_z * particle_spacing
@info "The fluid was overlapping.\n New fluid length in z-direction is set to $fluid_size_z."
diff --git a/src/setups/sphere_shape.jl b/src/setups/sphere_shape.jl
index fea261e127..b547897888 100644
--- a/src/setups/sphere_shape.jl
+++ b/src/setups/sphere_shape.jl
@@ -36,7 +36,8 @@ coordinate directions as `cutout_min` and `cutout_max`.
- `cutout_min`: Corner in negative coordinate directions of a cuboid that is to be
cut out of the sphere.
- `cutout_max`: Corner in positive coordinate directions of a cuboid that is to be
- cut out of the sphere.
+ cut out of the sphere. If the cutout has zero volume, no particles
+ are removed.
- `place_on_shell = false`: If `place_on_shell=true`, particles will be placed on the shell
of the shape. For example, the [`TotalLagrangianSPHSystem`](@ref)
requires particles to be placed on the shell of the shape and
@@ -114,9 +115,22 @@ function SphereShape(particle_spacing, radius, center_position, density;
cutout_min_ = collect(cutout_min)
cutout_max_ = collect(cutout_max)
+ # A zero-volume cutout means no cutout. This keeps the 2D zero default valid for
+ # 3D shapes while still validating dimensionality once a real cutout is requested.
+ has_cutout = length(cutout_min_) != length(cutout_max_) ||
+ norm(cutout_max_ - cutout_min_) > eps()
+
+ if has_cutout && (length(cutout_min_) != NDIMS || length(cutout_max_) != NDIMS)
+ throw(ArgumentError("`cutout_min` and `cutout_max` must be of length $NDIMS " *
+ "for a $(NDIMS)D problem"))
+ end
+
+ if has_cutout && any(cutout_min_ .> cutout_max_)
+ throw(ArgumentError("`cutout_min` must be smaller than or equal to `cutout_max`"))
+ end
+
# Remove particles in cutout
# TODO This should consider the particle radius as well
- has_cutout = norm(cutout_max_ - cutout_min_) > eps()
function in_cutout(particle)
return has_cutout &&
all(cutout_min_ .<= view(coordinates, :, particle) .<= cutout_max_)
diff --git a/src/visualization/makie.jl b/src/visualization/makie.jl
new file mode 100644
index 0000000000..430d30343e
--- /dev/null
+++ b/src/visualization/makie.jl
@@ -0,0 +1,14 @@
+"""
+ trixi2makie(scene, solution; frame=lastindex(solution.u), kwargs...)
+ trixi2makie(scene, v_ode, u_ode, semi; kwargs...)
+
+Plot a TrixiParticles solution in a Makie `Scene` or `LScene` as physically sized particle
+spheres. The first method plots one frame of an ODE solution, while the second accepts the
+position and state arrays explicitly. This particle-level view is intended for diagnostics; fluid
+surface reconstruction requires additional post-processing.
+
+This function is available after loading Makie or one of its backends, such as CairoMakie,
+GLMakie, or RayMakie. See the visualization documentation for the supported keyword
+arguments.
+"""
+function trixi2makie end
diff --git a/test/Project.toml b/test/Project.toml
index 8ceb190ca2..698451f784 100644
--- a/test/Project.toml
+++ b/test/Project.toml
@@ -14,6 +14,7 @@ Polyester = "f517fe37-dbe3-4b94-8317-1923a5111588"
Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7"
QuadGK = "1fd47b50-473d-5c70-9696-f719f8f3bcdc"
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
+Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
TrixiTest = "0a316866-cbd0-4425-8bcb-08103b2c1f26"
@@ -33,4 +34,5 @@ Polyester = "0.7"
Printf = "1"
QuadGK = "2"
Random = "1"
+Statistics = "1"
TrixiTest = "0.2"
diff --git a/test/examples/examples_fluid.jl b/test/examples/examples_fluid.jl
index f7fc1890d6..24c8de5345 100644
--- a/test/examples/examples_fluid.jl
+++ b/test/examples/examples_fluid.jl
@@ -150,6 +150,16 @@
boundary_layers=1, spacing_ratio=3, sol=nothing, semi=nothing,
ode=nothing)
+ # The Akinci variant has a compact-support radius of 0.015 m. Migrate its old 2D
+ # pairwise strengths once, then keep the coefficients fixed under resolution changes.
+ akinci_reference_support_radius = 0.015
+ akinci_cohesion_migration = 627 / (790 * akinci_reference_support_radius)
+ akinci_adhesion_migration = 42 / (65 * akinci_reference_support_radius)
+ akinci_surface_tension_coefficient = 0.025 * akinci_cohesion_migration
+ akinci_surface_tension = SurfaceTensionAkinci(;
+ surface_tension_coefficient=akinci_surface_tension_coefficient)
+ akinci_adhesion_coefficient = 0.05 * akinci_adhesion_migration
+
dam_break_tests = Dict(
"default" => (),
"with SummationDensity" => (fluid_density_calculator=SummationDensity(),
@@ -162,7 +172,7 @@
boundary_particle_spacing,
tank.boundary.mass),
boundary_layers=1, spacing_ratio=3),
- "with SurfaceTensionAkinci" => (surface_tension=SurfaceTensionAkinci(surface_tension_coefficient=0.025),
+ "with SurfaceTensionAkinci" => (surface_tension=akinci_surface_tension,
fluid_particle_spacing=0.5 *
fluid_particle_spacing,
smoothing_kernel=SchoenbergCubicSplineKernel{2}(),
@@ -170,7 +180,7 @@
fluid_particle_spacing,
correction=AkinciFreeSurfaceCorrection(fluid_density),
density_diffusion=nothing,
- adhesion_coefficient=0.05,
+ adhesion_coefficient=akinci_adhesion_coefficient,
sound_speed=100.0,
reference_particle_spacing=fluid_particle_spacing)
)
@@ -570,6 +580,106 @@
@test count_rhs_allocations(sol) == 0
end
+ @trixi_testset "fluid/akinci_cube_to_sphere_3d.jl" begin
+ @trixi_test_nowarn trixi_include(@__MODULE__,
+ joinpath(examples_dir(), "fluid",
+ "akinci_cube_to_sphere_3d.jl"),
+ particle_spacing=0.002, tspan=(0.0, 0.001),
+ fluid_clip_negative_pressure=false,
+ saving_callback=nothing)
+ @test sol.retcode == ReturnCode.Success
+ @test fluid_system.surface_tension isa SurfaceTensionAkinci
+ @test !isnothing(fluid_system.source_terms)
+ @test !TrixiParticles.clip_negative_pressure(fluid_system.state_equation)
+ @test TrixiParticles.clip_negative_pressure(boundary_system.boundary_model)
+ @test count_rhs_allocations(sol) == 0
+ end
+
+ @trixi_testset "fluid/akinci_water_crown_3d.jl" begin
+ @trixi_test_nowarn trixi_include(@__MODULE__,
+ joinpath(examples_dir(), "fluid",
+ "akinci_water_crown_3d.jl"),
+ particle_spacing=0.01, tspan=(0.0, 0.001),
+ saving_callback=nothing)
+ @test sol.retcode == ReturnCode.Success
+ @test isapprox(sum(drop.mass) / fluid_density, drop_volume; rtol=0.1)
+ @test fluid_system.density_calculator isa SummationDensity
+ @test boundary_system.adhesion_coefficient == 1.0
+ @test boundary_system.boundary_model.viscosity === fluid_system.viscosity
+ @test count_rhs_allocations(sol) == 0
+ end
+
+ @trixi_testset "fluid/akinci_droplet_on_plate_3d.jl" begin
+ @trixi_test_nowarn trixi_include(@__MODULE__,
+ joinpath(examples_dir(), "fluid",
+ "akinci_droplet_on_plate_3d.jl"),
+ particle_spacing=0.025, tspan=(0.0, 0.001),
+ saving_callback=nothing)
+ @test sol.retcode == ReturnCode.Success
+ @test boundary_system.adhesion_coefficient == 0.6
+ @test count_rhs_allocations(sol) == 0
+ end
+
+ @trixi_testset "fluid/akinci_stream_over_sphere_3d.jl" begin
+ @trixi_test_nowarn trixi_include(@__MODULE__,
+ joinpath(examples_dir(), "fluid",
+ "akinci_stream_over_sphere_3d.jl"),
+ particle_spacing=0.015, tspan=(0.0, 0.001),
+ saving_callback=nothing)
+ @test sol.retcode == ReturnCode.Success
+ @test open_boundary isa OpenBoundarySystem
+ @test semi.update_callback_used[]
+ @test count_rhs_allocations(sol) == 0
+ end
+
+ @trixi_testset "fluid/akinci_wetting_3d.jl" begin
+ wetting_cases = Dict("no_wetting" => (1.0, 0.0),
+ "moderate_wetting" => (1.0, 0.1),
+ "intermediate_wetting" => (1.0, 0.25),
+ "perfect_wetting" => (0.001, 0.0))
+
+ for (wetting_case, coefficients) in wetting_cases
+ @testset "$wetting_case" begin
+ @trixi_test_nowarn trixi_include(@__MODULE__,
+ joinpath(examples_dir(), "fluid",
+ "akinci_wetting_3d.jl"),
+ particle_spacing=0.003,
+ tspan=(0.0, 0.001),
+ wetting_case=wetting_case,
+ saving_callback=nothing)
+ @test sol.retcode == ReturnCode.Success
+ @test surface_tension_coefficient == coefficients[1]
+ @test adhesion_coefficient == coefficients[2]
+ @test count_rhs_allocations(sol) == 0
+ end
+ end
+ end
+
+ @trixi_testset "fluid/akinci_droplet_splitting_3d.jl" begin
+ @trixi_test_nowarn trixi_include(@__MODULE__,
+ joinpath(examples_dir(), "fluid",
+ "akinci_droplet_splitting_3d.jl"),
+ particle_spacing=0.02, tspan=(0.0, 0.001),
+ saving_callback=nothing)
+ @test sol.retcode == ReturnCode.Success
+ @test tank_boundary_system.adhesion_coefficient == 2.0
+ @test blade_boundary_system.adhesion_coefficient == 0.0
+ @test count_rhs_allocations(sol) == 0
+ end
+
+ @trixi_testset "fluid/akinci_rolling_droplet_3d.jl" begin
+ @trixi_test_nowarn trixi_include(@__MODULE__,
+ joinpath(examples_dir(), "fluid",
+ "akinci_rolling_droplet_3d.jl"),
+ particle_spacing=0.02, tspan=(0.0, 0.001),
+ saving_callback=nothing)
+ @test sol.retcode == ReturnCode.Success
+ @test adhesive_figure_system isa RigidBodySystem
+ @test adhesive_figure_system.adhesion_coefficient == 1.0
+ @test nonadhesive_figure_system.adhesion_coefficient == 0.0
+ @test count_rhs_allocations(sol) == 0
+ end
+
@trixi_testset "fluid/sphere_surface_tension_2d.jl" begin
@trixi_test_nowarn trixi_include(@__MODULE__,
joinpath(examples_dir(), "fluid",
@@ -597,8 +707,11 @@
end
@trixi_testset "fluid/falling_water_spheres_2d.jl" begin
+ akinci_surface_tension_coefficient = 0.05 * 627 / (790 * 0.01)
+ akinci_surface_tension = SurfaceTensionAkinci(;
+ surface_tension_coefficient=akinci_surface_tension_coefficient)
surface_tension_models = Dict(
- "SurfaceTensionAkinci" => SurfaceTensionAkinci(surface_tension_coefficient=0.05),
+ "SurfaceTensionAkinci" => akinci_surface_tension,
"SurfaceTensionMorris" => SurfaceTensionMorris(surface_tension_coefficient=0.05),
"SurfaceTensionMomentumMorris" => SurfaceTensionMomentumMorris(surface_tension_coefficient=0.05),
"SurfaceTensionNone" => nothing # For cases without surface tension
diff --git a/test/general/custom_quantities.jl b/test/general/custom_quantities.jl
index 9181269a6b..b5d9f63830 100644
--- a/test/general/custom_quantities.jl
+++ b/test/general/custom_quantities.jl
@@ -121,4 +121,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
diff --git a/test/general/neighborhood_search.jl b/test/general/neighborhood_search.jl
index 0a8ef7119f..cf838f0663 100644
--- a/test/general/neighborhood_search.jl
+++ b/test/general/neighborhood_search.jl
@@ -17,7 +17,8 @@
cell_list = TrixiParticles.FullGridCellList(; min_corner=(-1.0, -1.0),
max_corner=(1.0, 1.0),
search_radius=0.1)
- dummy_nhs = (; cell_size=0.1, periodic_box=nothing, cell_list)
+ dummy_nhs = (; cell_size=0.1, periodic_box=nothing,
+ n_cells=size(cell_list.linear_indices), cell_list)
semi = DummySemidiscretization()
# All particles should remain active
@@ -39,7 +40,8 @@
cell_list = TrixiParticles.FullGridCellList(; min_corner=(-1.0, -1.0),
max_corner=(1.0, 1.0),
search_radius=0.1)
- dummy_nhs = (; cell_size=0.1, periodic_box=nothing, cell_list)
+ dummy_nhs = (; cell_size=0.1, periodic_box=nothing,
+ n_cells=size(cell_list.linear_indices), cell_list)
semi = DummySemidiscretization()
TrixiParticles.deactivate_out_of_bounds_particles!(system, buffer, dummy_nhs,
@@ -68,7 +70,8 @@
cell_list = TrixiParticles.FullGridCellList(; min_corner=(-1.0, -1.0),
max_corner=(1.0, 1.0),
search_radius=0.1)
- dummy_nhs = (; cell_size=0.1, periodic_box=nothing, cell_list)
+ dummy_nhs = (; cell_size=0.1, periodic_box=nothing,
+ n_cells=size(cell_list.linear_indices), cell_list)
semi = DummySemidiscretization()
TrixiParticles.deactivate_out_of_bounds_particles!(system, buffer, dummy_nhs,
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
diff --git a/test/preprocessing/geometries/geometries.jl b/test/preprocessing/geometries/geometries.jl
index 4f428d266c..f64d415dd7 100644
--- a/test/preprocessing/geometries/geometries.jl
+++ b/test/preprocessing/geometries/geometries.jl
@@ -54,12 +54,98 @@
end
end
+ @testset verbose=true "Open Polygon Closure" begin
+ open_square = [1.0 2.0 2.0 1.0;
+ 1.0 1.0 2.0 2.0]
+
+ geometry = TrixiParticles.Polygon(open_square)
+
+ @test TrixiParticles.nfaces(geometry) == 4
+ @test first(geometry.vertices) == last(geometry.vertices)
+ @test TrixiParticles.volume(geometry) โ 1.0
+
+ mktempdir() do dir
+ filename = joinpath(dir, "open_square.asc")
+ open(filename, "w") do io
+ println(io, "# ASCII")
+ for vertex in eachcol(open_square)
+ println(io, vertex[1], " ", vertex[2])
+ end
+ end
+
+ geometry_from_file = load_geometry(filename)
+
+ @test TrixiParticles.nfaces(geometry_from_file) == 4
+ @test first(geometry_from_file.vertices) == last(geometry_from_file.vertices)
+ @test TrixiParticles.volume(geometry_from_file) โ 1.0
+ end
+ end
+
+ @testset verbose=true "Closed Geometry Detection" begin
+ open_square = [1.0 2.0 2.0 1.0;
+ 1.0 1.0 2.0 2.0]
+
+ closed_polygon = TrixiParticles.Polygon(open_square)
+ open_polygon = TrixiParticles.Polygon(open_square; close_curve=false)
+ partial_polygon = delete_faces(closed_polygon, 2)
+ rebuilt_closed_polygon = delete_faces(closed_polygon, Int[])
+
+ @test TrixiParticles.is_closed_geometry(closed_polygon)
+ @test TrixiParticles.is_closed_geometry(rebuilt_closed_polygon)
+ @test !TrixiParticles.is_closed_geometry(open_polygon)
+ @test !TrixiParticles.is_closed_geometry(partial_polygon)
+
+ shape = RectangularShape(0.5, (2, 2), (1.0, 1.0), density=1.0)
+ @test_throws ArgumentError intersect(shape, open_polygon)
+ @test_throws ArgumentError setdiff(shape, open_polygon)
+
+ file = pkgdir(TrixiParticles, "test", "preprocessing", "data")
+ planar_geometry = load_geometry(joinpath(file, "inflow_geometry.stl"))
+ closed_mesh = extrude_geometry(planar_geometry, 0.8)
+ open_mesh = extrude_geometry(planar_geometry, 0.8; omit_top_face=true)
+
+ @test !TrixiParticles.is_closed_geometry(planar_geometry)
+ @test TrixiParticles.is_closed_geometry(closed_mesh)
+ @test !TrixiParticles.is_closed_geometry(open_mesh)
+ end
+
+ @testset verbose=true "`delete_faces` Rebuilds Derived Data" begin
+ triangle = [0.0 1.0 0.5 0.0;
+ 0.0 0.0 0.7 0.0]
+
+ edge_only = TrixiParticles.delete_faces(TrixiParticles.Polygon(triangle), [1, 2])
+
+ @test TrixiParticles.nfaces(edge_only) == 1
+ @test length(edge_only.vertices) == 2
+ @test length(edge_only.vertex_normals) == 1
+ @test edge_only.min_corner == min.(edge_only.edge_vertices[1]...)
+ @test edge_only.max_corner == max.(edge_only.edge_vertices[1]...)
+ @test edge_only.vertex_normals[1] == (edge_only.edge_normals[1],
+ edge_only.edge_normals[1])
+
+ A = SVector(0.0, 0.0, 0.0)
+ B = SVector(1.0, 0.0, 0.0)
+ C = SVector(0.0, 1.0, 0.0)
+ D = SVector(1.0, 1.0, 0.0)
+ face_vertices = [(A, B, C), (B, D, C)]
+ face_normals = [SVector(0.0, 0.0, 1.0), SVector(0.0, 0.0, 1.0)]
+ mesh = TrixiParticles.TriangleMesh(face_vertices, face_normals, [A, B, C, D])
+
+ mesh = TrixiParticles.delete_faces(mesh, 1)
+
+ @test TrixiParticles.nfaces(mesh) == 1
+ @test length(mesh.vertices) == 3
+ @test length(mesh.edge_normals) == 3
+ @test mesh.face_vertices == [face_vertices[2]]
+ end
+
@testset verbose=true "Real World Data" begin
data_dir = pkgdir(TrixiParticles, "examples", "preprocessing", "data")
validation_dir = pkgdir(TrixiParticles, "test", "preprocessing", "data")
@testset verbose=true "2D" begin
files = ["hexagon", "circle", "inverted_open_curve"]
+ close_curves = [true, true, false]
n_edges = [6, 63, 240]
volumes = [2.5980750000000006, 3.1363805763454, 2.6153740535469048]
@@ -74,7 +160,8 @@
points = vcat((data.var"Points:0")', (data.var"Points:1")')
- geometry = load_geometry(joinpath(data_dir, files[i] * ".asc"))
+ geometry = load_geometry(joinpath(data_dir, files[i] * ".asc");
+ close_curve=close_curves[i])
@test TrixiParticles.nfaces(geometry) == n_edges[i]
@@ -157,6 +244,19 @@
end
end
+ @testset verbose=true "Degenerate Triangle Normals" begin
+ vertex = SVector(0.0, 0.0, 0.0)
+ normal = SVector(0.0, 0.0, 0.0)
+
+ geometry = TrixiParticles.TriangleMesh([(vertex, vertex, vertex)],
+ [normal], [vertex, vertex, vertex])
+
+ @test all(iszero, geometry.vertex_normals)
+ @test all(iszero, geometry.edge_normals)
+ @test all(all(isfinite, normal) for normal in geometry.vertex_normals)
+ @test all(all(isfinite, normal) for normal in geometry.edge_normals)
+ end
+
@testset verbose=true "Union" begin
# Build a single geometry by uniting multiple STL patches (cuboid.stl contains separate solids).
# The union should produce a closed volume.
@@ -202,47 +302,22 @@
omit_bottom_face=true)
winding_number_factor = 0.2
- @testset verbose=true "Omit Top Face" begin
- expected_min_corner = [-0.036399998962879196; 0.24624998748302457; -0.5233639197487431;;]
- expected_max_corner = [0.38360000103712083; 1.1462499874830245; -0.07336391974874301;;]
-
- ic_1 = ComplexShape(geometry_extruded_1; particle_spacing=0.03, density=1.0,
- point_in_geometry_algorithm=WindingNumberJacobson(;
- geometry=geometry_extruded_1,
- winding_number_factor))
-
- @test nparticles(ic_1) == 2994
- @test isapprox(maximum(ic_1.coordinates, dims=2), expected_max_corner)
- @test isapprox(minimum(ic_1.coordinates, dims=2), expected_min_corner)
- end
- @testset verbose=true "Omit Bottom Face" begin
- expected_min_corner = [-0.0663999989628792; 0.1562499874830246; -0.49336391974874305;;]
- expected_max_corner = [0.38360000103712083; 1.0562499874830245; -0.07336391974874301;;]
-
- ic_2 = ComplexShape(geometry_extruded_2; particle_spacing=0.03, density=1.0,
- point_in_geometry_algorithm=WindingNumberJacobson(;
- geometry=geometry_extruded_2,
- winding_number_factor))
-
- @test nparticles(ic_2) == 2988
- @test isapprox(maximum(ic_2.coordinates, dims=2), expected_max_corner)
- @test isapprox(minimum(ic_2.coordinates, dims=2), expected_min_corner)
- end
-
- @testset verbose=true "Omit Both" begin
- expected_min_corner = [-0.0663999989628792; 0.1562499874830246; -0.5233639197487431;;]
- expected_max_corner = [0.38360000103712083; 1.1462499874830245; -0.07336391974874301;;]
-
- ic_3 = ComplexShape(geometry_extruded_3; particle_spacing=0.03, density=1.0,
- point_in_geometry_algorithm=WindingNumberJacobson(;
- geometry=geometry_extruded_3,
- winding_number_factor))
-
- @test nparticles(ic_3) == 3258
- @test isapprox(maximum(ic_3.coordinates, dims=2), expected_max_corner)
- @test isapprox(minimum(ic_3.coordinates, dims=2), expected_min_corner)
- end
+ @test_throws ArgumentError ComplexShape(geometry_extruded_1;
+ particle_spacing=0.03, density=1.0,
+ point_in_geometry_algorithm=WindingNumberJacobson(;
+ geometry=geometry_extruded_1,
+ winding_number_factor))
+ @test_throws ArgumentError ComplexShape(geometry_extruded_2;
+ particle_spacing=0.03, density=1.0,
+ point_in_geometry_algorithm=WindingNumberJacobson(;
+ geometry=geometry_extruded_2,
+ winding_number_factor))
+ @test_throws ArgumentError ComplexShape(geometry_extruded_3;
+ particle_spacing=0.03, density=1.0,
+ point_in_geometry_algorithm=WindingNumberJacobson(;
+ geometry=geometry_extruded_3,
+ winding_number_factor))
end
end
diff --git a/test/preprocessing/packing/nhs_faces.jl b/test/preprocessing/packing/nhs_faces.jl
index 292522a91b..a9b038f2ed 100644
--- a/test/preprocessing/packing/nhs_faces.jl
+++ b/test/preprocessing/packing/nhs_faces.jl
@@ -4,7 +4,7 @@
0.0 0.0 0.7 0.0]
# Only use the third edge of the triangle, i.e. the edge from [0.1, 0.0] to [0.0, 0.0]
- edge_aligned = deleteat!(TrixiParticles.Polygon(triangle), [1, 2])
+ edge_aligned = TrixiParticles.delete_faces(TrixiParticles.Polygon(triangle), [1, 2])
edge_id = 1 # Only one edge in `Polygon`
cell_sizes = [1.0 + sqrt(eps()), 0.1]
@@ -27,7 +27,8 @@
end
# Only use the first edge of the triangle, i.e. the edge from [0.0, 0.0] to [0.5, 0.7]
- edge_arbitrary = deleteat!(TrixiParticles.Polygon(triangle), [2, 3])
+ edge_arbitrary = TrixiParticles.delete_faces(TrixiParticles.Polygon(triangle),
+ [2, 3])
edge_id = 1 # Only one edge in `Polygon`
expected_ncells_bbox = [(1, 1), (6, 7)]
diff --git a/test/preprocessing/packing/signed_distance.jl b/test/preprocessing/packing/signed_distance.jl
index cb02753b26..d49ae57fcb 100644
--- a/test/preprocessing/packing/signed_distance.jl
+++ b/test/preprocessing/packing/signed_distance.jl
@@ -44,6 +44,16 @@
@test repr("text/plain", signed_distance_field) == show_box
end
+ @testset verbose=true "Open Geometry Validation" begin
+ open_square = [0.0 1.0 1.0 0.0;
+ 0.0 0.0 1.0 1.0]
+ geometry = TrixiParticles.Polygon(open_square; close_curve=false)
+
+ @test SignedDistanceField(geometry, 0.1) isa SignedDistanceField
+ @test_throws ArgumentError SignedDistanceField(geometry, 0.1;
+ use_for_boundary_packing=true)
+ end
+
@testset verbose=true "Real World Data" begin
data_dir = pkgdir(TrixiParticles, "examples", "preprocessing", "data")
validation_dir = pkgdir(TrixiParticles, "test", "preprocessing", "data")
@@ -135,4 +145,15 @@
end
end
end
+
+ @testset verbose=true "Point Matrix Input" begin
+ data_dir = pkgdir(TrixiParticles, "examples", "preprocessing", "data")
+ geometry = load_geometry(joinpath(data_dir, "hexagon.asc"))
+
+ point = first(geometry.vertices)
+ signed_distance_field = SignedDistanceField(geometry, 0.1; points=hcat(point))
+
+ @test signed_distance_field.positions == [point]
+ @test signed_distance_field.distances == [0.0]
+ end
end
diff --git a/test/preprocessing/point_in_poly/winding_number_jacobson.jl b/test/preprocessing/point_in_poly/winding_number_jacobson.jl
index ee2119a62d..3622b48f52 100644
--- a/test/preprocessing/point_in_poly/winding_number_jacobson.jl
+++ b/test/preprocessing/point_in_poly/winding_number_jacobson.jl
@@ -3,6 +3,11 @@
data_dir = pkgdir(TrixiParticles, "examples", "preprocessing", "data")
geometry = load_geometry(joinpath(data_dir, "circle.asc"))
+ winding = WindingNumberJacobson()
+
+ show_compact = "WindingNumberJacobson{NaiveWinding}()"
+ @test repr(winding) == show_compact
+
winding = WindingNumberJacobson(; hierarchical_winding=false)
show_compact = "WindingNumberJacobson{NaiveWinding}()"
@@ -30,4 +35,33 @@
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ"""
@test repr("text/plain", winding) == show_box
end
+
+ @testset verbose=true "Point Matrix Input" begin
+ geometry = TrixiParticles.Polygon([0.0 1.0 1.0 0.0 0.0;
+ 0.0 0.0 1.0 1.0 0.0])
+ point_storage = [0.5 0.0 1.5;
+ 0.5 0.0 1.5]
+ points = @view point_storage[:, 1:2:3]
+
+ expected = Bool[true, false]
+
+ inpoly_jacobson, _ = WindingNumberJacobson()(geometry, points)
+ inpoly_hormann, _ = WindingNumberHormann()(geometry, points)
+
+ @test inpoly_jacobson == expected
+ @test inpoly_hormann == expected
+ end
+
+ @testset verbose=true "Open Geometry Validation" begin
+ open_square = [0.0 1.0 1.0 0.0;
+ 0.0 0.0 1.0 1.0]
+ geometry = TrixiParticles.Polygon(open_square; close_curve=false)
+ points = [SVector(0.5, 0.5)]
+
+ jacobson = WindingNumberJacobson(; hierarchical_winding=false)
+ hormann = WindingNumberHormann()
+
+ @test jacobson(geometry, points)[1] isa Vector{Bool}
+ @test hormann(geometry, points)[1] isa Vector{Bool}
+ end
end
diff --git a/test/schemes/boundary/dummy_particles/dummy_particles.jl b/test/schemes/boundary/dummy_particles/dummy_particles.jl
index 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.
diff --git a/test/schemes/boundary/open_boundary/boundary_zone.jl b/test/schemes/boundary/open_boundary/boundary_zone.jl
index 8542fd554d..869f565f87 100644
--- a/test/schemes/boundary/open_boundary/boundary_zone.jl
+++ b/test/schemes/boundary/open_boundary/boundary_zone.jl
@@ -256,6 +256,24 @@
end
end
+ @testset verbose=true "Boundary Zone 3D Float32 Tolerance" begin
+ edge1 = Float32[0.6208666, 0.6295315, 0.46713477]
+ edge2 = Float32[-0.48528308, 0.7766439, -0.4016525]
+ boundary_face = (zeros(Float32, 3), edge1, edge2)
+ face_normal = normalize(cross(edge1, edge2))
+
+ # This is orthogonal to Float32 precision, but not to a Float64-based tolerance.
+ @test abs(dot(edge1, edge2)) > sqrt(eps()) * norm(edge1) * norm(edge2)
+ @test abs(dot(edge1, edge2)) <= sqrt(eps(Float32)) * norm(edge1) * norm(edge2)
+
+ boundary_zone = BoundaryZone(; boundary_face, particle_spacing=0.5f0,
+ face_normal, density=1.0f0,
+ open_boundary_layers=1, boundary_type=InFlow(),
+ sample_points=nothing)
+
+ @test size(boundary_zone.initial_condition.coordinates, 2) > 0
+ end
+
@testset verbose=true "Particle In Boundary Zone 2D" begin
face_vertices = [[-0.2, -0.5], [0.3, 0.6]]
face_size = face_vertices[2] - face_vertices[1]
@@ -367,6 +385,26 @@
open_boundary_layers=2,
boundary_type=OutFlow())
+ non_orthogonal_face = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]]
+ flow_direction = [0.0, 0.0, 1.0]
+
+ error_str = "the vectors `AB` and `AC` must be orthogonal"
+
+ @test_throws ArgumentError(error_str) BoundaryZone(;
+ boundary_face=non_orthogonal_face,
+ particle_spacing=0.1,
+ face_normal=flow_direction,
+ density=1.0,
+ open_boundary_layers=2,
+ boundary_type=InFlow())
+ @test_throws ArgumentError(error_str) BoundaryZone(;
+ boundary_face=non_orthogonal_face,
+ particle_spacing=0.1,
+ face_normal=(-flow_direction),
+ density=1.0,
+ open_boundary_layers=2,
+ boundary_type=OutFlow())
+
rectangular_face = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
flow_direction = [0.0, 1.0, 0.0]
diff --git a/test/schemes/boundary/open_boundary/characteristic_variables.jl b/test/schemes/boundary/open_boundary/characteristic_variables.jl
index 9d2e9fa03e..1282917f0c 100644
--- a/test/schemes/boundary/open_boundary/characteristic_variables.jl
+++ b/test/schemes/boundary/open_boundary/characteristic_variables.jl
@@ -18,6 +18,72 @@
# Add small offset to avoid "ArgumentError: density must be positive and larger than `eps()`"
reference_density = (pos, t) -> 1000.0 * (t + sqrt(eps()))
+ @testset "Reject bidirectional flow" begin
+ initial_condition = rectangular_patch(particle_spacing, (2, 2))
+ fluid_system = WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel,
+ smoothing_length,
+ density_calculator=ContinuityDensity(),
+ state_equation=nothing)
+
+ bidirectional = BoundaryZone(; boundary_face=([0.0, 0.0], [0.0, 1.0]),
+ face_normal=[1.0, 0.0], open_boundary_layers,
+ density, particle_spacing)
+ boundary_system = OpenBoundarySystem(bidirectional; fluid_system, buffer_size=0,
+ boundary_model=BoundaryModelCharacteristicsLastiwka())
+
+ error_str = "`BoundaryModelCharacteristicsLastiwka` needs a directed boundary zone. " *
+ "Please specify `InFlow()` or `OutFlow()` instead of `BidirectionalFlow()`."
+ @test_throws ArgumentError(error_str) Semidiscretization(fluid_system,
+ boundary_system)
+ end
+
+ @testset "Fallback is zone-local" begin
+ face_vertices = ([0.0, 0.0], [0.0, 0.5])
+ face_vertices_far = ([10.0, 0.0], [10.0, 0.5])
+ flow_direction = SVector(1.0, 0.0)
+
+ inflow = BoundaryZone(; boundary_face=face_vertices, face_normal=flow_direction,
+ open_boundary_layers, boundary_type=InFlow(),
+ reference_velocity, reference_pressure, reference_density,
+ density, particle_spacing)
+ inflow_far = BoundaryZone(; boundary_face=face_vertices_far,
+ face_normal=flow_direction,
+ open_boundary_layers, boundary_type=InFlow(),
+ reference_velocity, reference_pressure, reference_density,
+ density, particle_spacing)
+ fluid = extrude_geometry(face_vertices; particle_spacing, n_extrude=4,
+ density, pressure, direction=flow_direction)
+ fluid_system = EntropicallyDampedSPHSystem(fluid; smoothing_kernel,
+ smoothing_length,
+ sound_speed,
+ buffer_size=0,
+ density_calculator=ContinuityDensity())
+ boundary_system = OpenBoundarySystem(inflow, inflow_far;
+ fluid_system, buffer_size=0,
+ boundary_model=BoundaryModelCharacteristicsLastiwka())
+ semi = Semidiscretization(fluid_system, boundary_system)
+ ode = semidiscretize(semi, (0.0, 5.0))
+
+ v0_ode, u0_ode = ode.u0.x
+ v = TrixiParticles.wrap_v(v0_ode, boundary_system, semi)
+ u = TrixiParticles.wrap_u(u0_ode, boundary_system, semi)
+
+ TrixiParticles.evaluate_characteristics!(boundary_system,
+ v, u, v0_ode, u0_ode, semi, 2.0)
+ TrixiParticles.evaluate_characteristics!(boundary_system,
+ v, u, v0_ode, u0_ode, semi, 3.0)
+
+ zone_1_particles = findall(==(1), boundary_system.boundary_zone_indices)
+ zone_2_particles = findall(==(2), boundary_system.boundary_zone_indices)
+
+ @test any(!isapprox(characteristic, 0.0)
+ for characteristic in boundary_system.cache.characteristics[:,
+ zone_1_particles])
+ @test all(isapprox(characteristic, 0.0)
+ for characteristic in boundary_system.cache.characteristics[:,
+ zone_2_particles])
+ end
+
# Face vertices of open boundary
face_vertices_1 = [[0.0, 0.0], [0.5, -0.5], [1.0, 0.5]]
face_vertices_2 = [[0.0, 1.0], [0.2, 2.0], [2.3, 0.5]]
@@ -144,7 +210,7 @@
initial_condition = rectangular_patch(particle_spacing, ntuple(_ -> 2, n_dims))
boundary_face = n_dims == 2 ? ([0.0, 0.0], [0.0, 1.0]) :
- ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 1.0, 1.0])
+ ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0])
face_normal = n_dims == 2 ? [1.0, 0.0] : [1.0, 0.0, 0.0]
inflow = BoundaryZone(; boundary_face, boundary_type=InFlow(), face_normal,
open_boundary_layers=10, density=1.0, particle_spacing)
diff --git a/test/schemes/boundary/open_boundary/dynamical_pressure.jl b/test/schemes/boundary/open_boundary/dynamical_pressure.jl
index bd325eb690..dbe80f7406 100644
--- a/test/schemes/boundary/open_boundary/dynamical_pressure.jl
+++ b/test/schemes/boundary/open_boundary/dynamical_pressure.jl
@@ -123,7 +123,7 @@
initial_condition = rectangular_patch(particle_spacing, ntuple(_ -> 2, n_dims))
boundary_face = n_dims == 2 ? ([0.0, 0.0], [0.0, 1.0]) :
- ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 1.0, 1.0])
+ ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0])
face_normal = n_dims == 2 ? [1.0, 0.0] : [1.0, 0.0, 0.0]
inflow = BoundaryZone(; boundary_face, boundary_type=InFlow(), face_normal,
open_boundary_layers=10, density=1.0, particle_spacing)
diff --git a/test/schemes/boundary/open_boundary/mirroring.jl b/test/schemes/boundary/open_boundary/mirroring.jl
index d9e41ee0a2..5bd9a5e5d7 100644
--- a/test/schemes/boundary/open_boundary/mirroring.jl
+++ b/test/schemes/boundary/open_boundary/mirroring.jl
@@ -548,7 +548,7 @@
initial_condition = rectangular_patch(particle_spacing, ntuple(_ -> 2, n_dims))
boundary_face = n_dims == 2 ? ([0.0, 0.0], [0.0, 1.0]) :
- ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 1.0, 1.0])
+ ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0])
face_normal = n_dims == 2 ? [1.0, 0.0] : [1.0, 0.0, 0.0]
inflow = BoundaryZone(; boundary_face, boundary_type=InFlow(), face_normal,
open_boundary_layers=10, density=1.0, particle_spacing)
diff --git a/test/schemes/fluid/pressure_acceleration.jl b/test/schemes/fluid/pressure_acceleration.jl
index 80de3d9a66..46003b4413 100644
--- a/test/schemes/fluid/pressure_acceleration.jl
+++ b/test/schemes/fluid/pressure_acceleration.jl
@@ -65,4 +65,97 @@
correction_dict_2[correction_name])
end
end
+
+ @testset "Interface-aware tensile control" begin
+ control = InterfaceAwareTensileInstabilityControl()
+ 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)
+ @test TrixiParticles.interface_aware_tensile_acceleration(m_a, m_b, rho_a,
+ rho_b, p_a, p_b,
+ grad_kernel, 0.0,
+ 0.0, 1.0) == controlled
+ @test TrixiParticles.interface_aware_tensile_acceleration(m_a, m_b, rho_a,
+ rho_b, p_a, p_b,
+ grad_kernel, 1.0,
+ 0.0, 1.0) == standard
+ @test TrixiParticles.interface_aware_tensile_acceleration(m_a, m_b, rho_a,
+ rho_b, p_a, p_b,
+ grad_kernel, 0.5,
+ 0.0, 1.0) ==
+ (standard + controlled) / 2
+ @test TrixiParticles.interface_aware_tensile_acceleration(m_a, m_b, rho_a,
+ rho_b, p_a, p_b,
+ grad_kernel, 0.0,
+ 0.0, 0.25) ==
+ standard + 0.25 * (controlled - standard)
+ @test_throws ArgumentError InterfaceAwareTensileInstabilityControl(; strength=0)
+
+ 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
+ surface_tension = SurfaceTensionMomentumMorris(;
+ surface_tension_coefficient=1.0)
+ surface_normal_method = ColorfieldSurfaceNormal(; ideal_density_threshold=0.95)
+ state_equation = StateEquationCole(; sound_speed=10.0, reference_density=1000.0,
+ exponent=7, clip_negative_pressure=false)
+ system = WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel,
+ smoothing_length,
+ density_calculator=ContinuityDensity(),
+ state_equation, pressure_acceleration=control,
+ surface_tension, surface_normal_method,
+ reference_particle_spacing=particle_spacing)
+ @test system.pressure_acceleration_formulation === control
+
+ clipped_state_equation = StateEquationCole(; sound_speed=10.0,
+ reference_density=1000.0,
+ exponent=7,
+ clip_negative_pressure=true)
+ @test_throws ArgumentError WeaklyCompressibleSPHSystem(initial_condition;
+ smoothing_kernel,
+ smoothing_length,
+ density_calculator=ContinuityDensity(),
+ state_equation=clipped_state_equation,
+ pressure_acceleration=control,
+ surface_tension,
+ surface_normal_method,
+ reference_particle_spacing=particle_spacing)
+ @test_throws ArgumentError WeaklyCompressibleSPHSystem(initial_condition;
+ smoothing_kernel,
+ smoothing_length,
+ density_calculator=ContinuityDensity(),
+ state_equation,
+ pressure_acceleration=control)
+
+ # C-CSF provides the interface activity required by the TIC blend.
+ ccsf_system = WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel,
+ smoothing_length,
+ density_calculator=ContinuityDensity(),
+ state_equation,
+ pressure_acceleration=control,
+ surface_tension=SurfaceTensionMorris(;
+ surface_tension_coefficient=1.0),
+ surface_normal_method=CorrectedCSFSurfaceNormal(),
+ reference_particle_spacing=particle_spacing)
+ @test ccsf_system.pressure_acceleration_formulation === control
+
+ @test TrixiParticles.supports_interface_aware_tic(CorrectedCSFSurfaceNormal(),
+ SurfaceTensionMorris(;
+ surface_tension_coefficient=1.0))
+ @test !TrixiParticles.supports_interface_aware_tic(CorrectedCSFSurfaceNormal(),
+ SurfaceTensionMomentumMorris(;
+ surface_tension_coefficient=1.0))
+ @test !TrixiParticles.supports_interface_aware_tic(nothing,
+ SurfaceTensionMorris(;
+ surface_tension_coefficient=1.0))
+ end
end
diff --git a/test/schemes/fluid/shifting_techniques.jl b/test/schemes/fluid/shifting_techniques.jl
index 20ef2372fa..889346220d 100644
--- a/test/schemes/fluid/shifting_techniques.jl
+++ b/test/schemes/fluid/shifting_techniques.jl
@@ -7,6 +7,23 @@
@test_nowarn ConsistentShiftingSun2019()
pst = @test_nowarn ConsistentShiftingSun2019(sound_speed_factor=0.2)
@test pst.v_factor == 0.2
+ treatment = FreeSurfaceTangentialShifting()
+ pst = @test_nowarn ConsistentShiftingSun2019(; free_surface_treatment=treatment)
+ @test pst.free_surface_treatment === treatment
+ @test_throws ArgumentError ParticleShiftingTechnique(free_surface_treatment=:invalid)
+ css = SurfaceTensionMomentumMorris(; 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_nowarn TrixiParticles.validate_free_surface_shifting(pst,
+ ColorfieldSurfaceNormal(),
+ css)
+ @test_nowarn TrixiParticles.validate_free_surface_shifting(pst,
+ CorrectedCSFSurfaceNormal(),
+ SurfaceTensionMorris())
# Can't use both `v_max_factor` and `sound_speed_factor`
@test_throws ArgumentError ParticleShiftingTechnique(v_max_factor=1.0,
@@ -28,4 +45,53 @@
modify_continuity_equation=false,
second_continuity_equation_term=ContinuityEquationTermSun2019())
end
+
+ @testset "Tangential free-surface projection" begin
+ shifting_velocity = [3.0, 4.0]
+ normal = [1.0, 0.0]
+
+ @test TrixiParticles.tangential_shifting_velocity(shifting_velocity, normal,
+ 0.0) โ [3.0, 4.0]
+ @test TrixiParticles.tangential_shifting_velocity(shifting_velocity, normal,
+ 0.5) โ [1.5, 4.0]
+ tangential = TrixiParticles.tangential_shifting_velocity(shifting_velocity,
+ normal, 1.0)
+ @test tangential โ [0.0, 4.0]
+ @test dot(tangential, normal) โ 0.0
+ @test TrixiParticles.tangential_shifting_velocity(shifting_velocity,
+ zeros(2), 1.0) ==
+ 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()
+ shifting_technique = ConsistentShiftingSun2019(;
+ free_surface_treatment=FreeSurfaceTangentialShifting())
+ system = WeaklyCompressibleSPHSystem(initial_condition;
+ smoothing_kernel=WendlandC2Kernel{2}(),
+ smoothing_length=1.4 * particle_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 .= 0
+ system.cache.surface_normal .= 0
+ system.cache.interface_activity .= 0
+ system.cache.delta_v[:, 1] .= shifting_velocity
+ system.cache.surface_normal[:, 1] .= normal
+ system.cache.interface_activity[1] = 1
+
+ TrixiParticles.modify_shifting_with_surface_normal!(system,
+ FreeSurfaceTangentialShifting(),
+ DummySemidiscretization())
+ @test system.cache.delta_v[:, 1] โ [0.0, 4.0]
+ @test all(iszero, system.cache.delta_v[:, 2:end])
+ end
end
diff --git a/test/schemes/fluid/surface_normal_sph.jl b/test/schemes/fluid/surface_normal_sph.jl
index 5eb8a81704..4ce5f6c06f 100644
--- a/test/schemes/fluid/surface_normal_sph.jl
+++ b/test/schemes/fluid/surface_normal_sph.jl
@@ -143,6 +143,476 @@ function compute_curvature!(system, semi, ode)
v, u, v0_ode, u0_ode, semi, 0.0)
end
+@testset "Akinci planar-normal magnitude" begin
+ particle_spacing = 1.0
+ smoothing_kernel = SchoenbergCubicSplineKernel{3}()
+ smoothing_length = particle_spacing
+ fluid = RectangularShape(particle_spacing, (5, 5, 5), (0.0, 0.0, 0.0);
+ density=1.0)
+ state_equation = StateEquationCole(; sound_speed=10.0, reference_density=1.0,
+ exponent=1)
+ system = WeaklyCompressibleSPHSystem(fluid; smoothing_kernel, smoothing_length,
+ density_calculator=ContinuityDensity(),
+ state_equation,
+ surface_tension=SurfaceTensionAkinci(),
+ 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)
+
+ coordinates = fluid.coordinates
+ center = findfirst(particle -> coordinates[:, particle] == [2.5, 2.5, 2.5],
+ axes(coordinates, 2))
+ left_face = findfirst(particle -> coordinates[:, particle] == [0.5, 2.5, 2.5],
+ axes(coordinates, 2))
+ right_face = findfirst(particle -> coordinates[:, particle] == [4.5, 2.5, 2.5],
+ axes(coordinates, 2))
+
+ @test norm(TrixiParticles.akinci_surface_normal(system, center)) < 2e-16
+ @test isapprox(TrixiParticles.akinci_surface_normal(system, left_face),
+ SVector(1.0, 0.0, 0.0); atol=0.03)
+ @test isapprox(TrixiParticles.akinci_surface_normal(system, right_face),
+ SVector(-1.0, 0.0, 0.0); atol=0.03)
+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)
+
+ @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 "Corrected C-CSF planar boundary geometry" begin
+ nonsymmetric_moment = TrixiParticles.SMatrix{2, 2}((2.0, 0.2, 0.8, 1.0))
+ symmetrized_moment = (nonsymmetric_moment + transpose(nonsymmetric_moment)) / 2
+ @test TrixiParticles.ccsf_minimum_eigenvalue(nonsymmetric_moment) โ
+ minimum(eigvals(Symmetric(symmetrized_moment)))
+ renormalization = inv(nonsymmetric_moment)
+ normal_difference = SVector(0.3, -0.4)
+ kernel_direction = SVector(-0.2, 0.7)
+ corrected_divergence = dot(renormalization * normal_difference, kernel_direction)
+ @test TrixiParticles.ccsf_corrected_divergence(normal_difference, renormalization,
+ kernel_direction) โ corrected_divergence
+ @test abs(corrected_divergence -
+ dot(normal_difference, renormalization * kernel_direction)) > 0.01
+ @test TrixiParticles.ccsf_lambda_difference(0.8, 1.0) โ 0.2
+ @test TrixiParticles.ccsf_lambda_difference(0.6, 1.0) == 1.0
+
+ particle_spacing = 0.1
+ reference_density = 1000.0
+ smoothing_kernel = WendlandC2Kernel{3}()
+ smoothing_length = 1.4particle_spacing
+ fluid = RectangularShape(particle_spacing, (7, 7, 7), (0.0, 0.0, 0.0);
+ density=reference_density)
+ 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(;
+ contact_angle=90.0),
+ reference_particle_spacing=particle_spacing)
+
+ boundary_raw = RectangularShape(particle_spacing, (7, 7, 3),
+ (0.0, 0.0, -3particle_spacing);
+ density=reference_density)
+ exposed = isapprox.(boundary_raw.coordinates[3, :],
+ maximum(boundary_raw.coordinates[3, :]); atol=eps())
+ normals = zeros(size(boundary_raw.coordinates))
+ normals[3, exposed] .= -particle_spacing / 2
+ surface_measure = zeros(nparticles(boundary_raw))
+ surface_measure[exposed] .= particle_spacing^2
+ boundary = InitialCondition(; coordinates=boundary_raw.coordinates,
+ velocity=boundary_raw.velocity,
+ mass=boundary_raw.mass, density=boundary_raw.density,
+ pressure=boundary_raw.pressure, particle_spacing,
+ normals)
+ boundary_model = BoundaryModelDummyParticles(boundary; fluid_system=system,
+ surface_measure)
+ boundary_system = WallBoundarySystem(boundary, boundary_model)
+ semi = Semidiscretization(system, boundary_system)
+ ode = semidiscretize(semi, (0.0, 0.01))
+ TrixiParticles.update_systems_and_nhs(ode.u0.x..., semi, 0.0)
+
+ center = argmin(eachparticle(system)) do particle
+ sum(abs2, fluid.coordinates[:, particle] - [0.35, 0.35, 0.05])
+ end
+ cache = system.cache
+ @test cache.ccsf_boundary_distance[center] โ particle_spacing / 2
+ @test 0.9 < cache.ccsf_minimum_eigenvalue[center] < 1.1
+ @test cache.interface_activity[center] == 0
+ @test iszero(TrixiParticles.surface_normal(system, center))
+ @test cache.curvature[center] == 0
+ @test all(isfinite, cache.ccsf_minimum_eigenvalue)
+ @test all(isfinite, cache.curvature)
+
+ contact_line = filter(eachparticle(system)) do particle
+ cache.interface_activity[particle] > 0 &&
+ isapprox(cache.ccsf_boundary_distance[particle], particle_spacing / 2;
+ atol=eps())
+ end
+ @test !isempty(contact_line)
+ @test maximum(contact_line) do particle
+ abs(TrixiParticles.surface_normal(system, particle)[3])
+ end < 0.05
+end
+
+@testset "Corrected C-CSF hemispherical contact" begin
+ particle_spacing = 0.05
+ radius = 0.5
+ reference_density = 1000.0
+ full_sphere = SphereShape(particle_spacing, radius, (0.0, 0.0, 0.0),
+ reference_density; sphere_type=RoundSphere())
+ keep = findall(>(0), full_sphere.coordinates[3, :])
+ fluid = InitialCondition(; coordinates=full_sphere.coordinates[:, keep],
+ velocity=full_sphere.velocity[:, keep],
+ mass=full_sphere.mass[keep], density=full_sphere.density[keep],
+ pressure=full_sphere.pressure[keep], particle_spacing)
+ smoothing_kernel = WendlandC2Kernel{3}()
+ smoothing_length = 1.4particle_spacing
+ 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(;
+ contact_angle=90.0),
+ reference_particle_spacing=particle_spacing)
+
+ boundary_raw = RectangularShape(particle_spacing, (22, 22, 3),
+ (-0.55, -0.55, -3particle_spacing);
+ density=reference_density)
+ exposed = isapprox.(boundary_raw.coordinates[3, :],
+ maximum(boundary_raw.coordinates[3, :]); atol=eps())
+ normals = zeros(size(boundary_raw.coordinates))
+ normals[3, exposed] .= -particle_spacing / 2
+ surface_measure = zeros(nparticles(boundary_raw))
+ surface_measure[exposed] .= particle_spacing^2
+ boundary = InitialCondition(; coordinates=boundary_raw.coordinates,
+ velocity=boundary_raw.velocity,
+ mass=boundary_raw.mass, density=boundary_raw.density,
+ pressure=boundary_raw.pressure, particle_spacing,
+ normals)
+ boundary_model = BoundaryModelDummyParticles(boundary; fluid_system=system,
+ surface_measure)
+ boundary_system = WallBoundarySystem(boundary, boundary_model)
+ semi = Semidiscretization(system, boundary_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)
+ support = 2smoothing_length
+ contact = filter(active) do particle
+ cache.ccsf_boundary_distance[particle] < support
+ end
+ @test !isempty(contact)
+ @test minimum(cache.curvature[contact]) > 0
+ weighted_curvature = sum(cache.curvature[active] .* cache.delta_s[active]) /
+ sum(cache.delta_s[active])
+ contact_curvature = sum(cache.curvature[contact] .* cache.delta_s[contact]) /
+ sum(cache.delta_s[contact])
+ @test isapprox(weighted_curvature, 2 / radius; rtol=0.1)
+ @test isapprox(contact_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())
+ system = WeaklyCompressibleSPHSystem(fluid;
+ smoothing_kernel=WendlandC2Kernel{3}(),
+ smoothing_length=1.4particle_spacing,
+ density_calculator=ContinuityDensity(),
+ state_equation=StateEquationCole(;
+ sound_speed=10.0,
+ reference_density,
+ exponent=7),
+ surface_tension=SurfaceTensionMomentumMorris(;
+ surface_tension_coefficient=1.0),
+ surface_normal_method=ColorfieldSurfaceNormal(;
+ ideal_density_threshold=0.95,
+ normal_smoothing=true),
+ 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)
+
+ active = findall(>(0), system.cache.interface_activity)
+ @test !isempty(active)
+ @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
+ @test all(active) do particle
+ dot(TrixiParticles.surface_normal(system, particle),
+ fluid.coordinates[:, particle]) < 0
+ end
+
+ raw_system = WeaklyCompressibleSPHSystem(fluid;
+ smoothing_kernel=WendlandC2Kernel{3}(),
+ smoothing_length=1.4particle_spacing,
+ density_calculator=ContinuityDensity(),
+ state_equation=StateEquationCole(;
+ sound_speed=10.0,
+ reference_density,
+ exponent=7),
+ surface_tension=SurfaceTensionMomentumMorris(;
+ surface_tension_coefficient=1.0),
+ 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 is a capillary-model choice and must not alter the raw normal used by PST.
+ @test system.cache.surface_normal โ raw_system.cache.surface_normal
+ @test maximum(active) do particle
+ norm(TrixiParticles.surface_tension_normal(system, particle) -
+ TrixiParticles.surface_normal(system, particle))
+ end > 1.0e-4
+end
+
+# With an explicit finite contact threshold, the colorfield-gradient normal extends the
+# fluid-only formulation of Akinci et al. (2013) by including boundary neighbors. Fluid
+# particles resting on a wetted, lattice-continuing wall are thereby treated like interior
+# particles with near-zero normals, while a distant wall leaves the free-surface normal
+# untouched. This test also pins the fluid-only Akinci default.
+@testset "Akinci wall-contact normals" begin
+ function build_fluid_over_wall(wall_offset;
+ surface_normal_method=ColorfieldSurfaceNormal())
+ particle_spacing = 0.2
+ # Compact support of 1.6 particle spacings, so that only the first missing fluid
+ # row below the bottom row can be replaced by wall contributions
+ smoothing_length = 0.8 * particle_spacing
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ state_equation = StateEquationCole(sound_speed=10.0, reference_density=1000.0,
+ exponent=1)
+
+ fluid = RectangularShape(particle_spacing, (8, 6), (0.0, 0.0); density=1000.0)
+ fluid_sys = WeaklyCompressibleSPHSystem(fluid; smoothing_kernel, smoothing_length,
+ density_calculator=SummationDensity(),
+ state_equation,
+ surface_tension=SurfaceTensionAkinci(surface_tension_coefficient=0.072),
+ surface_normal_method,
+ reference_particle_spacing=particle_spacing)
+
+ # Wall on the same lattice as the fluid, with the top particle row `wall_offset`
+ # below the bottom fluid row. `wall_offset == particle_spacing` continues the
+ # fluid lattice, as the boundaries of `RectangularTank` do.
+ wall_thickness = 4 * particle_spacing
+ ymin = minimum(fluid.coordinates[2, :])
+ wall = RectangularShape(particle_spacing, (8, 4),
+ (0.0,
+ ymin - wall_offset - wall_thickness +
+ particle_spacing / 2); density=1000.0)
+ boundary_model = BoundaryModelDummyParticles(wall.density, wall.mass,
+ AdamiPressureExtrapolation(),
+ smoothing_kernel, smoothing_length;
+ state_equation,
+ reference_particle_spacing=particle_spacing)
+ boundary_sys = WallBoundarySystem(wall, boundary_model, adhesion_coefficient=0.0)
+
+ semi_ = Semidiscretization(fluid_sys, boundary_sys)
+ 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)
+
+ find(coords) = findfirst(p -> isapprox(fluid.coordinates[:, p], coords;
+ atol=1e-10),
+ axes(fluid.coordinates, 2))
+ bottom_center = find([0.7, 0.1])
+ top_center = find([0.7, 1.1])
+
+ return (TrixiParticles.akinci_surface_normal(fluid_sys, bottom_center),
+ TrixiParticles.akinci_surface_normal(fluid_sys, top_center))
+ end
+
+ # Wetted wall continuing the fluid lattice and wall far outside the compact support
+ n_bottom_wetted, n_top_wetted = build_fluid_over_wall(0.2)
+ n_bottom_far, n_top_far = build_fluid_over_wall(2.0)
+
+ # With a distant wall, the bottom row is a free surface with an inward (upward) normal
+ @test n_bottom_far[2] > 0.99 * norm(n_bottom_far)
+ @test norm(n_bottom_far) > 0.5
+
+ # The wetted wall replaces the missing fluid neighbors, so the bottom row is treated
+ # like the fluid interior
+ @test norm(n_bottom_wetted) < 0.05 * norm(n_bottom_far)
+
+ # The free surface at the top is unaffected by the wall in both cases
+ @test isapprox(n_top_wetted, n_top_far; rtol=sqrt(eps()))
+ @test n_top_far[2] < -0.99 * norm(n_top_far)
+
+ n_bottom_default,
+ n_top_default = build_fluid_over_wall(0.2;
+ surface_normal_method=nothing)
+ n_bottom_fluid_only,
+ n_top_fluid_only = build_fluid_over_wall(0.2;
+ surface_normal_method=ColorfieldSurfaceNormal(boundary_contact_threshold=Inf))
+ @test n_bottom_default == n_bottom_fluid_only
+ @test n_top_default == n_top_fluid_only
+end
+
+@testset "CSS flat-pool geometry" begin
+ function build_flat_pool(contact_model)
+ particle_spacing = 0.1
+ reference_density = 1000.0
+ smoothing_kernel = WendlandC2Kernel{2}()
+ smoothing_length = 1.4 * particle_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,
+ contact_model)
+ 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 wall continues the fluid lattice: the top wall row is one particle 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)
+
+ return (; fluid, fluid_system, boundary_system, semi, v_ode, u_ode)
+ end
+
+ flat_pool = build_flat_pool(nothing)
+ (; fluid, fluid_system, boundary_system, semi, v_ode, u_ode) = flat_pool
+ 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
+
+ # The continuous support moment identifies wall-completed bulk stencils as interior while
+ # retaining the planar free surface. Both regions must have zero CSS acceleration.
+ @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 7fe8abbd97..5a29f47ad7 100644
--- a/test/schemes/fluid/surface_tension.jl
+++ b/test/schemes/fluid/surface_tension.jl
@@ -1,5 +1,788 @@
-
@testset verbose=true "Surface Tension" begin
+ function build_wetted_area_setup(; solver=:wcsph, angle=60.0,
+ contact=true, ELTYPE=Float64,
+ smoothing_kernel=WendlandC2Kernel{3}(),
+ smoothing_length_ratio=1.4,
+ density_calculator=ContinuityDensity(),
+ surface_tension_model=:momentum,
+ provide_surface_measure=true,
+ provide_normals=true,
+ surface_measure_mode=:connected,
+ boundary_kind=:wall,
+ prescribed_motion=nothing,
+ rotation=nothing,
+ fluid_color=1, boundary_color=0)
+ particle_spacing = ELTYPE(0.1)
+ smoothing_length = ELTYPE(smoothing_length_ratio) * particle_spacing
+ reference_density = ELTYPE(1000)
+ fluid_raw = RectangularShape(particle_spacing, (4, 4, 3),
+ (zero(ELTYPE), zero(ELTYPE), zero(ELTYPE));
+ density=reference_density)
+ transform = isnothing(rotation) ? Matrix{ELTYPE}(I, 3, 3) : ELTYPE.(rotation)
+ fluid = InitialCondition(; coordinates=transform * fluid_raw.coordinates,
+ velocity=transform * fluid_raw.velocity,
+ mass=fluid_raw.mass, density=fluid_raw.density,
+ pressure=fluid_raw.pressure,
+ particle_spacing)
+ state_equation = StateEquationCole(; sound_speed=ELTYPE(10), reference_density,
+ exponent=1)
+ contact_model = contact ? WettedAreaContactAngle(ELTYPE(angle)) : nothing
+ normal_method = ColorfieldSurfaceNormal(; boundary_contact_threshold=zero(ELTYPE),
+ interface_threshold=ELTYPE(0.01),
+ ideal_density_threshold=ELTYPE(0.95),
+ contact_model)
+ surface_tension = surface_tension_model == :momentum ?
+ SurfaceTensionMomentumMorris(;
+ surface_tension_coefficient=ELTYPE(0.072)) :
+ SurfaceTensionMorris(;
+ surface_tension_coefficient=ELTYPE(0.072))
+ fluid_system = if solver == :wcsph
+ WeaklyCompressibleSPHSystem(fluid; smoothing_kernel, smoothing_length,
+ density_calculator, state_equation, surface_tension,
+ surface_normal_method=normal_method,
+ reference_particle_spacing=particle_spacing,
+ color_value=fluid_color)
+ else
+ EntropicallyDampedSPHSystem(fluid; smoothing_kernel, smoothing_length,
+ sound_speed=ELTYPE(10), density_calculator,
+ surface_tension,
+ surface_normal_method=normal_method,
+ reference_particle_spacing=particle_spacing,
+ color_value=fluid_color)
+ end
+
+ boundary_raw = RectangularShape(particle_spacing, (4, 4, 3),
+ (zero(ELTYPE), zero(ELTYPE),
+ -3particle_spacing);
+ density=reference_density)
+ exposed_height = maximum(boundary_raw.coordinates[3, :])
+ exposed = isapprox.(boundary_raw.coordinates[3, :], exposed_height;
+ atol=eps(ELTYPE))
+ normals = zeros(ELTYPE, size(boundary_raw.coordinates))
+ normals[3, exposed] .= -particle_spacing / 2
+ surface_measure = zeros(ELTYPE, nparticles(boundary_raw))
+ if surface_measure_mode == :connected
+ surface_measure[exposed] .= particle_spacing^2
+ elseif surface_measure_mode == :disconnected
+ exposed_particles = findall(exposed)
+ surface_measure[first(exposed_particles)] = particle_spacing^2
+ surface_measure[last(exposed_particles)] = particle_spacing^2
+ end
+ boundary = InitialCondition(;
+ coordinates=transform * boundary_raw.coordinates,
+ velocity=transform * boundary_raw.velocity,
+ mass=boundary_raw.mass, density=boundary_raw.density,
+ pressure=boundary_raw.pressure, particle_spacing,
+ normals=provide_normals ? transform * normals : nothing)
+ boundary_model = if provide_surface_measure
+ BoundaryModelDummyParticles(boundary; fluid_system,
+ surface_measure=surface_measure)
+ else
+ BoundaryModelDummyParticles(boundary; fluid_system)
+ end
+ boundary_system = if boundary_kind == :wall
+ WallBoundarySystem(boundary, boundary_model; prescribed_motion,
+ color_value=boundary_color)
+ else
+ RigidBodySystem(boundary; boundary_model, color_value=boundary_color)
+ end
+ semi = Semidiscretization(fluid_system, boundary_system)
+ ode = semidiscretize(semi, (zero(ELTYPE), ELTYPE(0.01)))
+ return (; fluid_system, boundary_system, semi, ode, surface_measure,
+ particle_spacing)
+ end
+
+ function wetted_area_kick(setup; time=zero(eltype(setup.fluid_system)))
+ v_ode, u_ode = setup.ode.u0.x
+ dv_ode = zero(v_ode)
+ TrixiParticles.kick!(dv_ode, v_ode, u_ode, setup.ode.p, time)
+ fluid_dv = TrixiParticles.wrap_v(dv_ode, setup.fluid_system, setup.semi)
+ return Array(fluid_dv[1:3, :]), dv_ode
+ end
+
+ @testset "constructors and capabilities" begin
+ constructors = (CohesionForceAkinci, SurfaceTensionAkinci,
+ SurfaceTensionMorris, SurfaceTensionMomentumMorris)
+
+ for constructor in constructors
+ model = constructor(surface_tension_coefficient=0.5f0)
+ @test model.surface_tension_coefficient === 0.5f0
+ @test iszero(constructor(surface_tension_coefficient=0).surface_tension_coefficient)
+
+ for coefficient in (-1.0, NaN, Inf, -Inf, 1.0im, "invalid")
+ @test_throws ArgumentError constructor(surface_tension_coefficient=coefficient)
+ end
+ end
+
+ physical = SurfaceTensionAkinciCohesionPhysical(;
+ surface_tension_coefficient=0.072f0,
+ reference_density=1000.0f0)
+ @test physical.surface_tension_coefficient === 0.072f0
+ @test physical.reference_density === 1000.0f0
+ system_data = Dict{String, Any}()
+ TrixiParticles.add_system_data!(system_data, physical)
+ @test system_data["surface_tension"]["model"] ==
+ "SurfaceTensionAkinciCohesionPhysical"
+ @test system_data["surface_tension"]["surface_tension_coefficient"] === 0.072f0
+ @test system_data["surface_tension"]["reference_density"] === 1000.0f0
+ @test SurfaceTensionAkinciCohesionPhysical(;
+ surface_tension_coefficient=0,
+ reference_density=1).surface_tension_coefficient ==
+ 0
+ for coefficient in (-1.0, NaN, Inf, -Inf, 1.0im, "invalid")
+ @test_throws ArgumentError SurfaceTensionAkinciCohesionPhysical(;
+ surface_tension_coefficient=coefficient,
+ reference_density=1000.0)
+ end
+ for density in (0.0, -1.0, NaN, Inf, -Inf, 1.0im, "invalid")
+ @test_throws ArgumentError SurfaceTensionAkinciCohesionPhysical(;
+ surface_tension_coefficient=0.072,
+ reference_density=density)
+ end
+
+ @test !TrixiParticles.requires_surface_normal(nothing)
+ @test !TrixiParticles.requires_surface_normal(CohesionForceAkinci())
+ @test !TrixiParticles.requires_surface_normal(physical)
+ @test TrixiParticles.requires_surface_normal(SurfaceTensionAkinci())
+ @test TrixiParticles.requires_surface_normal(SurfaceTensionMorris())
+ @test TrixiParticles.requires_surface_normal(SurfaceTensionMomentumMorris())
+ @test isinf(TrixiParticles.default_surface_normal_method(SurfaceTensionAkinci(),
+ nothing).boundary_contact_threshold)
+ @test TrixiParticles.default_surface_normal_method(SurfaceTensionMorris(),
+ nothing).boundary_contact_threshold ==
+ 0.1
+
+ initial_condition_1d = InitialCondition(; coordinates=reshape([0.0, 1.0], 1, 2),
+ density=ones(2), particle_spacing=1.0)
+ smoothing_kernel_1d = WendlandC2Kernel{1}()
+ state_equation_1d = StateEquationCole(sound_speed=10.0, reference_density=1.0,
+ exponent=1)
+ for surface_tension in (CohesionForceAkinci(), SurfaceTensionAkinci())
+ @test_throws ArgumentError WeaklyCompressibleSPHSystem(initial_condition_1d;
+ smoothing_kernel=smoothing_kernel_1d,
+ smoothing_length=1.0,
+ density_calculator=SummationDensity(),
+ state_equation=state_equation_1d,
+ surface_tension,
+ reference_particle_spacing=1.0)
+ @test_throws ArgumentError EntropicallyDampedSPHSystem(initial_condition_1d;
+ smoothing_kernel=smoothing_kernel_1d,
+ smoothing_length=1.0,
+ sound_speed=10.0,
+ surface_tension,
+ reference_particle_spacing=1.0)
+ end
+
+ initial_condition_2d = InitialCondition(; coordinates=[0.0 1.0; 0.0 0.0],
+ density=ones(2), particle_spacing=1.0)
+ @test_throws ArgumentError WeaklyCompressibleSPHSystem(initial_condition_2d;
+ smoothing_kernel=WendlandC2Kernel{2}(),
+ smoothing_length=1.0,
+ density_calculator=SummationDensity(),
+ state_equation=state_equation_1d,
+ surface_tension=physical)
+
+ normal_method = ColorfieldSurfaceNormal(boundary_contact_threshold=1,
+ interface_threshold=0.1f0,
+ ideal_density_threshold=0.25)
+ @test normal_method isa ColorfieldSurfaceNormal{Float64}
+ @test normal_method.interface_taper_start === 0.8
+ @test normal_method.support_taper_width === 0.025
+ @test ColorfieldSurfaceNormal(boundary_contact_threshold=0.1f0,
+ interface_threshold=0.01f0,
+ ideal_density_threshold=0.0f0) isa
+ ColorfieldSurfaceNormal{Float32}
+ wetted_area = ColorfieldSurfaceNormal(boundary_contact_threshold=0.1f0,
+ interface_threshold=0.01f0,
+ ideal_density_threshold=0.0f0,
+ contact_model=WettedAreaContactAngle(60.0f0))
+ @test wetted_area.contact_model.contact_angle === 60.0f0
+ @test isnothing(ColorfieldSurfaceNormal().contact_model)
+ @test isnothing(ColorfieldSurfaceNormal(0.1, 0.01, 0.0).contact_model)
+ @test ColorfieldSurfaceNormal(1, 1, 0) isa ColorfieldSurfaceNormal{Float64}
+ normal_data = Dict{String, Any}()
+ TrixiParticles.add_system_data!(normal_data, normal_method)
+ @test normal_data["surface_normal_method"]["interface_threshold"] โ 0.1
+ @test normal_data["surface_normal_method"]["interface_taper_start"] === 0.8
+ @test normal_data["surface_normal_method"]["support_taper_width"] === 0.025
+ @test isnothing(normal_data["surface_normal_method"]["contact_model"])
+ @test isnothing(normal_data["surface_normal_method"]["contact_angle"])
+ akinci_normal_data = Dict{String, Any}()
+ TrixiParticles.add_system_data!(akinci_normal_data,
+ TrixiParticles.default_surface_normal_method(SurfaceTensionAkinci(),
+ nothing))
+ @test akinci_normal_data["surface_normal_method"]["boundary_contact_threshold"] ==
+ "Inf"
+ @test_nowarn JSON.json(akinci_normal_data)
+ for (method, model_name, angle) in
+ ((wetted_area, "WettedAreaContactAngle", 60.0f0),)
+ data = Dict{String, Any}()
+ TrixiParticles.add_system_data!(data, method)
+ @test data["surface_normal_method"]["contact_model"] == model_name
+ @test data["surface_normal_method"]["contact_angle"] === angle
+ end
+ for angle in (-1, 181, NaN, Inf, 1im, "invalid")
+ @test_throws ArgumentError WettedAreaContactAngle(angle)
+ end
+ @test_throws ArgumentError WettedAreaContactAngle(0)
+ @test_throws ArgumentError WettedAreaContactAngle(180)
+ @test_throws ArgumentError ColorfieldSurfaceNormal(contact_model=:invalid)
+ 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
+ end
+
+ @testset "wetted-area configuration and quadrature" begin
+ setup32 = build_wetted_area_setup(; ELTYPE=Float32)
+ fluid32 = setup32.fluid_system
+ boundary_cache32 = setup32.boundary_system.boundary_model.cache
+ @test fluid32.surface_normal_method.contact_model isa
+ WettedAreaContactAngle{Float32}
+ @test eltype(fluid32.cache.wetted_area_density_conjugate) == Float32
+ @test eltype(boundary_cache32.wetted_area_surface_measure) == Float32
+ @test all(>=(0), boundary_cache32.wetted_area_surface_measure)
+ @test all(>(0),
+ boundary_cache32.wetted_area_flooded_reference[setup32.surface_measure .> 0])
+ @test isfinite(fluid32.cache.wetted_area_normalized_edge_shift[])
+
+ @test_throws ArgumentError build_wetted_area_setup(;
+ provide_surface_measure=false)
+ @test_throws ArgumentError build_wetted_area_setup(; provide_normals=false)
+ @test_throws ArgumentError build_wetted_area_setup(;
+ surface_measure_mode=:disconnected)
+ @test_throws ArgumentError build_wetted_area_setup(;
+ surface_measure_mode=:empty)
+ @test_throws ArgumentError build_wetted_area_setup(;
+ smoothing_length_ratio=1.5)
+ @test_throws ArgumentError build_wetted_area_setup(;
+ smoothing_kernel=SchoenbergCubicSplineKernel{3}())
+ @test_throws ArgumentError build_wetted_area_setup(;
+ density_calculator=SummationDensity())
+ @test_throws ArgumentError build_wetted_area_setup(;
+ surface_tension_model=:csf)
+ @test_throws ArgumentError build_wetted_area_setup(; fluid_color=2)
+ @test_throws ArgumentError build_wetted_area_setup(; boundary_color=1)
+
+ boundary = setup32.boundary_system.initial_condition
+ fluid_system = setup32.fluid_system
+ @test_throws ArgumentError BoundaryModelDummyParticles(boundary; fluid_system,
+ surface_measure=1.0f0)
+ @test_throws ArgumentError BoundaryModelDummyParticles(boundary; fluid_system,
+ surface_measure=zeros(Float32,
+ nparticles(boundary) -
+ 1))
+ invalid_measure = copy(setup32.surface_measure)
+ invalid_measure[1] = -1
+ @test_throws ArgumentError BoundaryModelDummyParticles(boundary; fluid_system,
+ surface_measure=invalid_measure)
+ invalid_measure[1] = NaN
+ @test_throws ArgumentError BoundaryModelDummyParticles(boundary; fluid_system,
+ surface_measure=invalid_measure)
+ no_quadrature = build_wetted_area_setup(; contact=false,
+ provide_surface_measure=false)
+ @test !haskey(no_quadrature.boundary_system.boundary_model.cache,
+ :wetted_area_surface_measure)
+
+ particle_spacing = 0.1
+ fluid_2d = RectangularShape(particle_spacing, (3, 3), (0.0, 0.0);
+ density=1000.0)
+ fluid_system_2d = WeaklyCompressibleSPHSystem(fluid_2d;
+ smoothing_kernel=WendlandC2Kernel{2}(),
+ smoothing_length=1.4particle_spacing,
+ density_calculator=ContinuityDensity(),
+ state_equation=StateEquationCole(;
+ sound_speed=10.0,
+ reference_density=1000.0,
+ exponent=1),
+ surface_tension=SurfaceTensionMomentumMorris(;
+ surface_tension_coefficient=0.072),
+ surface_normal_method=ColorfieldSurfaceNormal(;
+ contact_model=WettedAreaContactAngle(60.0)),
+ reference_particle_spacing=particle_spacing)
+ @test_throws ArgumentError Semidiscretization(fluid_system_2d)
+
+ setup = build_wetted_area_setup()
+ boundary = setup.boundary_system.initial_condition
+ second_model = BoundaryModelDummyParticles(boundary;
+ fluid_system=setup.fluid_system,
+ surface_measure=setup.surface_measure)
+ second_boundary = WallBoundarySystem(boundary, second_model)
+ multiple_semi = Semidiscretization(setup.fluid_system, setup.boundary_system,
+ second_boundary)
+ multiple_ode = semidiscretize(multiple_semi, (0.0, 0.01))
+ multiple_dv = zero(multiple_ode.u0.x[1])
+ TrixiParticles.kick!(multiple_dv, multiple_ode.u0.x...,
+ multiple_ode.p, 0.0)
+ @test sum(abs, setup.boundary_system.boundary_model.cache.wetted_area_weight) > 0
+ @test sum(abs, second_model.cache.wetted_area_weight) > 0
+ @test setup.fluid_system.cache.wetted_area[] > 0
+ end
+
+ @testset "smooth interface activity" begin
+ 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
+ end
+
+ @testset "wetted-area energy and production RHS" begin
+ active = build_wetted_area_setup(; angle=60.0)
+ neutral = build_wetted_area_setup(; angle=90.0)
+ no_contact = build_wetted_area_setup(; contact=false)
+ active_acceleration, = wetted_area_kick(active)
+ neutral_acceleration, = wetted_area_kick(neutral)
+ no_contact_acceleration, = wetted_area_kick(no_contact)
+ contact_acceleration = active_acceleration - neutral_acceleration
+
+ @test neutral_acceleration == no_contact_acceleration
+ @test neutral.fluid_system.cache.wetted_area_energy[] == 0
+ @test all(iszero, neutral.fluid_system.cache.wetted_area_density_conjugate)
+ @test all(iszero,
+ neutral.boundary_system.boundary_model.cache.wetted_area_weight)
+ @test all(iszero,
+ neutral.boundary_system.boundary_model.cache.wetted_area_reaction)
+ @test active.fluid_system.cache.wetted_area_energy[] < 0
+ @test norm(contact_acceleration) > 0
+
+ fluid_force = contact_acceleration * active.fluid_system.mass
+ wall_reaction_cache = active.boundary_system.boundary_model.cache.wetted_area_reaction
+ wall_reaction = vec(sum(wall_reaction_cache;
+ dims=2))
+ force_scale = sum(particle -> norm(active.fluid_system.mass[particle] *
+ contact_acceleration[:, particle]),
+ eachparticle(active.fluid_system)) +
+ sum(particle -> norm(view(wall_reaction_cache, :, particle)),
+ eachparticle(active.boundary_system))
+ @test norm(fluid_force + wall_reaction) / force_scale < 1.0e-12
+
+ v_ode, u_ode = active.ode.u0.x
+ v = TrixiParticles.wrap_v(v_ode, active.fluid_system, active.semi)
+ u = TrixiParticles.wrap_u(u_ode, active.fluid_system, active.semi)
+ u_boundary = TrixiParticles.wrap_u(u_ode, active.boundary_system, active.semi)
+ coordinates = Array(TrixiParticles.current_coordinates(u, active.fluid_system))
+ boundary_coordinates = Array(TrixiParticles.current_coordinates(u_boundary,
+ active.boundary_system))
+ density = collect(TrixiParticles.current_density(v, active.fluid_system))
+ displacement = similar(coordinates)
+ displacement_scale = max(maximum(abs, coordinates), active.particle_spacing)
+ for particle in eachparticle(active.fluid_system)
+ displacement[1, particle] = -coordinates[1, particle] / displacement_scale
+ displacement[2, particle] = -coordinates[2, particle] / displacement_scale
+ displacement[3, particle] = 2coordinates[3, particle] / displacement_scale
+ end
+ density_rate = zeros(eltype(active.fluid_system),
+ nparticles(active.fluid_system))
+ TrixiParticles.foreach_point_neighbor(active.fluid_system, active.fluid_system,
+ coordinates, coordinates, active.semi;
+ points=eachparticle(active.fluid_system),
+ parallelization_backend=SerialBackend()) do particle,
+ neighbor,
+ pos_diff,
+ distance
+ gradient = TrixiParticles.smoothing_kernel_grad(active.fluid_system,
+ pos_diff, distance, particle)
+ mass_b = TrixiParticles.hydrodynamic_mass(active.fluid_system, neighbor)
+ density_rate[particle] += density[particle] / density[neighbor] * mass_b *
+ dot(displacement[:, particle] -
+ displacement[:, neighbor], gradient)
+ end
+ fluid_boundary_pairs = Tuple{Int, Int}[]
+ TrixiParticles.foreach_point_neighbor(active.fluid_system,
+ active.boundary_system,
+ coordinates, boundary_coordinates,
+ active.semi;
+ points=eachparticle(active.fluid_system),
+ parallelization_backend=SerialBackend()) do particle,
+ neighbor,
+ pos_diff,
+ distance
+ push!(fluid_boundary_pairs, (particle, neighbor))
+ end
+
+ function perturbed_wetted_area_energy(epsilon)
+ boundary_cache = active.boundary_system.boundary_model.cache
+ colorfield = copy(boundary_cache.initial_colorfield)
+ for (particle, neighbor) in fluid_boundary_pairs
+ distance2 = zero(eltype(active.fluid_system))
+ for dim in 1:3
+ difference = coordinates[dim, particle] +
+ epsilon * displacement[dim, particle] -
+ boundary_coordinates[dim, neighbor]
+ distance2 += difference^2
+ end
+ perturbed_density = density[particle] +
+ epsilon * density_rate[particle]
+ colorfield[neighbor] += active.fluid_system.mass[particle] /
+ perturbed_density *
+ TrixiParticles.smoothing_kernel(active.fluid_system,
+ sqrt(distance2),
+ particle)
+ end
+ raw_area = zero(eltype(active.fluid_system))
+ for particle in eachparticle(active.boundary_system)
+ measure = boundary_cache.wetted_area_surface_measure[particle]
+ iszero(measure) && continue
+ reference = boundary_cache.wetted_area_flooded_reference[particle]
+ fraction = clamp(colorfield[particle] / reference, 0, 1)
+ raw_area += measure * TrixiParticles.cubic_smoothstep(fraction)
+ end
+ raw_radius = sqrt(raw_area / pi)
+ edge_shift = active.fluid_system.cache.wetted_area_normalized_edge_shift[] *
+ TrixiParticles.initial_smoothing_length(active.fluid_system)
+ corrected_radius = max(raw_radius - edge_shift, zero(raw_radius))
+ coefficient = TrixiParticles.wetted_area_coefficient(active.fluid_system.surface_tension,
+ active.fluid_system.surface_normal_method.contact_model)
+ return -coefficient * pi * corrected_radius^2
+ end
+
+ epsilon = 1.0e-5active.particle_spacing
+ finite_difference = (perturbed_wetted_area_energy(epsilon) -
+ perturbed_wetted_area_energy(-epsilon)) / (2epsilon)
+ analytic_derivative = zero(finite_difference)
+ for particle in eachparticle(active.fluid_system)
+ analytic_derivative -= active.fluid_system.mass[particle] *
+ dot(contact_acceleration[:, particle],
+ displacement[:, particle])
+ end
+ derivative_scale = max(abs(finite_difference), abs(analytic_derivative))
+ @test abs(finite_difference - analytic_derivative) / derivative_scale < 1.0e-5
+ @test perturbed_wetted_area_energy(0.0) โ
+ active.fluid_system.cache.wetted_area_energy[] rtol = 5eps()
+
+ active_edac = build_wetted_area_setup(; solver=:edac, angle=60.0)
+ neutral_edac = build_wetted_area_setup(; solver=:edac, angle=90.0)
+ active_edac_acceleration, = wetted_area_kick(active_edac)
+ neutral_edac_acceleration, = wetted_area_kick(neutral_edac)
+ edac_contact_acceleration = active_edac_acceleration -
+ neutral_edac_acceleration
+ edac_force = edac_contact_acceleration * active_edac.fluid_system.mass
+ edac_reaction = vec(sum(active_edac.boundary_system.boundary_model.cache.wetted_area_reaction;
+ dims=2))
+ @test norm(edac_contact_acceleration) > 0
+ @test norm(edac_force + edac_reaction) <
+ 1.0e-12 *
+ (norm(edac_force) + norm(edac_reaction))
+
+ active_rigid = build_wetted_area_setup(; boundary_kind=:rigid, angle=60.0)
+ neutral_rigid = build_wetted_area_setup(; boundary_kind=:rigid, angle=90.0)
+ wetted_area_kick(active_rigid)
+ wetted_area_kick(neutral_rigid)
+ rigid_reaction = active_rigid.boundary_system.boundary_model.cache.wetted_area_reaction
+ rigid_contact_force = active_rigid.boundary_system.force_per_particle -
+ neutral_rigid.boundary_system.force_per_particle
+ @test rigid_contact_force โ rigid_reaction rtol = 2eps()
+ @test active_rigid.boundary_system.resultant_force[] -
+ neutral_rigid.boundary_system.resultant_force[] โ
+ vec(sum(rigid_reaction; dims=2)) rtol = 2eps()
+ expected_torque = zero(active_rigid.boundary_system.resultant_torque[])
+ for particle in eachparticle(active_rigid.boundary_system)
+ relative_position = TrixiParticles.extract_svector(active_rigid.boundary_system.relative_coordinates,
+ active_rigid.boundary_system,
+ particle)
+ reaction = TrixiParticles.extract_svector(rigid_reaction,
+ active_rigid.boundary_system,
+ particle)
+ expected_torque += cross(relative_position, reaction)
+ end
+ @test active_rigid.boundary_system.resultant_torque[] -
+ neutral_rigid.boundary_system.resultant_torque[] โ expected_torque atol = 1.0e-12
+
+ rotation = [0.0 0.0 1.0; 0.0 1.0 0.0; -1.0 0.0 0.0]
+ rotated_active = build_wetted_area_setup(; angle=60.0, rotation)
+ rotated_neutral = build_wetted_area_setup(; angle=90.0, rotation)
+ rotated_active_acceleration, = wetted_area_kick(rotated_active)
+ rotated_neutral_acceleration, = wetted_area_kick(rotated_neutral)
+ @test rotated_active_acceleration-rotated_neutral_accelerationโ
+ rotation*contact_acceleration rtol=2.0e-12 atol=2.0e-12
+
+ moving_motion() = PrescribedMotion((position,
+ time) -> begin
+ cosine = cos(time)
+ sine = sin(time)
+ SVector(cosine * position[1] +
+ sine * position[3], position[2],
+ -sine * position[1] +
+ cosine * position[3])
+ end,
+ time -> true)
+ moving_active = build_wetted_area_setup(; angle=60.0,
+ prescribed_motion=moving_motion())
+ moving_neutral = build_wetted_area_setup(; angle=90.0,
+ prescribed_motion=moving_motion())
+ moving_active_acceleration, = wetted_area_kick(moving_active; time=0.02)
+ moving_neutral_acceleration, = wetted_area_kick(moving_neutral; time=0.02)
+ moving_contact_acceleration = moving_active_acceleration -
+ moving_neutral_acceleration
+ moving_force = moving_contact_acceleration * moving_active.fluid_system.mass
+ moving_reaction = vec(sum(moving_active.boundary_system.boundary_model.cache.wetted_area_reaction;
+ dims=2))
+ @test norm(moving_contact_acceleration) > 0
+ @test norm(moving_force + moving_reaction) <
+ 1.0e-12 *
+ (norm(moving_force) + norm(moving_reaction))
+ 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.0; 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 .= [1.0; 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
+
+ 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 denominator > sqrt(eps()) ? system.cache.curvature[1] / denominator : 0.0
+ 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)
+ end
+
+ @testset "cohesion-only systems do not require normals" begin
+ coordinates = [0.0 1.0;
+ 0.0 0.0]
+ initial_condition = InitialCondition(; coordinates, density=ones(2),
+ particle_spacing=1.0)
+ smoothing_kernel = WendlandC2Kernel{2}()
+ smoothing_length = 1.0
+ surface_tension = CohesionForceAkinci(surface_tension_coefficient=0.1)
+
+ wcsph = WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel,
+ smoothing_length,
+ density_calculator=SummationDensity(),
+ state_equation=StateEquationCole(sound_speed=10.0,
+ reference_density=1.0,
+ exponent=1),
+ surface_tension)
+ edac = EntropicallyDampedSPHSystem(initial_condition; smoothing_kernel,
+ smoothing_length, sound_speed=10.0,
+ density_calculator=SummationDensity(),
+ surface_tension)
+
+ for system in (wcsph, edac)
+ @test isnothing(system.surface_normal_method)
+ @test !haskey(system.cache, :surface_normal)
+ @test !haskey(system.cache, :neighbor_count)
+ @test !haskey(system.cache, :reference_particle_spacing)
+ end
+
+ @test_throws ArgumentError WeaklyCompressibleSPHSystem(initial_condition;
+ smoothing_kernel,
+ smoothing_length,
+ density_calculator=SummationDensity(),
+ state_equation=StateEquationCole(sound_speed=10.0,
+ reference_density=1.0,
+ exponent=1),
+ surface_tension=SurfaceTensionAkinci())
+ @test_throws ArgumentError EntropicallyDampedSPHSystem(initial_condition;
+ smoothing_kernel,
+ smoothing_length,
+ sound_speed=10.0,
+ density_calculator=SummationDensity(),
+ surface_tension=SurfaceTensionAkinci())
+
+ full_akinci = WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel,
+ smoothing_length,
+ density_calculator=SummationDensity(),
+ state_equation=StateEquationCole(sound_speed=10.0,
+ reference_density=1.0,
+ exponent=1),
+ surface_tension=SurfaceTensionAkinci(),
+ reference_particle_spacing=1.0)
+ @test full_akinci.surface_normal_method isa ColorfieldSurfaceNormal
+ @test haskey(full_akinci.cache, :surface_normal)
+ end
+
+ @testset "surface tension time-step restriction" begin
+ function calculate_initial_dt(surface_tension)
+ initial_condition = InitialCondition(; coordinates=[0.0 1.0;
+ 0.0 0.0;
+ 0.0 0.0],
+ density=ones(2), particle_spacing=1.0)
+ reference_particle_spacing = isnothing(surface_tension) ? 0 : 1.0
+ system = WeaklyCompressibleSPHSystem(initial_condition;
+ smoothing_kernel=WendlandC2Kernel{3}(),
+ smoothing_length=1.0,
+ density_calculator=ContinuityDensity(),
+ state_equation=StateEquationCole(sound_speed=10.0,
+ reference_density=1.0,
+ exponent=1),
+ surface_tension,
+ reference_particle_spacing)
+ semi = Semidiscretization(system)
+ ode = semidiscretize(semi, (0.0, 0.1))
+ v_ode, u_ode = ode.u0.x
+ TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, 0.0)
+ return TrixiParticles.calculate_dt(v_ode, u_ode, 0.25, semi.systems[1], semi)
+ end
+
+ dt_without_surface_tension = calculate_initial_dt(nothing)
+ dt_with_zero_csf = calculate_initial_dt(SurfaceTensionMorris(;
+ surface_tension_coefficient=0.0))
+ dt_with_zero_css = calculate_initial_dt(SurfaceTensionMomentumMorris(;
+ surface_tension_coefficient=0.0))
+ dt_with_zero_physical = calculate_initial_dt(SurfaceTensionAkinciCohesionPhysical(;
+ surface_tension_coefficient=0.0,
+ reference_density=1.0))
+
+ @test dt_with_zero_csf == dt_without_surface_tension
+ @test dt_with_zero_css == dt_without_surface_tension
+ @test dt_with_zero_physical == dt_without_surface_tension
+
+ physical = SurfaceTensionAkinciCohesionPhysical(;
+ surface_tension_coefficient=1000.0,
+ reference_density=1.0)
+ @test calculate_initial_dt(physical) โ sqrt(1 / (2pi * 1000))
+ for model in (SurfaceTensionMorris(; surface_tension_coefficient=1000.0),
+ SurfaceTensionMomentumMorris(;
+ surface_tension_coefficient=1000.0))
+ @test calculate_initial_dt(model) โ sqrt(1 / (2pi * 1000))
+ end
+ end
+
@testset verbose=true "`cohesion_force_akinci`" begin
surface_tension = SurfaceTensionAkinci(surface_tension_coefficient=1.0)
support_radius = 1.0
@@ -10,35 +793,40 @@
# Additional digits have been accepted from the actual calculation.
test_distance = 0.1
val = TrixiParticles.cohesion_force_akinci(surface_tension, support_radius, m_b,
- pos_diff, test_distance) * test_distance
+ pos_diff, test_distance, Val(3)) *
+ test_distance
@test isapprox(val[1], 0.1443038770421044, atol=6e-15)
@test isapprox(val[2], 0.1443038770421044, atol=6e-15)
# Maximum repulsion force
test_distance = 0.01
max = TrixiParticles.cohesion_force_akinci(surface_tension, support_radius, m_b,
- pos_diff, test_distance) * test_distance
+ pos_diff, test_distance, Val(3)) *
+ test_distance
@test isapprox(max[1], 0.15913517632298307, atol=6e-15)
@test isapprox(max[2], 0.15913517632298307, atol=6e-15)
# Near 0
test_distance = 0.2725
zero = TrixiParticles.cohesion_force_akinci(surface_tension, support_radius, m_b,
- pos_diff, test_distance) * test_distance
+ pos_diff, test_distance, Val(3)) *
+ test_distance
@test isapprox(zero[1], 0.0004360543645195717, atol=6e-15)
@test isapprox(zero[2], 0.0004360543645195717, atol=6e-15)
# Maximum attraction force
test_distance = 0.5
maxa = TrixiParticles.cohesion_force_akinci(surface_tension, support_radius, m_b,
- pos_diff, test_distance) * test_distance
+ pos_diff, test_distance, Val(3)) *
+ test_distance
@test isapprox(maxa[1], -0.15915494309189535, atol=6e-15)
@test isapprox(maxa[2], -0.15915494309189535, atol=6e-15)
# Should be 0
test_distance = 1.0
zero = TrixiParticles.cohesion_force_akinci(surface_tension, support_radius, m_b,
- pos_diff, test_distance) * test_distance
+ pos_diff, test_distance, Val(3)) *
+ test_distance
@test isapprox(zero[1], 0.0, atol=6e-15)
@test isapprox(zero[2], 0.0, atol=6e-15)
end
@@ -53,14 +841,14 @@
# Additional digits have been accepted from the actual calculation.
test_distance = 0.1
zero = TrixiParticles.adhesion_force_akinci(surface_tension, support_radius, m_b,
- pos_diff, test_distance, 1.0) *
+ pos_diff, test_distance, 1.0, Val(3)) *
test_distance
@test isapprox(zero[1], 0.0, atol=6e-15)
@test isapprox(zero[2], 0.0, atol=6e-15)
test_distance = 0.5
zero = TrixiParticles.adhesion_force_akinci(surface_tension, support_radius, m_b,
- pos_diff, test_distance, 1.0) *
+ pos_diff, test_distance, 1.0, Val(3)) *
test_distance
@test isapprox(zero[1], 0.0, atol=6e-15)
@test isapprox(zero[2], 0.0, atol=6e-15)
@@ -68,7 +856,7 @@
# Near 0
test_distance = 0.51
zero = TrixiParticles.adhesion_force_akinci(surface_tension, support_radius, m_b,
- pos_diff, test_distance, 1.0) *
+ pos_diff, test_distance, 1.0, Val(3)) *
test_distance
@test isapprox(zero[1], -0.002619160170741761, atol=6e-15)
@test isapprox(zero[2], -0.002619160170741761, atol=6e-15)
@@ -76,7 +864,7 @@
# Maximum adhesion force
test_distance = 0.75
max = TrixiParticles.adhesion_force_akinci(surface_tension, support_radius, m_b,
- pos_diff, test_distance, 1.0) *
+ pos_diff, test_distance, 1.0, Val(3)) *
test_distance
@test isapprox(max[1], -0.004949747468305833, atol=6e-15)
@test isapprox(max[2], -0.004949747468305833, atol=6e-15)
@@ -84,102 +872,627 @@
# Should be 0
test_distance = 1.0
zero = TrixiParticles.adhesion_force_akinci(surface_tension, support_radius, m_b,
- pos_diff, test_distance, 1.0) *
+ pos_diff, test_distance, 1.0, Val(3)) *
test_distance
@test isapprox(zero[1], 0.0, atol=6e-15)
@test isapprox(zero[2], 0.0, atol=6e-15)
+
+ support_radius_f32 = 15.594092f0
+ distance_f32 = prevfloat(support_radius_f32)
+ near_support = TrixiParticles.adhesion_force_akinci(surface_tension,
+ support_radius_f32, 1.0f0,
+ Float32[1, 0], distance_f32,
+ 1.0f0, Val(3))
+ @test eltype(near_support) == Float32
+ @test all(isfinite, near_support)
+ @test 0 < norm(near_support) < eps(Float32)
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)
+ @testset "two-dimensional Akinci kernels" begin
+ surface_tension = SurfaceTensionAkinci(surface_tension_coefficient=1.0)
+ support_radius = 1.0
+ cohesion_normalization = 25280 / (627 * pi)
+
+ for distance in (0.25, 0.75)
+ pos_diff = SVector(distance, 0.0)
+ shape = if distance > 0.5 * support_radius
+ (support_radius - distance)^3 * distance^3
+ else
+ 2 * (support_radius - distance)^3 * distance^3 - support_radius^6 / 64
+ end
+ expected = -cohesion_normalization * shape * pos_diff / distance
+ force = TrixiParticles.cohesion_force_akinci(surface_tension, support_radius,
+ 1.0, pos_diff, distance, Val(2))
+ @test isapprox(force, expected; rtol=5eps(), atol=5eps())
+ end
+
+ distance = 0.75
+ pos_diff = SVector(distance, 0.0)
+ radicand = -4 * distance^2 / support_radius + 6 * distance -
+ 2 * support_radius
+ expected = -(13 / 1200) * radicand^(1 / 4) * pos_diff / distance
+ force = TrixiParticles.adhesion_force_akinci(surface_tension, support_radius, 1.0,
+ pos_diff, distance, 1.0, Val(2))
+ @test isapprox(force, expected; rtol=5eps(), atol=5eps())
+
+ surface_tension_f32 = SurfaceTensionAkinci(surface_tension_coefficient=1.0f0)
+ distance_f32 = 0.75f0
+ pos_diff_f32 = SVector(distance_f32, 0.0f0)
+ cohesion_f32 = TrixiParticles.cohesion_force_akinci(surface_tension_f32, 1.0f0,
+ 1.0f0, pos_diff_f32,
+ distance_f32, Val(2))
+ adhesion_f32 = TrixiParticles.adhesion_force_akinci(surface_tension_f32, 1.0f0,
+ 1.0f0, pos_diff_f32,
+ distance_f32, 1.0f0, Val(2))
+ @test eltype(cohesion_f32) == Float32
+ @test eltype(adhesion_f32) == Float32
+ @test all(isfinite, cohesion_f32)
+ @test all(isfinite, adhesion_f32)
+ end
+
+ @testset "Akinci kernel resolution scaling" begin
+ surface_tension = SurfaceTensionAkinci(surface_tension_coefficient=0.8)
+ adhesion_coefficient = 0.6
+
+ function forces(scale, dimensions::Val{NDIMS}) where {NDIMS}
+ support_radius = scale
+ distance = 0.75 * support_radius
+ pos_diff = SVector{NDIMS}(ntuple(i -> i == 1 ? distance : zero(distance),
+ NDIMS))
+ mass = scale^NDIMS
+ cohesion = TrixiParticles.cohesion_force_akinci(surface_tension,
+ support_radius, mass,
+ pos_diff, distance, dimensions)
+ adhesion = TrixiParticles.adhesion_force_akinci(surface_tension,
+ support_radius, mass,
+ pos_diff, distance,
+ adhesion_coefficient,
+ dimensions)
+ return cohesion, adhesion
+ end
+
+ for dimensions in (Val(2), Val(3))
+ reference_cohesion, reference_adhesion = forces(1.0, dimensions)
+ for scale in (0.25, 0.5, 2.0, 4.0)
+ cohesion, adhesion = forces(scale, dimensions)
+ @test isapprox(cohesion, reference_cohesion; rtol=5eps(), atol=5eps())
+ @test isapprox(adhesion, reference_adhesion; rtol=5eps(), atol=5eps())
+ end
+ end
+ end
+
+ @testset "Akinci kernel integral matching" begin
+ surface_tension = SurfaceTensionAkinci(surface_tension_coefficient=1.0)
+ support_radius = 1.3
+
+ function pos_diff_at_radius(radius, ::Val{NDIMS}) where {NDIMS}
+ return SVector{NDIMS}(ntuple(i -> i == 1 ? radius : zero(radius), NDIMS))
+ end
- ic = InitialCondition(; coordinates=coords, velocity, mass, density,
- particle_spacing=1.0)
+ function integrate_cohesion(dimensions::Val{NDIMS}) where {NDIMS}
+ radial_integral,
+ _ = quadgk(0.0, support_radius / 2, support_radius;
+ rtol=1e-13) do radius
+ pos_diff = pos_diff_at_radius(radius, dimensions)
+ force = TrixiParticles.cohesion_force_akinci(surface_tension,
+ support_radius, 1.0,
+ pos_diff, radius, dimensions)
+ return radius^(NDIMS - 1) * -force[1]
+ end
+ surface_measure = NDIMS == 2 ? 2pi : 4pi
+ return surface_measure * radial_integral
+ end
- # 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}()
+ function integrate_adhesion(dimensions::Val{NDIMS}) where {NDIMS}
+ radial_integral,
+ _ = quadgk(support_radius / 2, support_radius;
+ rtol=1e-13) do radius
+ pos_diff = pos_diff_at_radius(radius, dimensions)
+ force = TrixiParticles.adhesion_force_akinci(surface_tension,
+ support_radius, 1.0,
+ pos_diff, radius, 1.0,
+ dimensions)
+ return radius^(NDIMS - 1) * -force[1]
+ end
+ surface_measure = NDIMS == 2 ? 2pi : 4pi
+ return surface_measure * radial_integral
+ end
+
+ cohesion_2d = integrate_cohesion(Val(2))
+ cohesion_3d = integrate_cohesion(Val(3))
+ @test isapprox(cohesion_2d, 79 / 336; rtol=1e-12)
+ @test isapprox(cohesion_3d, 79 / 336; rtol=1e-12)
+ @test isapprox(integrate_adhesion(Val(2)), integrate_adhesion(Val(3));
+ rtol=1e-12)
+ end
+
+ @testset "physical Akinci cohesion" begin
+ smoothing_kernel = WendlandC2Kernel{3}()
smoothing_length = 0.5
+ support_radius = TrixiParticles.compact_support(smoothing_kernel, smoothing_length)
+ reference_density = 2.0
+ surface_tension = SurfaceTensionAkinciCohesionPhysical(;
+ surface_tension_coefficient=0.3,
+ reference_density)
+ internal_coefficient = 0.3 /
+ ((21 / 7040) * reference_density^2 * support_radius^2)
+ @test TrixiParticles.akinci_physical_cohesion_coefficient(surface_tension,
+ support_radius) โ
+ internal_coefficient
+ @test TrixiParticles.akinci_physical_cohesion_coefficient(surface_tension,
+ support_radius / 2) โ
+ 4internal_coefficient
+
+ initial_condition = InitialCondition(;
+ coordinates=[0.0 0.75; 0.0 0.0; 0.0 0.0],
+ velocity=zeros(3, 2),
+ mass=ones(2),
+ density=fill(reference_density, 2),
+ particle_spacing=0.5)
+ system = WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel,
+ smoothing_length,
+ density_calculator=ContinuityDensity(),
+ state_equation=StateEquationCole(;
+ sound_speed=10.0,
+ reference_density,
+ exponent=1),
+ surface_tension)
+ @test isnothing(system.surface_normal_method)
+ @test !haskey(system.cache, :surface_normal)
+
+ pos_diff = SVector(-0.75, 0.0, 0.0)
+ distance = norm(pos_diff)
+ correction = 1.25
+ dv_a = Ref(zero(pos_diff))
+ TrixiParticles.surface_tension_force!(dv_a, surface_tension, surface_tension,
+ system, system, 1, 2, pos_diff, distance,
+ reference_density, reference_density,
+ zero(pos_diff), correction)
+ empirical = CohesionForceAkinci(;
+ surface_tension_coefficient=internal_coefficient)
+ expected = correction *
+ TrixiParticles.cohesion_force_akinci(empirical, support_radius, 1.0,
+ pos_diff, distance, Val(3))
+ @test isapprox(dv_a[], expected; rtol=2eps(), atol=2eps())
+
+ dv_b = Ref(zero(pos_diff))
+ TrixiParticles.surface_tension_force!(dv_b, surface_tension, surface_tension,
+ system, system, 2, 1, -pos_diff, distance,
+ reference_density, reference_density,
+ zero(pos_diff), correction)
+ @test isapprox(dv_a[], -dv_b[]; rtol=2eps(), atol=2eps())
+
+ boundary_condition = InitialCondition(;
+ coordinates=reshape([0.75, 0.0, 0.0], 3, 1),
+ mass=[2.0],
+ density=[reference_density],
+ particle_spacing=0.5)
+ boundary_model = BoundaryModelDummyParticles(boundary_condition;
+ fluid_system=system)
+ boundary_system = WallBoundarySystem(boundary_condition, boundary_model;
+ adhesion_coefficient=0.5)
+ dv_wall = Ref(zero(pos_diff))
+ TrixiParticles.adhesion_force!(dv_wall, surface_tension, system, boundary_system,
+ 1, 1, pos_diff, distance)
+ wall_model = CohesionForceAkinci(;
+ surface_tension_coefficient=0.5internal_coefficient)
+ expected_wall = TrixiParticles.cohesion_force_akinci(wall_model, support_radius,
+ 2.0, pos_diff, distance,
+ Val(3))
+ @test isapprox(dv_wall[], expected_wall; rtol=2eps(), atol=2eps())
- # 3. Create the WeaklyCompressibleSPHSystem with Surface Tension
- system = WeaklyCompressibleSPHSystem(ic; smoothing_kernel=kernel,
+ rigid_system = RigidBodySystem(boundary_condition; boundary_model,
+ adhesion_coefficient=0.5)
+ dv_rigid = Ref(zero(pos_diff))
+ TrixiParticles.adhesion_force!(dv_rigid, surface_tension, system, rigid_system,
+ 1, 1, pos_diff, distance)
+ @test isapprox(dv_rigid[], expected_wall; rtol=2eps(), atol=2eps())
+ end
+
+ @testset "complete Akinci pair force" begin
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ smoothing_length = 1.0
+ surface_tension = SurfaceTensionAkinci(surface_tension_coefficient=0.7)
+ initial_condition = InitialCondition(; coordinates=[0.0 1.5; 0.0 0.0],
+ velocity=zeros(2, 2), mass=ones(2),
+ density=ones(2), particle_spacing=1.0)
+ system = WeaklyCompressibleSPHSystem(initial_condition; smoothing_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
+ density_calculator=ContinuityDensity(),
+ state_equation=StateEquationCole(sound_speed=10.0,
+ reference_density=1.0,
+ exponent=1),
+ surface_tension,
+ reference_particle_spacing=1.0)
+ system.cache.surface_normal[:, 1] .= (0.2, -0.1)
+ system.cache.surface_normal[:, 2] .= (-0.3, 0.4)
+
+ pos_diff = SVector(-1.5, 0.0)
+ distance = norm(pos_diff)
+ correction = 1.25
+ dv_a = Ref(zero(pos_diff))
+ TrixiParticles.surface_tension_force!(dv_a, surface_tension, surface_tension,
+ system, system, 1, 2, pos_diff, distance,
+ 1.0, 1.0, zero(pos_diff), correction)
+
+ support_radius = TrixiParticles.compact_support(smoothing_kernel, smoothing_length)
+ normal_a = support_radius * SVector(0.2, -0.1)
+ normal_b = support_radius * SVector(-0.3, 0.4)
+ expected = correction *
+ (TrixiParticles.cohesion_force_akinci(surface_tension,
+ support_radius, 1.0,
+ pos_diff, distance, Val(2)) -
+ surface_tension.surface_tension_coefficient *
+ (normal_a - normal_b))
+ @test isapprox(dv_a[], expected; rtol=2eps(), atol=2eps())
+
+ dv_b = Ref(zero(pos_diff))
+ TrixiParticles.surface_tension_force!(dv_b, surface_tension, surface_tension,
+ system, system, 2, 1, -pos_diff, distance,
+ 1.0, 1.0, zero(pos_diff), correction)
+ @test isapprox(dv_a[], -dv_b[]; rtol=2eps(), atol=2eps())
+ end
+
+ @testset "Akinci free-surface correction" begin
+ correction = AkinciFreeSurfaceCorrection(1000.0)
+ @test TrixiParticles.free_surface_correction(correction, nothing, 1000.0,
+ 1000.0) == (1.0, 1, 1.0)
+ expected = 1000.0 / ((500.0 + 1000.0) / 2)
+ viscosity, pressure,
+ surface_tension = TrixiParticles.free_surface_correction(correction, nothing,
+ 500.0, 1000.0)
+ @test viscosity == expected
+ @test pressure == 1
+ @test surface_tension == expected
+ @test TrixiParticles.free_surface_correction(correction, nothing, 1000.0,
+ 500.0) == (expected, 1, expected)
+ end
+
+ @testset "Akinci ContinuityDensity reconstruction" begin
+ particle_spacing = 1.0
+ rho0 = 1000.0
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ state_equation = StateEquationCole(sound_speed=10.0, reference_density=rho0,
+ exponent=1)
+ correction = AkinciFreeSurfaceCorrection(rho0)
+ fluid = RectangularShape(particle_spacing, (7, 7), (0.0, 0.0); density=rho0)
+
+ function correction_density_values(density_calculator)
+ system = WeaklyCompressibleSPHSystem(fluid; smoothing_kernel,
+ smoothing_length=particle_spacing,
+ density_calculator, state_equation,
+ correction)
+ 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)
+ density = GC.@preserve v_ode begin
+ v = TrixiParticles.wrap_v(v_ode, system, semi)
+ collect(TrixiParticles.current_density(v, system))
+ end
+ correction_density = [TrixiParticles.correction_density(correction, system,
+ particle,
+ density[particle])
+ for particle in TrixiParticles.eachparticle(system)]
+ return system, density, correction_density
+ end
+
+ continuity_system, continuity_density,
+ continuity_correction_density = correction_density_values(ContinuityDensity())
+ _, summation_density,
+ summation_correction_density = correction_density_values(SummationDensity())
+
+ @test all(==(rho0), continuity_density)
+ @test isapprox(continuity_system.cache.kernel_summation_density,
+ summation_density; rtol=2eps())
+ @test isapprox(continuity_correction_density,
+ summation_correction_density; rtol=2eps())
+
+ coordinates = fluid.coordinates
+ particle_at(position) = findfirst(particle -> coordinates[:, particle] == position,
+ axes(coordinates, 2))
+ center = particle_at([3.5, 3.5])
+ face = particle_at([3.5, 0.5])
+ corner = particle_at([0.5, 0.5])
+ k = rho0 ./ continuity_correction_density
+
+ @test isapprox(k[center], 1; atol=0.002)
+ @test k[face] > 1.15
+ @test k[corner] > k[face]
+
+ # Dummy boundary masses complete the kernel sum at a wall, so wall particles are
+ # not mistaken for a free surface by the reconstructed density.
+ tank = RectangularTank(particle_spacing, (7.0, 5.0), (7.0, 8.0), rho0;
+ n_layers=2, faces=(false, false, true, false))
+ wall_system = WeaklyCompressibleSPHSystem(tank.fluid; smoothing_kernel,
+ smoothing_length=particle_spacing,
+ density_calculator=ContinuityDensity(),
+ state_equation, correction)
+ boundary_model = BoundaryModelDummyParticles(tank.boundary;
+ fluid_system=wall_system,
+ boundary_density_calculator=AdamiPressureExtrapolation())
+ boundary_system = WallBoundarySystem(tank.boundary, boundary_model)
+ wall_semi = Semidiscretization(wall_system, boundary_system)
+ wall_ode = semidiscretize(wall_semi, (0.0, 0.01))
+ TrixiParticles.update_systems_and_nhs(wall_ode.u0.x..., wall_semi, 0.0)
+
+ wall_coordinates = tank.fluid.coordinates
+ wall_particle_at(position) = findfirst(particle -> wall_coordinates[:, particle] ==
+ position,
+ axes(wall_coordinates, 2))
+ bottom = wall_particle_at([3.5, 0.5])
+ interior = wall_particle_at([3.5, 2.5])
+ top = wall_particle_at([3.5, 4.5])
+ reconstructed_density = wall_system.cache.kernel_summation_density
+ wall_k = rho0 ./ reconstructed_density
+
+ @test isapprox(wall_k[bottom], wall_k[interior]; rtol=2eps())
+ @test isapprox(wall_k[interior], 1; atol=0.002)
+ @test wall_k[top] > 1.15
+ end
+
+ @testset "Akinci pipeline force assembly" begin
+ # End-to-end verification: the surface normals are computed by the actual update
+ # pipeline (not injected into the cache) and the resulting fluid-fluid RHS
+ # contribution is compared against equations 1-5 of Akinci et al. (2013),
+ # implemented independently below.
+ particle_spacing = 1.0
+ # The compact support radius 2.2 lies strictly between the lattice distances
+ # 2 and sqrt(5), so the pair set is unambiguous, and every particle of the
+ # 4^3 block keeps at least 2^3 + 1 = 9 neighbors, so no normal is filtered.
+ smoothing_length = 1.1 * particle_spacing
+ smoothing_kernel = SchoenbergCubicSplineKernel{3}()
+ support_radius = TrixiParticles.compact_support(smoothing_kernel, smoothing_length)
+
+ coordinates = RectangularShape(particle_spacing, (4, 4, 4), (0.0, 0.0, 0.0);
+ density=1000.0).coordinates
+ n_particles = size(coordinates, 2)
+ rho0 = 1000.0
+ # Perturb the densities so that the free-surface correction K_ij deviates from one
+ density = rho0 .+ 40 .* sin.(range(0, 2pi, length=n_particles))
+ mass = fill(rho0 * particle_spacing^3, n_particles)
+ surface_tension_coefficient = 0.7
+ state_equation = StateEquationCole(sound_speed=10.0, reference_density=rho0,
+ exponent=7)
+
+ # Note that all variables of this closure must not be assigned anywhere in the
+ # enclosing test sets. Otherwise, the closure captures and overwrites them.
+ function fluid_fluid_dv(surface_tension_model, correction_model)
+ fluid_ic = InitialCondition(; coordinates,
+ velocity=zeros(3, n_particles),
+ mass, density, particle_spacing)
+ fluid_sys = WeaklyCompressibleSPHSystem(fluid_ic; smoothing_kernel,
+ smoothing_length,
+ density_calculator=ContinuityDensity(),
+ state_equation,
+ surface_tension=surface_tension_model,
+ correction=correction_model,
+ reference_particle_spacing=particle_spacing)
+ semi_ = Semidiscretization(fluid_sys)
+ 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)
+ # `wrap_v` and `wrap_u` return raw-pointer arrays. Inside a function, the ODE
+ # vectors must be preserved manually, since the garbage collector might
+ # otherwise free them after their last syntactic use.
+ dv_ = GC.@preserve v_ode_ u_ode_ begin
+ v_ = TrixiParticles.wrap_v(v_ode_, fluid_sys, semi_)
+ u_ = TrixiParticles.wrap_u(u_ode_, fluid_sys, semi_)
+ dv_inner = zeros(eltype(v_), size(v_))
+ TrixiParticles.interact!(dv_inner, v_, u_, v_, u_, fluid_sys, fluid_sys,
+ semi_)
+ dv_inner
+ end
+ return fluid_sys, dv_
+ end
+
+ system_akinci,
+ dv_akinci = fluid_fluid_dv(SurfaceTensionAkinci(; surface_tension_coefficient),
+ AkinciFreeSurfaceCorrection(rho0))
+ _, dv_without = fluid_fluid_dv(nothing, nothing)
+
+ # The free-surface correction does not modify the pressure force and no viscosity
+ # is used, so the difference of the two right-hand sides isolates the cohesion,
+ # curvature and K_ij contributions of the Akinci model.
+ dv_surface_tension = (dv_akinci-dv_without)[1:3, :]
+
+ # No normal may have been removed by the neighbor-count filter
+ @test all(>=(2^3 + 1), system_akinci.cache.neighbor_count)
+
+ # Independent references for the colorfield gradient of section 2.2 and the
+ # auxiliary summation density used by the ContinuityDensity extension.
+ gradients = zeros(3, n_particles)
+ kernel_summation_density = zeros(n_particles)
+ for a in 1:n_particles, b in 1:n_particles
+ pos_diff = SVector{3}(coordinates[:, a] - coordinates[:, b])
+ distance = norm(pos_diff)
+ kernel_summation_density[a] += mass[b] *
+ TrixiParticles.kernel(smoothing_kernel, distance,
+ smoothing_length)
+ (distance < eps() || distance > support_radius) && continue
+ grad = TrixiParticles.kernel_grad(smoothing_kernel, pos_diff, distance,
+ smoothing_length)
+ gradients[:, a] .+= mass[b] / density[b] .* grad
+ end
+
+ # The pipeline stores the unscaled colorfield gradient
+ @test isapprox(gradients, system_akinci.cache.surface_normal; atol=1e-12)
+ @test isapprox(kernel_summation_density,
+ system_akinci.cache.kernel_summation_density; rtol=2eps())
+
+ # Independent reference for equations 1-5. The normal of equation 2 is the
+ # gradient scaled with the compact support radius.
+ dv_expected = zeros(3, n_particles)
+ for a in 1:n_particles, b in 1:n_particles
+ pos_diff = SVector{3}(coordinates[:, a] - coordinates[:, b])
+ distance = norm(pos_diff)
+ (distance < eps() || distance > support_radius) && continue
+ correction_factor = 2 * rho0 /
+ (kernel_summation_density[a] +
+ kernel_summation_density[b])
+ cohesion_kernel = if 2 * distance > support_radius
+ (support_radius - distance)^3 * distance^3
+ else
+ 2 * (support_radius - distance)^3 * distance^3 - support_radius^6 / 64
+ end
+ cohesion_kernel *= 32 / (pi * support_radius^9)
+ normal_difference = support_radius .* (gradients[:, a] - gradients[:, b])
+ dv_expected[:,
+ a] .+= correction_factor .*
+ (-surface_tension_coefficient .* mass[b] .*
+ cohesion_kernel .* pos_diff ./ distance .-
+ surface_tension_coefficient .* normal_difference)
+ end
+
+ @test maximum(abs, dv_expected) > 0
+ @test isapprox(dv_surface_tension, dv_expected; rtol=1e-8,
+ atol=1e-9 * maximum(abs, dv_expected))
+ end
+
+ @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)
+ @test !haskey(system.cache, :boundary_normal)
+
+ # The surface delta must be captured before the color gradient is normalized.
+ system.cache.surface_normal .= [2.0 1.0; 0.0 1.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.interface_activity .= [0.25, 0.75]
+ 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[]
+
+ 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[])
+ 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.9),
+ 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
diff --git a/test/setups/complex_shape.jl b/test/setups/complex_shape.jl
index 2917661225..ca6b879cb1 100644
--- a/test/setups/complex_shape.jl
+++ b/test/setups/complex_shape.jl
@@ -2,6 +2,44 @@
data_dir = pkgdir(TrixiParticles, "examples", "preprocessing", "data")
validation_dir = pkgdir(TrixiParticles, "test", "preprocessing", "data")
+ @testset verbose=true "Sample Boundary" begin
+ particle_spacing = 0.1
+ positions = [
+ SVector(0.0, 0.0),
+ SVector(0.1, 0.0),
+ SVector(0.2, 0.0),
+ SVector(0.3, 0.0),
+ SVector(0.4, 0.0)
+ ]
+ distances = [0.01, 0.05, 0.1, 0.2, 0.3]
+
+ signed_distance_field = (; positions, distances, particle_spacing,
+ boundary_packing=true, max_signed_distance=0.3)
+
+ boundary = sample_boundary(signed_distance_field; boundary_density=1.0,
+ boundary_thickness=0.2, place_on_shell=false)
+ @test boundary.coordinates โ stack(positions[2:4])
+
+ boundary = sample_boundary(signed_distance_field; boundary_density=1.0,
+ boundary_thickness=0.2, place_on_shell=true)
+ @test boundary.coordinates โ stack(positions[3:4])
+
+ @test_throws ArgumentError sample_boundary(signed_distance_field;
+ boundary_density=1.0,
+ boundary_thickness=0.04,
+ place_on_shell=false)
+
+ too_thin_sdf = (; positions, distances, particle_spacing,
+ boundary_packing=true, max_signed_distance=0.1)
+ @test_throws ArgumentError sample_boundary(too_thin_sdf; boundary_density=1.0,
+ boundary_thickness=0.2)
+
+ not_boundary_sdf = (; positions, distances, particle_spacing,
+ boundary_packing=false, max_signed_distance=0.3)
+ @test_throws ArgumentError sample_boundary(not_boundary_sdf; boundary_density=1.0,
+ boundary_thickness=0.2)
+ end
+
@testset verbose=true "2D" begin
@testset verbose=true "Shifted Rectangle" begin
algorithms = [
@@ -41,7 +79,7 @@
end
@testset verbose=true "Real World Data" begin
- files = ["hexagon", "circle", "inverted_open_curve"]
+ files = ["hexagon", "circle"]
algorithms = [
WindingNumberHormann(),
WindingNumberJacobson(; hierarchical_winding=false)
@@ -72,7 +110,8 @@
# See https://docs.julialang.org/en/v1/base/base/#var%22name%22
coords = vcat((data.var"Points:0")', (data.var"Points:1")')
- geometry = load_geometry(joinpath(data_dir, files[j] * ".asc"))
+ geometry = load_geometry(joinpath(data_dir, files[j] * ".asc");
+ close_curve=true)
shape_sampled = ComplexShape(geometry; particle_spacing=0.05,
density=1.0, point_in_geometry_algorithm)
@@ -82,6 +121,15 @@
end
end
+ @testset verbose=true "Open Geometry Validation" begin
+ open_square = [0.0 1.0 1.0 0.0;
+ 0.0 0.0 1.0 1.0]
+ geometry = TrixiParticles.Polygon(open_square; close_curve=false)
+
+ @test_throws ArgumentError ComplexShape(geometry; particle_spacing=0.1,
+ density=1.0)
+ end
+
@testset verbose=true "Intersect of Overlapping Shapes and Geometries" begin
shape = RectangularShape(0.1, (10, 10), (0.0, 0.0), density=1.0)
geometry = load_geometry(joinpath(data_dir, "circle.asc"))
diff --git a/test/setups/extrude_geometry.jl b/test/setups/extrude_geometry.jl
index 4146ab6dd6..b61b14549b 100644
--- a/test/setups/extrude_geometry.jl
+++ b/test/setups/extrude_geometry.jl
@@ -47,6 +47,28 @@
@test shape.coordinates โ expected_coords
end
+
+ @testset verbose=true "Errors" begin
+ point1 = [0.0, 0.0]
+ point2 = [0.0, 1.0]
+
+ @test_throws ArgumentError extrude_geometry((point1, point2);
+ direction=[0.0, 0.0],
+ particle_spacing=0.1,
+ n_extrude=1, density=1.0)
+ @test_nowarn extrude_geometry((point1, point2);
+ direction=[1e-20, 0.0],
+ particle_spacing=0.1,
+ n_extrude=1, density=1.0)
+ @test_throws ArgumentError extrude_geometry((point1, point2);
+ direction=[1.0, 0.0],
+ particle_spacing=0.1,
+ n_extrude=0, density=1.0)
+ @test_throws ArgumentError extrude_geometry((point1, point2);
+ direction=[0.0, 0.0, 1.0],
+ particle_spacing=0.1,
+ n_extrude=1, density=1.0)
+ end
end
# 3D
diff --git a/test/setups/rectangular_shape.jl b/test/setups/rectangular_shape.jl
index 4e2419487f..aae2e8a290 100644
--- a/test/setups/rectangular_shape.jl
+++ b/test/setups/rectangular_shape.jl
@@ -48,6 +48,33 @@
@test shape.coordinates == expected_coords[i]
end
end
+
+ @testset "Function Density" begin
+ shape = RectangularShape(0.1, (2, 1), (0.0, 0.0),
+ density=coords -> 1000.0 + coords[1])
+
+ @test shape.density โ [1000.05, 1000.15]
+ end
+
+ @testset "Coordinates Perturbation Does Not Reset Random State" begin
+ Random.seed!(42)
+ first_random_number = rand()
+ next_random_number = rand()
+
+ Random.seed!(42)
+ @test rand() == first_random_number
+
+ RectangularShape(0.1, (2, 2), (0.0, 0.0), density=1.0,
+ coordinates_perturbation=0.1)
+
+ @test rand() == next_random_number
+ end
+
+ @testset "Errors" begin
+ @test_throws ArgumentError RectangularShape(0.1, (2, 2), (0.0, 0.0),
+ density=1000.0,
+ acceleration=(0.0, -9.81, 0.0))
+ end
end
# Only show all of these nested testsets in case of errors
@@ -123,6 +150,26 @@
@test shape.pressure โ 4.71 * 1000.0 * vec(reverse(pressure'))
end
end
+
+ @testset "Function Density" begin
+ density_function = coords -> 1000.0 + 100coords[1] + 10coords[2]
+ shape = RectangularShape(particle_spacing, (2, 3), (0.0, 0.0),
+ density=density_function,
+ acceleration=(0.0, -1.0))
+
+ @test shape.density โ [1005.5, 1015.5, 1006.5, 1016.5, 1007.5, 1017.5]
+ @test shape.pressure โ [251.775, 254.275, 151.125, 152.625, 50.375,
+ 50.875]
+ @test shape.mass โ particle_spacing^2 * shape.density
+ end
+
+ @testset "Zero Acceleration" begin
+ shape = RectangularShape(particle_spacing, (2, 5), (0.0, 0.0),
+ density=1000.0, acceleration=(0.0, 0.0))
+
+ @test shape.pressure == zeros(10)
+ @test shape.density == 1000 * ones(10)
+ end
end
# Use `@trixi_testset` to isolate the mock functions in a separate namespace
@@ -186,6 +233,16 @@
shape.pressure)
@test shape.mass == particle_spacing^2 * shape.density
end
+
+ @testset "Zero Acceleration" begin
+ shape = RectangularShape(particle_spacing, (2, 5), (0.0, 0.0);
+ acceleration=(0.0, 0.0), state_equation)
+
+ @test shape.pressure == zeros(10)
+ @test shape.density ==
+ TrixiParticles.inverse_state_equation.(Ref(state_equation),
+ shape.pressure)
+ end
end
end
diff --git a/test/setups/rectangular_tank.jl b/test/setups/rectangular_tank.jl
index 1a1fc88508..33775a5873 100644
--- a/test/setups/rectangular_tank.jl
+++ b/test/setups/rectangular_tank.jl
@@ -119,6 +119,43 @@
(water_width, water_height, 0.5),
(tank_width, tank_height),
water_density, spacing_ratio=3)
+
+ error = ArgumentError("`fluid_size` dimensions need to be non-negative")
+ @test_throws error RectangularTank(particle_spacing,
+ (-water_width, water_height),
+ (tank_width, tank_height),
+ water_density)
+
+ error = ArgumentError("`tank_size` dimensions need to be non-negative")
+ @test_throws error RectangularTank(particle_spacing,
+ (water_width, water_height),
+ (-tank_width, tank_height),
+ water_density)
+
+ @test_throws ArgumentError RectangularTank(particle_spacing,
+ (water_width, water_height),
+ (tank_width, tank_height),
+ water_density, spacing_ratio=0)
+
+ @test_throws ArgumentError RectangularTank(particle_spacing,
+ (water_width, water_height),
+ (tank_width, tank_height),
+ water_density, n_layers=0)
+
+ @test_throws ArgumentError RectangularTank(particle_spacing,
+ (water_width, water_height),
+ (tank_width, tank_height),
+ water_density, n_layers=1.5)
+
+ tank = RectangularTank(0.1, (1.0, 1.0), (0.3, 0.3), water_density)
+ @test tank.n_particles_per_dimension == (3, 3)
+ @test all(tank.fluid_size .โ (0.3, 0.3))
+
+ tank = RectangularTank(0.1, (1.0, 1.0), (0.05, 0.3), water_density;
+ acceleration=(1.0, 0.0))
+ @test isempty(tank.fluid.coordinates)
+ @test tank.n_particles_per_dimension == (0, 3)
+ @test all(tank.fluid_size .โ (0.0, 0.3))
end
end
diff --git a/test/setups/sphere_shape.jl b/test/setups/sphere_shape.jl
index c8b87d76db..d94abed713 100644
--- a/test/setups/sphere_shape.jl
+++ b/test/setups/sphere_shape.jl
@@ -79,6 +79,18 @@
end
end
+ @testset verbose=true "Errors" begin
+ @test_throws ArgumentError SphereShape(0.1, 0.5, (0.0, 0.0), 1000.0;
+ cutout_min=(0.2, 0.0),
+ cutout_max=(0.1, 0.1))
+ @test_throws ArgumentError SphereShape(0.1, 0.5, (0.0, 0.0, 0.0),
+ 1000.0; cutout_min=(0.0, 0.0),
+ cutout_max=(0.1, 0.1))
+ @test_nowarn SphereShape(0.1, 0.5, (0.0, 0.0, 0.0),
+ 1000.0; cutout_min=(0.0, 0.0),
+ cutout_max=(0.0, 0.0))
+ end
+
@testset verbose=true "SphereShape 3D" begin
shape_names = [
"1-particle VoxelSphere",
diff --git a/test/systems/boundary_system.jl b/test/systems/boundary_system.jl
index 8c55dfa029..67512a19bb 100644
--- a/test/systems/boundary_system.jl
+++ b/test/systems/boundary_system.jl
@@ -28,6 +28,55 @@
end
end
+ @testset verbose=true "High-level Dummy-Particle Builder" begin
+ boundary_coordinates = [1.0 2.0
+ 1.0 2.0]
+ fluid_coordinates = [0.0 0.5
+ 0.0 0.0]
+
+ boundary_ic = InitialCondition(; coordinates=boundary_coordinates, mass, density)
+ fluid_ic = InitialCondition(; coordinates=fluid_coordinates, mass, density)
+
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ smoothing_length = 0.8
+ state_equation = StateEquationCole(; sound_speed=15.0, reference_density=1000.0,
+ exponent=1)
+ viscosity = ViscosityAdami(nu=1e-6)
+
+ fluid_system = WeaklyCompressibleSPHSystem(fluid_ic;
+ density_calculator=ContinuityDensity(),
+ state_equation, smoothing_kernel,
+ smoothing_length,
+ correction=KernelCorrection(),
+ reference_particle_spacing=0.1)
+
+ boundary_model = BoundaryModelDummyParticles(boundary_ic;
+ fluid_system=fluid_system,
+ viscosity=viscosity)
+ system = WallBoundarySystem(boundary_ic, boundary_model,
+ adhesion_coefficient=0.3,
+ color_value=2)
+
+ @test system isa WallBoundarySystem
+ @test system.boundary_model isa BoundaryModelDummyParticles
+ @test system.boundary_model.hydrodynamic_mass == boundary_ic.mass
+ @test system.boundary_model.density_calculator isa AdamiPressureExtrapolation
+ @test system.boundary_model.smoothing_kernel === smoothing_kernel
+ @test system.boundary_model.smoothing_length == smoothing_length
+ @test system.boundary_model.viscosity == viscosity
+ @test system.boundary_model.state_equation == state_equation
+ @test system.boundary_model.correction isa KernelCorrection
+ @test system.boundary_model.cache.reference_particle_spacing == 0.1
+ @test system.adhesion_coefficient == 0.3
+ @test system.cache.color == 2
+
+ edac_system = EntropicallyDampedSPHSystem(fluid_ic; smoothing_kernel,
+ smoothing_length, sound_speed=15.0)
+ edac_boundary_model = BoundaryModelDummyParticles(boundary_ic;
+ fluid_system=edac_system)
+ @test edac_boundary_model.state_equation === nothing
+ end
+
@testset verbose=true "Moving Boundaries" begin
@testset "$(i+1)D" for i in 1:2
NDIMS = i + 1
diff --git a/test/systems/edac_system.jl b/test/systems/edac_system.jl
index ce04cd6774..33316af9ae 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}()
@@ -244,4 +295,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
diff --git a/test/systems/iisph_system.jl b/test/systems/iisph_system.jl
index 2bcd740552..de38f2cd2c 100644
--- a/test/systems/iisph_system.jl
+++ b/test/systems/iisph_system.jl
@@ -68,6 +68,7 @@
@test system.max_iterations == max_iterations
@test system.time_step == time_step
@test length(system.density) == size(coordinates, 2)
+ @test TrixiParticles.system_state_equation(system) === nothing
# A too-short acceleration vector triggers dimension validation
error_str1 = "`acceleration` must be of length $NDIMS for a $(NDIMS)D problem"
@@ -439,12 +440,12 @@
system_pressure.predicted_density .= [990.0, 1010.0]
system_pressure.sum_term .= [5.0, -2.0]
system_pressure.a_ii .= [0.5, 1.0e-10]
- fill!(system_pressure.density_error, 0.0)
+ system_pressure.density_error .= [0.0, 99.0]
semi = DummySemidiscretization()
# First particle uses standard Jacobi update; second hits the safeguarded zero-a_ii path.
# For particle 1: (1-omega)*0 + omega/a_ii * (source - sum_term) with omega=0.4,
- # source=(1000-990)=10, a_ii=0.5, sum_term=5 gives pressure 4 and density_error -3
+ # source=(1000-990)=10, a_ii=0.5, sum_term=5 gives pressure 4 and abs(density_error) 3
relative_error = TrixiParticles.pressure_update(system_pressure,
system_pressure.pressure,
system_pressure.reference_density,
@@ -454,9 +455,50 @@
system_pressure.density_error,
semi)
- @test isapprox(relative_error, -0.003)
+ @test isapprox(relative_error, 0.003)
@test isapprox(system_pressure.pressure, [4.0, 0.0])
- @test isapprox(system_pressure.density_error, [-3.0, 0.0])
+ @test isapprox(system_pressure.density_error, [3.0, 0.0])
+ end
+
+ @testset "Cross-system pressure sums use neighbor coordinates" begin
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ smoothing_length = 0.5
+ time_step = 0.5
+
+ coordinates_a = reshape([0.0, 0.0], 2, 1)
+ ic_a = InitialCondition(; coordinates=coordinates_a,
+ velocity=zeros(2, 1),
+ mass=[1.0],
+ density=[1000.0],
+ pressure=[1.0])
+ system = ImplicitIncompressibleSPHSystem(ic_a;
+ smoothing_kernel,
+ smoothing_length,
+ reference_density=1000.0,
+ time_step)
+
+ coordinates_b = [0.1 0.2
+ 0.0 0.0]
+ ic_b = InitialCondition(; coordinates=coordinates_b,
+ velocity=zeros(2, 2),
+ mass=[1.0, 1.0],
+ density=[1000.0, 1000.0],
+ pressure=[1.0, 2.0])
+ neighbor_system = ImplicitIncompressibleSPHSystem(ic_b;
+ smoothing_kernel,
+ smoothing_length,
+ reference_density=1000.0,
+ time_step)
+
+ semi = Semidiscretization(system, neighbor_system)
+ TrixiParticles.initialize_neighborhood_searches!(semi)
+ u_ode = vcat(vec(coordinates_a), vec(coordinates_b))
+ u = TrixiParticles.wrap_u(u_ode, system, semi)
+
+ @test_nowarn TrixiParticles.calculate_sum_d_ij_pj!(system.sum_d_ij_pj,
+ system, neighbor_system,
+ u, u_ode, semi)
+ @test !iszero(system.sum_d_ij_pj[1, 1])
end
@testset "Source term and iteration limits" begin
@@ -489,4 +531,30 @@
@test TrixiParticles.maximum_iisph_iterations(system_iters) == 7
end
end
+
+ @testset "Reject incompatible fluid systems" begin
+ smoothing_kernel = SchoenbergCubicSplineKernel{2}()
+ smoothing_length = 0.5
+ coordinates = [0.0 0.1
+ 0.0 0.2]
+ velocity = zeros(2, 2)
+ mass = [1.0, 1.0]
+ density = [1000.0, 1000.0]
+ pressure = [0.0, 0.0]
+ ic = InitialCondition(; coordinates, velocity, mass, density, pressure)
+
+ iisph_system = ImplicitIncompressibleSPHSystem(ic;
+ smoothing_kernel,
+ smoothing_length,
+ reference_density=1000.0,
+ time_step=0.5)
+ edac_system = EntropicallyDampedSPHSystem(ic; smoothing_kernel,
+ smoothing_length,
+ sound_speed=10.0)
+
+ error_str = "`ImplicitIncompressibleSPHSystem` cannot be used together with " *
+ "`EntropicallyDampedSPHSystem`"
+ @test_throws ArgumentError(error_str) Semidiscretization(iisph_system,
+ edac_system)
+ end
end
diff --git a/test/systems/open_boundary_system.jl b/test/systems/open_boundary_system.jl
index d1b2b3fc1d..eca71a9d6e 100644
--- a/test/systems/open_boundary_system.jl
+++ b/test/systems/open_boundary_system.jl
@@ -2,14 +2,16 @@
@testset "`show`" begin
# Mock fluid system
- struct FluidSystemMock2 <: TrixiParticles.AbstractFluidSystem{2}
+ struct FluidSystemMock2{B} <: TrixiParticles.AbstractFluidSystem{2}
pressure_acceleration_formulation::Nothing
density_diffusion::Nothing
+ buffer::B
end
TrixiParticles.initial_smoothing_length(system::FluidSystemMock2) = 1.0
TrixiParticles.nparticles(system::FluidSystemMock2) = 1
TrixiParticles.system_smoothing_kernel(system::FluidSystemMock2) = nothing
TrixiParticles.density_calculator(system::FluidSystemMock2) = TrixiParticles.ContinuityDensity()
+ TrixiParticles.buffer(system::FluidSystemMock2) = system.buffer
inflow = BoundaryZone(; boundary_face=([0.0, 0.0], [0.0, 1.0]),
particle_spacing=0.05,
@@ -17,7 +19,8 @@
open_boundary_layers=4, boundary_type=InFlow())
system = OpenBoundarySystem(inflow; buffer_size=0,
boundary_model=BoundaryModelCharacteristicsLastiwka(),
- fluid_system=FluidSystemMock2(nothing, nothing))
+ fluid_system=FluidSystemMock2(nothing, nothing,
+ nothing))
show_compact = "OpenBoundarySystem{2}() with 80 particles"
@test repr(system) == show_compact
@@ -40,7 +43,8 @@
boundary_type=OutFlow())
system = OpenBoundarySystem(outflow; buffer_size=0,
boundary_model=BoundaryModelMirroringTafuni(),
- fluid_system=FluidSystemMock2(nothing, nothing))
+ fluid_system=FluidSystemMock2(nothing, nothing,
+ nothing))
show_compact = "OpenBoundarySystem{2}() with 80 particles"
@test repr(system) == show_compact
@@ -59,7 +63,8 @@
system = OpenBoundarySystem(outflow, inflow; buffer_size=0,
boundary_model=BoundaryModelMirroringTafuni(),
- fluid_system=FluidSystemMock2(nothing, nothing))
+ fluid_system=FluidSystemMock2(nothing, nothing,
+ nothing))
show_compact = "OpenBoundarySystem{2}() with 160 particles"
@test repr(system) == show_compact
@@ -78,7 +83,8 @@
system = OpenBoundarySystem(outflow, inflow; buffer_size=0,
boundary_model=BoundaryModelDynamicalPressureZhang(),
- fluid_system=FluidSystemMock2(nothing, nothing))
+ fluid_system=FluidSystemMock2(nothing, nothing,
+ nothing))
show_compact = "OpenBoundarySystem{2}() with 160 particles"
@test repr(system) == show_compact
@@ -96,6 +102,20 @@
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ"""
@test repr("text/plain", system) == show_box
+
+ fluid_system_with_buffer = FluidSystemMock2(nothing, nothing,
+ TrixiParticles.SystemBuffer(1, 3))
+ system = OpenBoundarySystem(outflow; fluid_system=fluid_system_with_buffer)
+ @test system.boundary_model isa BoundaryModelMirroringTafuni
+ @test system.buffer.buffer_size == 3
+
+ error_str = "`buffer_size` could not be inferred for `OpenBoundarySystem` " *
+ "because `fluid_system` has no buffer. Pass `buffer_size=...` " *
+ "explicitly or construct `fluid_system` with `buffer_size=...`."
+ @test_throws ArgumentError(error_str) OpenBoundarySystem(outflow;
+ fluid_system=FluidSystemMock2(nothing,
+ nothing,
+ nothing))
end
@testset "boundary zone width" begin
diff --git a/test/systems/packing_system.jl b/test/systems/packing_system.jl
index 20c915fb4c..b3e43a5944 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)
@@ -97,4 +113,15 @@
@test isapprox(u[1, 1], -expected_shift)
@test isapprox(u[2, 1], initial_condition.coordinates[2, 1])
end
+
+ @trixi_testset "Semidiscretization configuration" begin
+ initial_condition = RectangularShape(0.1, (2, 2), (0.0, 0.0); density=1.0)
+ system = ParticlePackingSystem(initial_condition;
+ signed_distance_field=nothing,
+ background_pressure=1.0)
+
+ @test isnothing(TrixiParticles.surface_tension_model(system))
+ @test isnothing(TrixiParticles.surface_normal_method(system))
+ @test Semidiscretization(system) isa Semidiscretization
+ end
end
diff --git a/test/test_util.jl b/test/test_util.jl
index dc1a102753..e76daf873a 100644
--- a/test/test_util.jl
+++ b/test/test_util.jl
@@ -7,6 +7,7 @@ using TrixiParticles: PointNeighbors
using TrixiParticles.Adapt
using LinearAlgebra
using Printf
+using Statistics: median
using CSV: CSV
using DataFrames: DataFrame
using JSON: JSON
diff --git a/test/unittest.jl b/test/unittest.jl
index ae6ac865c8..d008f0ffe1 100644
--- a/test/unittest.jl
+++ b/test/unittest.jl
@@ -9,4 +9,5 @@
include("preprocessing/preprocessing.jl")
include("io/write_vtk.jl")
include("io/read_vtk.jl")
+ include("visualization/makie.jl")
end;
diff --git a/test/validation/validation.jl b/test/validation/validation.jl
index 598c82973c..0ec74be426 100644
--- a/test/validation/validation.jl
+++ b/test/validation/validation.jl
@@ -135,4 +135,293 @@
@test sol.retcode == ReturnCode.Success
@test count_rhs_allocations(sol) == 0
end
+
+ @trixi_testset "surface tension" begin
+ include(joinpath(validation_dir(), "surface_tension_common.jl"))
+
+ laplace_2d = SurfaceTensionValidation.young_laplace_operator_fit(2, 100)
+ laplace_3d = SurfaceTensionValidation.young_laplace_operator_fit(3, 905)
+ rayleigh_coarse = SurfaceTensionValidation.rayleigh_mode2_stiffness(200;
+ stretch=1.04)
+ rayleigh_medium = SurfaceTensionValidation.rayleigh_mode2_stiffness(400;
+ stretch=1.04)
+
+ @test laplace_2d.relative_error < 0.06
+ @test laplace_3d.relative_error < 0.02
+ @test laplace_2d.total_force < 1.0e-12
+ @test laplace_3d.total_force < 1.0e-12
+ @test rayleigh_medium.frequency_error < 0.05
+ @test rayleigh_medium.frequency_error < rayleigh_coarse.frequency_error
+
+ scorecard = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_scorecard.csv"), DataFrame)
+ @test size(scorecard, 1) == 2
+ @test all(scorecard.static_eligible)
+ @test !any(scorecard.eligible)
+ @test only(scorecard[scorecard.mechanism .== "geometric", :response_passes]) == 1
+ @test only(scorecard[scorecard.mechanism .== "contact_line_force",
+ :response_passes]) == 2
+
+ normal_diagnostics = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_normal_components.csv"),
+ DataFrame)
+ fluid_middle = normal_diagnostics[(normal_diagnostics.variant .== "fluid_only") .& (normal_diagnostics.requested_particles .== 1500) .& (normal_diagnostics.target .== normal_diagnostics.initial_angle),
+ :]
+ @test maximum(fluid_middle.mean_error) > 5
+ baseline_middle = normal_diagnostics[(normal_diagnostics.variant .== "baseline_total") .& (normal_diagnostics.requested_particles .== 1500) .& (normal_diagnostics.target .== normal_diagnostics.initial_angle),
+ :]
+ @test count(baseline_middle.corrected_cross_error_4x .<= 0.2) == 3
+
+ line_normalization = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_line_normalization.csv"),
+ DataFrame)
+ line_middle = line_normalization[line_normalization.cells_per_h .== 4, :]
+ @test size(line_normalization, 1) == 150
+ @test all(line_middle.coarea_middle_pass)
+ @test !all(line_middle.coarea_endpoint_decreasing)
+ @test !any(line_middle[line_middle.angle .!= 90,
+ :divergence_middle_pass])
+ wendland_middle = line_middle[line_middle.kernel .== "wendland_c2", :]
+ @test count(wendland_middle.wedge_middle_pass) == 2
+ @test count(wendland_middle.gated_middle_pass) == 2
+
+ cap_transfer = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_line_cap_transfer.csv"), DataFrame)
+ compatible_middle = cap_transfer[(cap_transfer.variant .== "compatible_indicator") .& (cap_transfer.requested_particles .== 1500),
+ :]
+ @test size(cap_transfer, 1) == 90
+ @test all(compatible_middle.middle_pass)
+ @test !any(compatible_middle.endpoint_decreasing)
+
+ wetted_area = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "wetted_area_measure.csv"), DataFrame)
+ wetted_middle = wetted_area[wetted_area.requested_particles .== 1500, :]
+ @test count(wetted_middle.middle_pass) == 4
+ @test count(wetted_middle.endpoint_decreasing) == 4
+
+ recovery_comparison = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_recovery_comparison.csv"),
+ DataFrame)
+ @test !any(recovery_comparison.eligible)
+
+ measure_protocol = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_measure_protocol.csv"), DataFrame)
+ @test size(measure_protocol, 1) == 30
+ @test all(measure_protocol.protocol_pass)
+ protocol_middle = measure_protocol[(measure_protocol.series .== "production_resolution") .& (measure_protocol.requested_particles .== 1500),
+ :]
+ @test all(protocol_middle.middle_pass .& protocol_middle.endpoint_pass)
+
+ corrected_wetted_area = CSV.read(joinpath(validation_dir(),
+ "surface_tension_3d",
+ "wetted_area_corrected.csv"), DataFrame)
+ corrected_middle = corrected_wetted_area[corrected_wetted_area.requested_particles .== 1500,
+ :]
+ @test all(corrected_middle.middle_pass .& corrected_middle.endpoint_pass)
+ @test maximum(corrected_middle.corrected_area_error) < 0.06
+ @test only(corrected_middle[corrected_middle.target .== 150.0,
+ :corrected_area_error]) < 0.02
+
+ extended_recovery = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_recovery_extended.csv"),
+ DataFrame)
+ @test size(extended_recovery, 1) == 60
+ compatible_geometry_middle = extended_recovery[(extended_recovery.variant .== "compatible_geometry_wall") .& (extended_recovery.requested_particles .== 1500),
+ :]
+ young_middle = extended_recovery[(extended_recovery.variant .== "young_color_boundary") .& (extended_recovery.requested_particles .== 1500),
+ :]
+ @test count(compatible_geometry_middle.middle_pass) == 5
+ @test count(compatible_geometry_middle.endpoint_pass) == 2
+ @test count(young_middle.middle_pass) == 5
+ @test count(young_middle.endpoint_pass) == 5
+ @test count(young_middle.angle_middle_pass) == 2
+ @test count(young_middle.angle_endpoint_pass) == 1
+
+ extended_signs = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_force_sign_extended.csv"),
+ DataFrame)
+ corrected_signs = extended_signs[extended_signs.variant .== "corrected_wetted_area",
+ :]
+ geometry_signs = extended_signs[extended_signs.variant .== "compatible_geometry_wall",
+ :]
+ young_signs = extended_signs[extended_signs.variant .== "young_color_boundary", :]
+ @test count(corrected_signs.sign_pass) == 4
+ @test all(skipmissing(corrected_signs.wall_zero_at_90))
+ @test count(geometry_signs.sign_pass) == 4
+ @test !any(geometry_signs.measure_eligible)
+ @test count(young_signs.sign_pass) == 3
+ @test !any(young_signs.static_eligible)
+
+ extended_comparison = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_recovery_extended_comparison.csv"),
+ DataFrame)
+ @test count(extended_comparison.eligible_for_dynamics) == 1
+ @test only(extended_comparison[extended_comparison.method .== "R7-W corrected wetted-area energy",
+ :eligible_for_dynamics])
+
+ r4_static = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_static_r4_wetted_area.csv"), DataFrame)
+ @test size(r4_static, 1) == 9
+ @test all(r4_static.stage_pass)
+ @test count(r4_static[r4_static.kind .== "force_sign", :total_sign_pass]) == 4
+ @test maximum(r4_static[r4_static.kind .== "energy_gradient",
+ :gradient_relative_error]) <= 1.0e-5
+ @test all(r4_static[r4_static.target .== 90, :zero_at_90])
+
+ r4_initial = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_perturbation_r4_wetted_area.csv"),
+ DataFrame)
+ r4_initial_candidates = r4_initial[r4_initial.mechanism .== "r4_wetted_area", :]
+ @test count(r4_initial_candidates.response_pass) == 2
+ r4_classified = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_perturbation_r4_wetted_area_classified.csv"),
+ DataFrame)
+ r4_classified_candidates = r4_classified[r4_classified.mechanism .== "r4_wetted_area",
+ :]
+ @test count(r4_classified_candidates.formulation_response_pass) == 3
+ @test count(r4_classified_candidates.effective_acceleration_toward_target) == 4
+ @test all(r4_classified_candidates[r4_classified_candidates.target .== 90,
+ :control_equivalent])
+
+ r4_extended = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_perturbation_r4_wetted_area_extended.csv"),
+ DataFrame)
+ r4_extended_candidates = r4_extended[r4_extended.mechanism .== "r4_wetted_area", :]
+ @test count(r4_extended_candidates.formulation_response_pass) == 4
+ @test all(r4_extended_candidates.reaction_pass)
+
+ r4_threshold = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_threshold_r4_wetted_area.csv"),
+ DataFrame)
+ r4_timestep = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_timestep_r4_wetted_area.csv"),
+ DataFrame)
+ r4_cost = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_cost_r4_wetted_area.csv"), DataFrame)
+ r4_active_cost = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_cost_r4_wetted_area_active.csv"),
+ DataFrame)
+ @test size(r4_threshold, 1) == 5 && all(r4_threshold.pass)
+ @test size(r4_timestep, 1) == 2 && all(r4_timestep.pass)
+ @test size(r4_cost, 1) == 6
+ @test size(r4_active_cost, 1) == 6
+ @test all(r4_cost[r4_cost.mechanism .== "r4_wetted_area",
+ :contact_cache_bytes] .> 0)
+ @test all(r4_active_cost.target .== 60)
+
+ r4_selected = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_selected_matrix_r4_wetted_area.csv"),
+ DataFrame)
+ r4_sensitivity = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_sensitivity_r4_wetted_area.csv"),
+ DataFrame)
+ @test size(r4_selected, 1) == 15 && all(r4_selected.pass)
+ @test maximum(abs, r4_selected.final_error) <= 5
+ @test maximum(r4_selected.max_total_momentum_residual) <= 1.0e-12
+ @test size(r4_sensitivity, 1) == 4 && all(r4_sensitivity.pass)
+ @test only(unique(r4_sensitivity.angle_span)) <= 1
+
+ production_static = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_static_r4_wetted_area_production.csv"),
+ DataFrame)
+ @test size(production_static, 1) == 9 && all(production_static.stage_pass)
+ @test maximum(production_static[production_static.kind .== "energy_gradient",
+ :gradient_relative_error]) <= 1.0e-5
+ production_initial = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_perturbation_r4_wetted_area_production.csv"),
+ DataFrame)
+ production_initial_candidates = production_initial[production_initial.mechanism .== "wetted_area_production",
+ :]
+ @test count(production_initial_candidates.formulation_response_pass) == 3
+ production_extended = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_perturbation_r4_wetted_area_extended_production.csv"),
+ DataFrame)
+ production_extended_candidates = production_extended[production_extended.mechanism .== "wetted_area_production",
+ :]
+ @test size(production_extended_candidates, 1) == 4
+ @test all(production_extended_candidates.formulation_response_pass)
+
+ production_threshold = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_threshold_r4_wetted_area_production.csv"),
+ DataFrame)
+ production_timestep = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_timestep_r4_wetted_area_production.csv"),
+ DataFrame)
+ @test size(production_threshold, 1) == 5 && all(production_threshold.pass)
+ @test size(production_timestep, 1) == 2 && all(production_timestep.pass)
+ production_cost = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_cost_r4_wetted_area_production.csv"),
+ DataFrame)
+ production_active_cost = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_cost_r4_wetted_area_active_production.csv"),
+ DataFrame)
+ @test size(production_cost, 1) == 6
+ @test size(production_active_cost, 1) == 6
+ production_control_median = median(production_active_cost[production_active_cost.mechanism .== "none",
+ :solver_runtime])
+ production_active_median = median(production_active_cost[production_active_cost.mechanism .== "wetted_area_production",
+ :solver_runtime])
+ @test production_active_median / production_control_median <= 1.2
+
+ production_selected = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_selected_matrix_r4_wetted_area_production.csv"),
+ DataFrame)
+ production_sensitivity = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_sensitivity_r4_wetted_area_production.csv"),
+ DataFrame)
+ @test size(production_selected, 1) == 15 && all(production_selected.pass)
+ @test maximum(production_selected.max_total_momentum_residual) <= 1.0e-12
+ @test maximum(production_selected.force_consistency_residual) <= 1.0e-12
+ @test size(production_sensitivity, 1) == 4 && all(production_sensitivity.pass)
+ @test only(unique(production_sensitivity.angle_span)) <= 1
+
+ tensile_stability = CSV.read(joinpath(validation_dir(), "surface_tension_2d",
+ "rayleigh_tensile_stability.csv"), DataFrame)
+ @test count(tensile_stability.admissible) == 8
+ @test count(tensile_stability.accepted) == 1
+ shifted_rayleigh = only(eachrow(tensile_stability[tensile_stability.variant .== "particle_shifting_tangential",
+ :]))
+ @test shifted_rayleigh.periods_completed == 1.48
+ @test shifted_rayleigh.minimum_pair_ratio >= 0.5
+ @test shifted_rayleigh.density_min < 980
+ @test shifted_rayleigh.status == "timestep_collapse"
+ shifted_rayleigh_sun2017 = only(eachrow(tensile_stability[tensile_stability.variant .== "particle_shifting_sun2017_tangential",
+ :]))
+ @test shifted_rayleigh_sun2017.periods_completed == 0.4
+ @test shifted_rayleigh_sun2017.minimum_pair_ratio >= 0.5
+ @test shifted_rayleigh_sun2017.density_min < 980
+ @test shifted_rayleigh_sun2017.status == "timestep_collapse"
+ selected_tic = only(eachrow(tensile_stability[tensile_stability.variant .== "interface_tic_025_sun2017_tangential",
+ :]))
+ @test selected_tic.admissible && selected_tic.accepted
+ @test selected_tic.tic_strength == 0.25
+ @test !selected_tic.clip_negative_pressure
+ @test selected_tic.periods_completed >= selected_tic.requested_periods
+ @test selected_tic.frequency_error <= 0.05
+ @test selected_tic.minimum_pair_ratio >= 0.5
+ @test 980 <= selected_tic.density_min <= selected_tic.density_max <= 1020
+ @test selected_tic.status == "final_time"
+ tic_controls = tensile_stability[in.(tensile_stability.tic_strength,
+ Ref([0.1, 0.5, 1.0])), :]
+ @test size(tic_controls, 1) == 3
+ @test !any(tic_controls.accepted)
+
+ ghost_signs = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_force_sign_ghost_geometric.csv"),
+ DataFrame)
+ wall_energy_signs = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_force_sign_wall_energy.csv"),
+ DataFrame)
+ wetted_area_signs = CSV.read(joinpath(validation_dir(), "surface_tension_3d",
+ "contact_angle_force_sign_wetted_area.csv"),
+ DataFrame)
+ @test count(ghost_signs.sign_pass) == 2
+ @test count(wall_energy_signs[wall_energy_signs.variant .== "wall_energy_1x",
+ :sign_pass]) == 3
+ @test count(wall_energy_signs[wall_energy_signs.variant .== "wall_energy_2x",
+ :sign_pass]) == 4
+ @test count(wetted_area_signs.sign_pass) == 4
+ @test all(skipmissing(wetted_area_signs.wall_zero_at_90))
+ @test !any(wetted_area_signs.measure_eligible)
+ end
end
diff --git a/test/visualization/makie.jl b/test/visualization/makie.jl
new file mode 100644
index 0000000000..77edee6769
--- /dev/null
+++ b/test/visualization/makie.jl
@@ -0,0 +1,23 @@
+using CairoMakie
+
+@testset verbose=true "Makie Extension" begin
+ initial_condition = RectangularShape(0.1, (2, 2, 2), (0.0, 0.0, 0.0);
+ density=1.0)
+ fluid_system = WeaklyCompressibleSPHSystem(initial_condition;
+ smoothing_kernel=SchoenbergCubicSplineKernel{3}(),
+ smoothing_length=0.1,
+ density_calculator=SummationDensity(),
+ state_equation=nothing)
+ semi = Semidiscretization(fluid_system)
+ ode = semidiscretize(semi, (0.0, 0.01))
+ v_ode, u_ode = ode.u0.x
+
+ figure = Figure(; size=(320, 240))
+ axis = LScene(figure[1, 1]; show_axis=false)
+ plots = trixi2makie(axis, v_ode, u_ode, semi)
+
+ @test Base.get_extension(TrixiParticles, :TrixiParticlesMakieExt) !== nothing
+ @test length(plots) == 1
+ @test only(plots) isa CairoMakie.MeshScatter
+ @test length(only(plots)[1][]) == nparticles(fluid_system)
+end
diff --git a/validation/surface_tension_2d/plot_surface_tension_2d.jl b/validation/surface_tension_2d/plot_surface_tension_2d.jl
new file mode 100644
index 0000000000..55bec3b8ad
--- /dev/null
+++ b/validation/surface_tension_2d/plot_surface_tension_2d.jl
@@ -0,0 +1,47 @@
+using CairoMakie
+using CSV
+using DataFrames
+using JSON
+
+reference = JSON.parsefile(joinpath(@__DIR__, "validation_reference.json"))
+young_laplace = reference["young_laplace"]["results"]
+rayleigh = reference["rayleigh_mode_2"]
+stability = CSV.read(joinpath(@__DIR__, "rayleigh_tensile_stability.csv"), DataFrame)
+
+figure = Figure(size=(1450, 430))
+young_laplace_axis = Axis(figure[1, 1];
+ title="2D Young-Laplace convergence",
+ xlabel="particle count", ylabel="fitted sigma error [%]",
+ xscale=log10, yscale=log10)
+rayleigh_axis = Axis(figure[1, 2]; title="Rayleigh mode-2 stiffness",
+ xlabel="particle count", ylabel="frequency error [%]",
+ xscale=log10)
+stability_axis = Axis(figure[1, 3]; title="Free Rayleigh tensile stability",
+ xlabel="applicable shipped option", ylabel="periods before collapse",
+ xticks=(1:3, ["baseline", "EOS background", "tangential PST"]),
+ limits=(nothing, nothing, 0, 5.4))
+
+young_laplace_particles = getindex.(young_laplace, "particle_count")
+young_laplace_errors = 100 .* getindex.(young_laplace, "relative_error")
+rayleigh_particles = getindex.(rayleigh, "particle_count")
+rayleigh_errors = 100 .* getindex.(rayleigh, "frequency_error")
+
+scatterlines!(young_laplace_axis, young_laplace_particles, young_laplace_errors;
+ color=:navy, marker=:circle, label="CSS operator fit")
+hlines!(young_laplace_axis, [5.0]; color=:firebrick, linestyle=:dash,
+ label="5% acceptance")
+scatterlines!(rayleigh_axis, rayleigh_particles, rayleigh_errors;
+ color=:darkgreen, marker=:diamond, label="linear stiffness")
+hlines!(rayleigh_axis, [5.0]; color=:firebrick, linestyle=:dash,
+ label="5% acceptance")
+applicable = stability[stability.admissible, :]
+barplot!(stability_axis, 1:nrow(applicable), applicable.periods_completed;
+ color=[:gray45, :darkorange, :dodgerblue3])
+hlines!(stability_axis, [5.0]; color=:firebrick, linestyle=:dash,
+ label="5-period gate")
+
+axislegend(young_laplace_axis; position=:rt)
+axislegend(rayleigh_axis; position=:rt)
+axislegend(stability_axis; position=:rt)
+save(joinpath(@__DIR__, "surface_tension_2d_validation.png"), figure)
+figure
diff --git a/validation/surface_tension_2d/rayleigh_tensile_stability.csv b/validation/surface_tension_2d/rayleigh_tensile_stability.csv
new file mode 100644
index 0000000000..b3f9ef89f7
--- /dev/null
+++ b/validation/surface_tension_2d/rayleigh_tensile_stability.csv
@@ -0,0 +1,10 @@
+variant,admissible,status,reason,target_particle_count,particle_count,particle_spacing,background_pressure,tic_strength,clip_negative_pressure,requested_periods,periods_completed,omega_exact,omega_measured,frequency_error,fit_residual,minimum_pair_ratio,density_min,density_max,dt_reference,minimum_dt_ratio,accepted_steps,rejected_steps,runtime,accepted
+baseline,true,timestep_collapse,applicable shipped EOS option,400,435,0.000886226925452758,0.0,0.0,true,5.0,0.3,72.73671379456064,109.10507069184096,0.5,0.00033296465379983355,0.2616995321960881,842.4715780896266,1000.1043210178906,0.000551340036121354,0.0008861895130249629,1323,120,12.012634936,false
+eos_background_laplace,true,timestep_collapse,applicable shipped EOS option,400,435,0.000886226925452758,100.0,0.0,true,5.0,0.76,72.73671379456064,63.571887856446004,0.126,0.010129952599036658,0.006767672465331747,87.4120909992244,1001.9133192309974,0.000551340036121354,0.0007352377422851029,7262,1203,38.54535172,false
+transport_velocity,false,not_run,TVF requires an unavailable free-surface mask,400,0,NaN,NaN,NaN,false,5.0,0.0,NaN,NaN,NaN,NaN,NaN,NaN,NaN,NaN,NaN,0,0,0.0,false
+particle_shifting_tangential,true,timestep_collapse,colorfield tangential free-surface shifting,400,435,0.000886226925452758,0.0,0.0,true,5.0,1.48,72.73671379456064,48.29717795958827,0.33599999999999997,0.07874034609668361,0.7630137221175222,575.6437822669448,1000.6886455789786,0.000551340036121354,0.0002145307432426986,13503,1544,92.27811961,false
+particle_shifting_sun2017_tangential,true,timestep_collapse,callback shifting without Sun-2019 transport terms,400,435,0.000886226925452758,0.0,0.0,true,5.0,0.4,72.73671379456064,109.10507069184096,0.5,0.00029514312684822126,0.6506243977524491,718.0256213664027,1000.4231763280168,0.000551340036121354,0.0007894305392273193,1967,190,15.835765537,false
+interface_tic_010_sun2017_tangential,true,final_time,bounded interface-aware TIC strength control,400,435,0.000886226925452758,0.0,0.1,false,5.0,5.0,72.73671379456064,36.36835689728032,0.5,0.25415674187991844,0.5665936156897731,999.473181460651,1000.72113476308,0.000551340036121354,0.008684891975990901,19112,211,128.715613523,false
+interface_tic_025_sun2017_tangential,true,final_time,selected interface-aware TIC and callback-shifting combination,400,435,0.000886226925452758,0.0,0.25,false,5.0,5.0,72.73671379456064,70.48187566692927,0.030999999999999917,0.05535921552366913,0.6161210848345465,999.6867966735554,1000.614338645262,0.000551340036121354,0.008684891975990901,19388,345,127.888932352,true
+interface_tic_050_sun2017_tangential,true,timestep_collapse,bounded interface-aware TIC strength control,400,435,0.000886226925452758,0.0,0.5,false,5.0,5.0,72.73671379456064,42.041820573256054,0.42199999999999993,0.03996961047972764,0.6013503392105566,999.2008222818777,1000.669813464837,0.000551340036121354,0.0001705658148720756,19481,335,129.98183619,false
+interface_tic_100_sun2017_tangential,true,timestep_collapse,bounded interface-aware TIC strength control,400,435,0.000886226925452758,0.0,1.0,false,5.0,0.76,72.73671379456064,106.19560214005854,0.45999999999999996,0.0156529414709074,0.5570344862047545,996.8754837830535,1005.1753930005056,0.000551340036121354,0.0009864221924702529,10974,607,75.975225188,false
diff --git a/validation/surface_tension_2d/rayleigh_tensile_stability.jl b/validation/surface_tension_2d/rayleigh_tensile_stability.jl
new file mode 100644
index 0000000000..5658dd7f97
--- /dev/null
+++ b/validation/surface_tension_2d/rayleigh_tensile_stability.jl
@@ -0,0 +1,190 @@
+using CSV
+using DataFrames
+using LinearAlgebra
+using OrdinaryDiffEqLowStorageRK
+using Statistics
+using TrixiParticles
+
+include(joinpath(@__DIR__, "..", "surface_tension_common.jl"))
+using .SurfaceTensionValidation
+
+const OUTPUT_PATH = joinpath(@__DIR__, "rayleigh_tensile_stability.csv")
+
+function minimum_pair_ratio(solution, system, semi, particle_spacing)
+ minimum_distance = Inf
+ for state in solution.u
+ _, u_ode = state.x
+ u = TrixiParticles.wrap_u(u_ode, system, semi)
+ coordinates = TrixiParticles.current_coordinates(u, system)
+ for particle in 1:(TrixiParticles.nparticles(system) - 1)
+ for neighbor in (particle + 1):TrixiParticles.nparticles(system)
+ minimum_distance = min(minimum_distance,
+ norm(coordinates[:, particle] -
+ coordinates[:, neighbor]))
+ end
+ end
+ end
+ return minimum_distance / particle_spacing
+end
+
+function density_extrema(solution, system, semi)
+ minimum_density = Inf
+ maximum_density = -Inf
+ for state in solution.u
+ v_ode, _ = state.x
+ v = TrixiParticles.wrap_v(v_ode, system, semi)
+ density = collect(TrixiParticles.current_density(v, system))
+ minimum_density = min(minimum_density, minimum(density))
+ maximum_density = max(maximum_density, maximum(density))
+ end
+ return minimum_density, maximum_density
+end
+
+function run_stability_case(variant; target_particle_count=400, radius=0.01,
+ reference_density=1000.0,
+ surface_tension_coefficient=1.0,
+ background_pressure=0.0, periods=5.0, stretch=1.04,
+ shifting_technique=nothing,
+ pressure_acceleration=nothing,
+ tic_strength=0.0,
+ clip_negative_pressure=true,
+ reason="applicable shipped EOS option")
+ setup = SurfaceTensionValidation.spherical_drop_initial_condition(2,
+ target_particle_count;
+ radius,
+ reference_density,
+ surface_tension_coefficient,
+ stretch,
+ initialize_laplace_pressure=true)
+ (; initial_condition, particle_spacing) = setup
+ state_equation = StateEquationCole(; sound_speed=100.0, reference_density,
+ exponent=7, background_pressure,
+ clip_negative_pressure)
+ viscosity = ArtificialViscosityMonaghan(; alpha=0.05, beta=0.0)
+ density_diffusion = DensityDiffusionAntuono(; delta=0.05)
+ system = SurfaceTensionValidation.css_system(initial_condition, state_equation;
+ surface_tension_coefficient,
+ viscosity, density_diffusion,
+ shifting_technique,
+ pressure_acceleration)
+ semi = Semidiscretization(system; parallelization_backend=SerialBackend())
+ area = sum(initial_condition.mass) / reference_density
+ radius_discrete = sqrt(area / pi)
+ omega_exact = sqrt(6surface_tension_coefficient /
+ (reference_density * radius_discrete^3))
+ period_exact = 2pi / omega_exact
+ final_time = periods * period_exact
+ ode = semidiscretize(semi, (0.0, final_time))
+ capillary_dt = sqrt(reference_density * (1.4particle_spacing)^3 /
+ (2pi * surface_tension_coefficient))
+ dt_reference = min(period_exact / 120, capillary_dt)
+ accepted_dt = Float64[]
+ termination_reason = Ref("final_time")
+ termination_callback = DiscreteCallback((_, time, _) -> time > 0,
+ integrator -> begin
+ dt = abs(integrator.t - integrator.tprev)
+ push!(accepted_dt, dt)
+ if integrator.t > period_exact / 4 &&
+ dt / dt_reference < 1.0e-3
+ termination_reason[] = "timestep_collapse"
+ terminate!(integrator)
+ elseif length(accepted_dt) >= 50_000
+ termination_reason[] = "step_limit"
+ terminate!(integrator)
+ end
+ u_modified!(integrator, false)
+ end;
+ save_positions=(false, false))
+ callback = if TrixiParticles.requires_update_callback(shifting_technique)
+ CallbackSet(UpdateCallback(), termination_callback)
+ else
+ termination_callback
+ end
+ saveat = range(0.0, final_time; step=period_exact / 50)
+ solution = nothing
+ runtime = @elapsed solution = solve(ode, RDPK3SpFSAL35(); abstol=1.0e-8,
+ reltol=2.0e-5, dtmax=dt_reference,
+ maxiters=50_001, save_everystep=false,
+ saveat, callback)
+ axes = [SurfaceTensionValidation.signed_axes(state, system, semi)
+ for state in solution.u]
+ deformation = first.(axes) .- last.(axes)
+ fit = SurfaceTensionValidation.fit_angular_frequency(solution.t, deformation,
+ omega_exact)
+ frequency_error = abs(fit.omega / omega_exact - 1)
+ pair_ratio = minimum_pair_ratio(solution, system, semi, particle_spacing)
+ density_min, density_max = density_extrema(solution, system, semi)
+ periods_completed = last(solution.t) / period_exact
+ minimum_dt_ratio = isempty(accepted_dt) ? NaN : minimum(accepted_dt) / dt_reference
+ accepted = periods_completed >= periods && frequency_error <= 0.05 &&
+ pair_ratio >= 0.5 && density_min >= 980 && density_max <= 1020 &&
+ termination_reason[] == "final_time"
+ return (; variant=String(variant), admissible=true, status=termination_reason[],
+ reason, target_particle_count,
+ particle_count=TrixiParticles.nparticles(system), particle_spacing,
+ background_pressure, tic_strength, clip_negative_pressure,
+ requested_periods=periods, periods_completed,
+ omega_exact, omega_measured=fit.omega, frequency_error,
+ fit_residual=fit.residual, minimum_pair_ratio=pair_ratio,
+ density_min, density_max, dt_reference, minimum_dt_ratio,
+ accepted_steps=solution.stats.naccept,
+ rejected_steps=solution.stats.nreject, runtime, accepted)
+end
+
+function ineligible_row(variant, reason; target_particle_count=400, periods=5.0)
+ return (; variant=String(variant), admissible=false, status="not_run",
+ reason, target_particle_count, particle_count=0,
+ particle_spacing=NaN, background_pressure=NaN, tic_strength=NaN,
+ clip_negative_pressure=false,
+ requested_periods=periods, periods_completed=0.0,
+ omega_exact=NaN, omega_measured=NaN, frequency_error=NaN,
+ fit_residual=NaN, minimum_pair_ratio=NaN,
+ density_min=NaN, density_max=NaN, dt_reference=NaN,
+ minimum_dt_ratio=NaN, accepted_steps=0, rejected_steps=0,
+ runtime=0.0, accepted=false)
+end
+
+function run_tensile_stability_study(; output_path=OUTPUT_PATH)
+ radius = 0.01
+ surface_tension_coefficient = 1.0
+ laplace_pressure = surface_tension_coefficient / radius
+ rows = [run_stability_case(:baseline),
+ run_stability_case(:eos_background_laplace;
+ background_pressure=laplace_pressure),
+ ineligible_row(:transport_velocity,
+ "TVF requires an unavailable free-surface mask"),
+ run_stability_case(:particle_shifting_tangential;
+ shifting_technique=ConsistentShiftingSun2019(;
+ free_surface_treatment=FreeSurfaceTangentialShifting()),
+ reason="colorfield tangential free-surface shifting"),
+ run_stability_case(:particle_shifting_sun2017_tangential;
+ shifting_technique=ParticleShiftingTechniqueSun2017(;
+ free_surface_treatment=FreeSurfaceTangentialShifting()),
+ reason="callback shifting without Sun-2019 transport terms")]
+ controls = ((:interface_tic_010_sun2017_tangential, 0.1),
+ (:interface_tic_025_sun2017_tangential, 0.25),
+ (:interface_tic_050_sun2017_tangential, 0.5),
+ (:interface_tic_100_sun2017_tangential, 1.0))
+ for (variant, strength) in controls
+ reason = strength == 0.25 ?
+ "selected interface-aware TIC and callback-shifting combination" :
+ "bounded interface-aware TIC strength control"
+ push!(rows,
+ run_stability_case(variant;
+ shifting_technique=ParticleShiftingTechniqueSun2017(;
+ free_surface_treatment=FreeSurfaceTangentialShifting()),
+ pressure_acceleration=InterfaceAwareTensileInstabilityControl(;
+ strength),
+ tic_strength=strength,
+ clip_negative_pressure=false, reason))
+ end
+ data = DataFrame(rows)
+ CSV.write(output_path, data)
+ println(data)
+ println("Wrote Rayleigh tensile-stability study to ", output_path)
+ return data
+end
+
+if abspath(PROGRAM_FILE) == @__FILE__
+ run_tensile_stability_study()
+end
diff --git a/validation/surface_tension_2d/validation_reference.json b/validation/surface_tension_2d/validation_reference.json
new file mode 100644
index 0000000000..396d0cbee1
--- /dev/null
+++ b/validation/surface_tension_2d/validation_reference.json
@@ -0,0 +1,108 @@
+{
+ "rayleigh_mode_2": [
+ {
+ "acceleration_rms": 11.112352356839647,
+ "frequency_error": 0.04295591829944412,
+ "omega_exact": 70.9106119592665,
+ "omega_measured": 73.95664241315234,
+ "omega_squared": 5469.584957026884,
+ "particle_count": 225,
+ "particle_spacing": 0.0012533141373155003,
+ "quadrupole": 4.423879360752858e-6,
+ "quadrupole_acceleration": -0.02419678400327554,
+ "radius": 0.010606601717798213,
+ "target_particle_count": 200
+ },
+ {
+ "acceleration_rms": 13.079756725567265,
+ "frequency_error": 0.039969525954901775,
+ "omega_exact": 72.73671379456064,
+ "omega_measured": 75.64396576444659,
+ "omega_squared": 5722.009556572767,
+ "particle_count": 435,
+ "particle_spacing": 0.000886226925452758,
+ "quadrupole": 4.280286618348131e-6,
+ "quadrupole_acceleration": -0.02449184093505854,
+ "radius": 0.010428326807307103,
+ "target_particle_count": 400
+ },
+ {
+ "acceleration_rms": 15.793094388723517,
+ "frequency_error": 0.03920359982024535,
+ "omega_exact": 74.01656412943377,
+ "omega_measured": 76.91827988963362,
+ "omega_squared": 5916.421781180015,
+ "particle_count": 850,
+ "particle_spacing": 0.0006266570686577501,
+ "quadrupole": 4.170752035793783e-6,
+ "quadrupole_acceleration": -0.024675928188471228,
+ "radius": 0.01030776406404415,
+ "target_particle_count": 800
+ }
+ ],
+ "young_laplace": {
+ "observed_order": 2.6518777233650725,
+ "results": [
+ {
+ "interface_particles": 50,
+ "ndims": 2,
+ "particle_count": 78,
+ "particle_spacing": 0.0013398491713813574,
+ "pressure_jump": 163.4814129061293,
+ "radius": 0.006676183683170241,
+ "relative_error": 0.09143194134551735,
+ "residual": 0.25344068619889837,
+ "sigma_fit": 1.0914319413455174,
+ "sigma_virial": 1.0375749376800554,
+ "surface_measure_ratio": 0.9455427925609031,
+ "target_particle_count": 63,
+ "total_force": 7.369941604692741e-16
+ },
+ {
+ "interface_particles": 64,
+ "ndims": 2,
+ "particle_count": 118,
+ "particle_spacing": 0.0010634723105433096,
+ "pressure_jump": 160.87642605537968,
+ "radius": 0.006517668294720129,
+ "relative_error": 0.048539181469035464,
+ "residual": 0.2546663366979736,
+ "sigma_fit": 1.0485391814690355,
+ "sigma_virial": 1.0378238663782853,
+ "surface_measure_ratio": 0.9394309082741257,
+ "target_particle_count": 100,
+ "total_force": 8.262311455338682e-16
+ },
+ {
+ "interface_particles": 83,
+ "ndims": 2,
+ "particle_count": 181,
+ "particle_spacing": 0.000846053143171924,
+ "pressure_jump": 158.969718630854,
+ "radius": 0.006421877165526754,
+ "relative_error": 0.020884006085694296,
+ "residual": 0.26105841147481307,
+ "sigma_fit": 1.0208840060856943,
+ "sigma_virial": 1.063881792888934,
+ "surface_measure_ratio": 0.9499822737818112,
+ "target_particle_count": 158,
+ "total_force": 5.605063087764203e-16
+ },
+ {
+ "interface_particles": 118,
+ "ndims": 2,
+ "particle_count": 279,
+ "particle_spacing": 0.0006712577702644259,
+ "pressure_jump": 155.5822133260394,
+ "radius": 0.006325815066454288,
+ "relative_error": 0.01581569086983492,
+ "residual": 0.3233592525554224,
+ "sigma_fit": 0.9841843091301651,
+ "sigma_virial": 1.0933310278911812,
+ "surface_measure_ratio": 0.9810456891415679,
+ "target_particle_count": 251,
+ "total_force": 2.7341025078088734e-16
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/validation/surface_tension_2d/validation_surface_tension_2d.jl b/validation/surface_tension_2d/validation_surface_tension_2d.jl
new file mode 100644
index 0000000000..055ba451ac
--- /dev/null
+++ b/validation/surface_tension_2d/validation_surface_tension_2d.jl
@@ -0,0 +1,49 @@
+using JSON
+using Printf
+
+if !isdefined(@__MODULE__, :SurfaceTensionValidation)
+ include(joinpath(@__DIR__, "..", "surface_tension_common.jl"))
+end
+using .SurfaceTensionValidation
+
+targets_3d = (375, 750, 1500, 3000)
+rayleigh_targets = (200, 400, 800)
+write_results = true
+
+young_laplace_2d = young_laplace_series(2; targets_3d)
+rayleigh_results = [rayleigh_mode2_stiffness(target; stretch=1.04)
+ for target in rayleigh_targets]
+rayleigh_errors = [result.frequency_error for result in rayleigh_results]
+
+println("2D Young-Laplace operator fit")
+for result in young_laplace_2d.results
+ @printf(" n=%4d dx=%.4e sigma=%.6f error=%6.3f%% virial=%.6f |F|=%.3e\n",
+ result.particle_count, result.particle_spacing, result.sigma_fit,
+ 100result.relative_error, result.sigma_virial, result.total_force)
+end
+@printf(" observed order: %.3f\n", young_laplace_2d.observed_order)
+
+println("2D Rayleigh mode-2 stiffness")
+for result in rayleigh_results
+ @printf(" n=%4d omega=%.5f exact=%.5f error=%6.3f%%\n",
+ result.particle_count, result.omega_measured, result.omega_exact,
+ 100result.frequency_error)
+end
+
+if write_results
+ output_path = joinpath(@__DIR__, "validation_reference.json")
+ output = Dict(
+ "young_laplace" => Dict(
+ "observed_order" => young_laplace_2d.observed_order,
+ "results" => [Dict(string(key) => value for (key, value) in pairs(result))
+ for result in young_laplace_2d.results]
+ ),
+ "rayleigh_mode_2" => [Dict(string(key) => value
+ for (key, value) in pairs(result))
+ for result in rayleigh_results]
+ )
+ open(output_path, "w") do io
+ JSON.print(io, output, 2)
+ end
+ println("Wrote $output_path")
+end
diff --git a/validation/surface_tension_3d/contact_angle_cost.csv b/validation/surface_tension_3d/contact_angle_cost.csv
new file mode 100644
index 0000000000..d3fab85193
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_cost.csv
@@ -0,0 +1,10 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,line_angle,shape_acceleration,boundary_shape_acceleration,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,normalized_runtime
+cost,90.0,90.0,none,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.85482756777331,-2.2117561670355883,-2.145172432226687,0.03010446440764014,90.58348911431534,NaN,NaN,0.006322566937236813,0.0,0,998.6348038786263,1000.004952344707,0.0018842532409343899,true,322,42,0.11538461538461539,9.87003411610475e-6,1.2230126506308182e-5,1.2935004106918497,2.5738496457139224,1.0464080530369324,25.21845723,25.849152791,253840,0,0.9872559233382968
+cost,90.0,90.0,geometric,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.69810501918657,-2.2117561670355883,-2.3018949808134295,-0.04075440824865151,90.586582889824,90.00000000000001,NaN,0.005791039899580965,0.0,0,998.5343719232799,1000.0049882754446,0.0016925417536809712,true,323,50,0.13404825737265416,9.540941069913421e-6,1.2230126506308182e-5,1.238323400065117,2.5710108122629625,1.0528795980054548,26.076638469,26.432808597,290088,36192,1.020852130424774
+cost,90.0,90.0,contact_line_force,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,88.04368409678906,-2.2117561670355883,-1.9563159032109354,0.11549205451838707,90.58601651233053,76.36025992336582,79.60596287957348,0.009461805342587993,0.0,0,999.0580748748145,1000.0056780948962,0.001897729306743933,true,322,42,0.11538461538461539,1.051545892930318e-5,1.2230126506308182e-5,1.278689194190031,2.5814606624729017,1.0466719469935308,29.78317802,30.138502555,314312,60320,1.1659562933574414
+cost,90.0,90.0,geometric,1500,1508,0.1,4000.0,0.01,2,87.78824383296441,87.69810501918657,-2.2117561670355883,-2.3018949808134295,-0.04075440824865151,90.586582889824,90.00000000000001,NaN,0.005791039899580965,0.0,0,998.5343719232799,1000.0049882754446,0.0016925417536809712,true,323,50,0.13404825737265416,9.540941069913421e-6,1.2230126506308182e-5,1.238323400065117,2.5710108122629625,1.0528795980054548,26.189435211,26.543370811,290088,36192,1.0252679140969128
+cost,90.0,90.0,contact_line_force,1500,1508,0.1,4000.0,0.01,2,87.78824383296441,88.04368409678906,-2.2117561670355883,-1.9563159032109354,0.11549205451838707,90.58601651233053,76.36025992336582,79.60596287957348,0.009461805342587993,0.0,0,999.0580748748145,1000.0056780948962,0.001897729306743933,true,322,42,0.11538461538461539,1.051545892930318e-5,1.2230126506308182e-5,1.278689194190031,2.5814606624729017,1.0466719469935308,29.955174682,30.324932288,314312,60320,1.1726896443235708
+cost,90.0,90.0,none,1500,1508,0.1,4000.0,0.01,2,87.78824383296441,87.85482756777331,-2.2117561670355883,-2.145172432226687,0.03010446440764014,90.58348911431534,NaN,NaN,0.006322566937236813,0.0,0,998.6348038786263,1000.004952344707,0.0018842532409343899,true,322,42,0.11538461538461539,9.87003411610475e-6,1.2230126506308182e-5,1.2935004106918497,2.5738496457139224,1.0464080530369324,25.724510353,26.095512157,253840,0,1.0070669664425222
+cost,90.0,90.0,contact_line_force,1500,1508,0.1,4000.0,0.01,3,87.78824383296441,88.04368409678906,-2.2117561670355883,-1.9563159032109354,0.11549205451838707,90.58601651233053,76.36025992336582,79.60596287957348,0.009461805342587993,0.0,0,999.0580748748145,1000.0056780948962,0.001897729306743933,true,322,42,0.11538461538461539,1.051545892930318e-5,1.2230126506308182e-5,1.278689194190031,2.5814606624729017,1.0466719469935308,30.352439068,30.719361286,314312,60320,1.1882418097329315
+cost,90.0,90.0,none,1500,1508,0.1,4000.0,0.01,3,87.78824383296441,87.85482756777331,-2.2117561670355883,-2.145172432226687,0.03010446440764014,90.58348911431534,NaN,NaN,0.006322566937236813,0.0,0,998.6348038786263,1000.004952344707,0.0018842532409343899,true,322,42,0.11538461538461539,9.87003411610475e-6,1.2230126506308182e-5,1.2935004106918497,2.5738496457139224,1.0464080530369324,25.54399182,25.908315466,253840,0,1.0
+cost,90.0,90.0,geometric,1500,1508,0.1,4000.0,0.01,3,87.78824383296441,87.69810501918657,-2.2117561670355883,-2.3018949808134295,-0.04075440824865151,90.586582889824,90.00000000000001,NaN,0.005791039899580965,0.0,0,998.5343719232799,1000.0049882754446,0.0016925417536809712,true,323,50,0.13404825737265416,9.540941069913421e-6,1.2230126506308182e-5,1.238323400065117,2.5710108122629625,1.0528795980054548,26.144955996,26.487322666,290088,36192,1.023526635156901
diff --git a/validation/surface_tension_3d/contact_angle_cost_r4_wetted_area.csv b/validation/surface_tension_3d/contact_angle_cost_r4_wetted_area.csv
new file mode 100644
index 0000000000..99138e4082
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_cost_r4_wetted_area.csv
@@ -0,0 +1,7 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,shape_acceleration,boundary_shape_acceleration,wetting_energy,explicit_reaction_residual,density_resultant_residual,total_momentum_residual,max_explicit_reaction_residual,max_density_resultant_residual,max_total_momentum_residual,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,normalized_runtime
+cost,90.0,90.0,none,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.85482764022284,-2.2117561670355883,-2.1451723597771633,0.03010449716420016,90.58348914564176,NaN,0.006322566937281028,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348037418,1000.0049605366512,0.0018842586472753206,true,323,44,0.11989100817438691,9.870034116213684e-6,1.2230126506308182e-5,1.2942784041081006,2.5750478998283373,1.0499302911338562,28.642477199,29.008121261,253816,0,1.0
+cost,90.0,90.0,r4_wetted_area,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.85482764022284,-2.2117561670355883,-2.1451723597771633,0.03010449716420016,90.58348914564176,5.448616776298459,0.006322566937281028,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348037418,1000.0049605366512,0.0018842586472753206,true,323,44,0.11989100817438691,9.870034116213684e-6,1.2230126506308182e-5,1.2942784041081006,2.5750478998283373,1.0499302911338562,29.22883432,29.58848666,290056,209776,1.020471592485739
+cost,90.0,90.0,r4_wetted_area,1500,1508,0.1,4000.0,0.01,2,87.78824383296441,87.85482764022284,-2.2117561670355883,-2.1451723597771633,0.03010449716420016,90.58348914564176,5.448616776298459,0.006322566937281028,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348037418,1000.0049605366512,0.0018842586472753206,true,323,44,0.11989100817438691,9.870034116213684e-6,1.2230126506308182e-5,1.2942784041081006,2.5750478998283373,1.0499302911338562,29.294017765,29.659229625,290056,209776,1.0227473539203078
+cost,90.0,90.0,none,1500,1508,0.1,4000.0,0.01,2,87.78824383296441,87.85482764022284,-2.2117561670355883,-2.1451723597771633,0.03010449716420016,90.58348914564176,NaN,0.006322566937281028,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348037418,1000.0049605366512,0.0018842586472753206,true,323,44,0.11989100817438691,9.870034116213684e-6,1.2230126506308182e-5,1.2942784041081006,2.5750478998283373,1.0499302911338562,28.7115783,29.073017619,253816,0,1.0024125392688594
+cost,90.0,90.0,none,1500,1508,0.1,4000.0,0.01,3,87.78824383296441,87.85482764022284,-2.2117561670355883,-2.1451723597771633,0.03010449716420016,90.58348914564176,NaN,0.006322566937281028,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348037418,1000.0049605366512,0.0018842586472753206,true,323,44,0.11989100817438691,9.870034116213684e-6,1.2230126506308182e-5,1.2942784041081006,2.5750478998283373,1.0499302911338562,28.61919246,28.987552079,253816,0,0.9991870556852249
+cost,90.0,90.0,r4_wetted_area,1500,1508,0.1,4000.0,0.01,3,87.78824383296441,87.85482764022284,-2.2117561670355883,-2.1451723597771633,0.03010449716420016,90.58348914564176,5.448616776298459,0.006322566937281028,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348037418,1000.0049605366512,0.0018842586472753206,true,323,44,0.11989100817438691,9.870034116213684e-6,1.2230126506308182e-5,1.2942784041081006,2.5750478998283373,1.0499302911338562,29.057568609,29.41738002,290056,209776,1.0144921616631153
diff --git a/validation/surface_tension_3d/contact_angle_cost_r4_wetted_area_active.csv b/validation/surface_tension_3d/contact_angle_cost_r4_wetted_area_active.csv
new file mode 100644
index 0000000000..0b88ef83c4
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_cost_r4_wetted_area_active.csv
@@ -0,0 +1,7 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,shape_acceleration,boundary_shape_acceleration,wetting_energy,explicit_reaction_residual,density_resultant_residual,total_momentum_residual,max_explicit_reaction_residual,max_density_resultant_residual,max_total_momentum_residual,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,normalized_runtime
+cost,60.0,60.0,none,1500,1480,0.1,4000.0,0.01,1,59.240154735427446,59.3735407785842,-0.7598452645725544,-0.6264592214158,0.17554369208550613,65.69601879326311,NaN,0.025617239827415973,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,996.8675352730065,1000.004807022048,0.0022958181222382827,true,313,19,0.0572289156626506,9.807417560523462e-6,1.2230126506308182e-5,1.3302637799600283,2.650343000694687,1.0170239636400782,25.094044898,25.681478839,249112,0,1.000090529551986
+cost,60.0,60.0,r4_wetted_area,1500,1480,0.1,4000.0,0.01,1,59.240154735427446,59.310700670496175,-0.7598452645725544,-0.6892993295038252,0.09284250143799255,65.66748571576534,0.0,-6.016613999131602e-5,0.0,-1.0862359312261775e-5,1.216756091193397e-15,5.0158107346353796e-17,1.0167431393093095e-15,1.905932734181908e-15,2.362995557684334e-16,1.593490915171286e-15,0,999.875611776967,1000.004819421663,0.0009227050563320157,true,316,49,0.13424657534246576,9.414030698844364e-6,1.2230126506308182e-5,1.373297669230533,2.606743907413477,1.0395055894400071,32.736431059,33.333330619,284680,287240,1.3046678925822248
+cost,60.0,60.0,r4_wetted_area,1500,1480,0.1,4000.0,0.01,2,59.240154735427446,59.310700670496175,-0.7598452645725544,-0.6892993295038252,0.09284250143799255,65.66748571576534,0.0,-6.016613999131602e-5,0.0,-1.0862359312261775e-5,1.216756091193397e-15,5.0158107346353796e-17,1.0167431393093095e-15,1.905932734181908e-15,2.362995557684334e-16,1.593490915171286e-15,0,999.875611776967,1000.004819421663,0.0009227050563320157,true,316,49,0.13424657534246576,9.414030698844364e-6,1.2230126506308182e-5,1.373297669230533,2.606743907413477,1.0395055894400071,32.741270721,33.335537988,284680,287240,1.3048607710182085
+cost,60.0,60.0,none,1500,1480,0.1,4000.0,0.01,2,59.240154735427446,59.3735407785842,-0.7598452645725544,-0.6264592214158,0.17554369208550613,65.69601879326311,NaN,0.025617239827415973,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,996.8675352730065,1000.004807022048,0.0022958181222382827,true,313,19,0.0572289156626506,9.807417560523462e-6,1.2230126506308182e-5,1.3302637799600283,2.650343000694687,1.0170239636400782,25.091773351,25.67049851,249112,0,1.0
+cost,60.0,60.0,none,1500,1480,0.1,4000.0,0.01,3,59.240154735427446,59.3735407785842,-0.7598452645725544,-0.6264592214158,0.17554369208550613,65.69601879326311,NaN,0.025617239827415973,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,996.8675352730065,1000.004807022048,0.0022958181222382827,true,313,19,0.0572289156626506,9.807417560523462e-6,1.2230126506308182e-5,1.3302637799600283,2.650343000694687,1.0170239636400782,25.091369695,25.666394828,249112,0,0.9999839128149951
+cost,60.0,60.0,r4_wetted_area,1500,1480,0.1,4000.0,0.01,3,59.240154735427446,59.310700670496175,-0.7598452645725544,-0.6892993295038252,0.09284250143799255,65.66748571576534,0.0,-6.016613999131602e-5,0.0,-1.0862359312261775e-5,1.216756091193397e-15,5.0158107346353796e-17,1.0167431393093095e-15,1.905932734181908e-15,2.362995557684334e-16,1.593490915171286e-15,0,999.875611776967,1000.004819421663,0.0009227050563320157,true,316,49,0.13424657534246576,9.414030698844364e-6,1.2230126506308182e-5,1.373297669230533,2.606743907413477,1.0395055894400071,32.68203112,33.284356602,284680,287240,1.3024998537497747
diff --git a/validation/surface_tension_3d/contact_angle_cost_r4_wetted_area_active_production.csv b/validation/surface_tension_3d/contact_angle_cost_r4_wetted_area_active_production.csv
new file mode 100644
index 0000000000..1c1d14c1bb
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_cost_r4_wetted_area_active_production.csv
@@ -0,0 +1,7 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,shape_acceleration,boundary_shape_acceleration,wetting_energy,explicit_reaction_residual,density_resultant_residual,total_momentum_residual,max_explicit_reaction_residual,max_density_resultant_residual,max_total_momentum_residual,force_consistency_residual,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,normalized_runtime
+cost,60.0,60.0,none,1500,1480,0.1,4000.0,0.01,1,59.240154735427446,59.373540778584136,-0.7598452645725544,-0.626459221415864,0.17554369208542198,65.69601879326895,NaN,0.025617239827415966,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,996.8675352730007,1000.0048070220627,0.0022958181220983013,true,313,19,0.0572289156626506,9.807417560523447e-6,1.2230126506308182e-5,1.330263779960027,2.6503430149974263,1.0170264902156605,24.741918526,25.318581221,249112,0,0.9956705505474259
+cost,60.0,60.0,wetted_area_production,1500,1480,0.1,4000.0,0.01,1,59.240154735427446,59.31070040812313,-0.7598452645725544,-0.6892995918768676,0.0928421561400028,65.66748586166771,NaN,-6.0138113942466864e-5,0.0,-1.0862366326086675e-5,1.0247492582557004e-15,1.335579435011094e-16,1.1859543178325947e-15,1.0247492582557004e-15,1.335579435011094e-16,1.1859543178325947e-15,1.2461124944102086e-18,0,999.8756129247647,1000.0048195854953,0.0009226999890674609,true,315,55,0.14864864864864866,9.414055702017114e-6,1.2230126506308182e-5,1.3724904429934914,2.610699719275579,1.015882284414469,28.793039105,29.451440705,261080,316848,1.158696770724663
+cost,60.0,60.0,wetted_area_production,1500,1480,0.1,4000.0,0.01,2,59.240154735427446,59.31070040812313,-0.7598452645725544,-0.6892995918768676,0.0928421561400028,65.66748586166771,NaN,-6.0138113942466864e-5,0.0,-1.0862366326086675e-5,1.0247492582557004e-15,1.335579435011094e-16,1.1859543178325947e-15,1.0247492582557004e-15,1.335579435011094e-16,1.1859543178325947e-15,1.2461124944102086e-18,0,999.8756129247647,1000.0048195854953,0.0009226999890674609,true,315,55,0.14864864864864866,9.414055702017114e-6,1.2230126506308182e-5,1.3724904429934914,2.610699719275579,1.015882284414469,28.897576709,29.572412327,261080,316848,1.1629035994561623
+cost,60.0,60.0,none,1500,1480,0.1,4000.0,0.01,2,59.240154735427446,59.373540778584136,-0.7598452645725544,-0.626459221415864,0.17554369208542198,65.69601879326895,NaN,0.025617239827415966,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,996.8675352730007,1000.0048070220627,0.0022958181220983013,true,313,19,0.0572289156626506,9.807417560523447e-6,1.2230126506308182e-5,1.330263779960027,2.6503430149974263,1.0170264902156605,24.849503194,25.401852288,249112,0,1.0
+cost,60.0,60.0,none,1500,1480,0.1,4000.0,0.01,3,59.240154735427446,59.373540778584136,-0.7598452645725544,-0.626459221415864,0.17554369208542198,65.69601879326895,NaN,0.025617239827415966,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,996.8675352730007,1000.0048070220627,0.0022958181220983013,true,313,19,0.0572289156626506,9.807417560523447e-6,1.2230126506308182e-5,1.330263779960027,2.6503430149974263,1.0170264902156605,24.925173567,25.493986941,249112,0,1.003045146311749
+cost,60.0,60.0,wetted_area_production,1500,1480,0.1,4000.0,0.01,3,59.240154735427446,59.31070040812313,-0.7598452645725544,-0.6892995918768676,0.0928421561400028,65.66748586166771,NaN,-6.0138113942466864e-5,0.0,-1.0862366326086675e-5,1.0247492582557004e-15,1.335579435011094e-16,1.1859543178325947e-15,1.0247492582557004e-15,1.335579435011094e-16,1.1859543178325947e-15,1.2461124944102086e-18,0,999.8756129247647,1000.0048195854953,0.0009226999890674609,true,315,55,0.14864864864864866,9.414055702017114e-6,1.2230126506308182e-5,1.3724904429934914,2.610699719275579,1.015882284414469,28.816428413,29.478090338,261080,316848,1.159638009179911
diff --git a/validation/surface_tension_3d/contact_angle_cost_r4_wetted_area_production.csv b/validation/surface_tension_3d/contact_angle_cost_r4_wetted_area_production.csv
new file mode 100644
index 0000000000..6a3806f605
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_cost_r4_wetted_area_production.csv
@@ -0,0 +1,7 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,shape_acceleration,boundary_shape_acceleration,wetting_energy,explicit_reaction_residual,density_resultant_residual,total_momentum_residual,max_explicit_reaction_residual,max_density_resultant_residual,max_total_momentum_residual,force_consistency_residual,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,normalized_runtime
+cost,90.0,90.0,none,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.8548276025082,-2.2117561670355883,-2.1451723974918053,0.03010448011230149,90.58348913131027,NaN,0.006322566937281027,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348038162945,1000.0049560481513,0.0018842555157376594,true,323,45,0.12228260869565218,9.870034116213688e-6,1.2230126506308182e-5,1.2942784041080995,2.5682567242380516,1.0455498620055843,29.237147029,29.60730006,253816,0,0.9983291545074118
+cost,90.0,90.0,wetted_area_production,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.8548276025082,-2.2117561670355883,-2.1451723974918053,0.03010448011230149,90.58348913131027,NaN,0.006322566937281027,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348038162945,1000.0049560481513,0.0018842555157376594,true,323,45,0.12228260869565218,9.870034116213688e-6,1.2230126506308182e-5,1.2942784041080995,2.5682567242380516,1.0455498620055843,29.414880796,29.871575301,266008,198992,1.0043980367126601
+cost,90.0,90.0,wetted_area_production,1500,1508,0.1,4000.0,0.01,2,87.78824383296441,87.8548276025082,-2.2117561670355883,-2.1451723974918053,0.03010448011230149,90.58348913131027,NaN,0.006322566937281027,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348038162945,1000.0049560481513,0.0018842555157376594,true,323,45,0.12228260869565218,9.870034116213688e-6,1.2230126506308182e-5,1.2942784041080995,2.5682567242380516,1.0455498620055843,29.256105717,29.709950985,266008,198992,0.9989765162675328
+cost,90.0,90.0,none,1500,1508,0.1,4000.0,0.01,2,87.78824383296441,87.8548276025082,-2.2117561670355883,-2.1451723974918053,0.03010448011230149,90.58348913131027,NaN,0.006322566937281027,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348038162945,1000.0049560481513,0.0018842555157376594,true,323,45,0.12228260869565218,9.870034116213688e-6,1.2230126506308182e-5,1.2942784041080995,2.5682567242380516,1.0455498620055843,29.286079543,29.647507322,253816,0,1.0
+cost,90.0,90.0,none,1500,1508,0.1,4000.0,0.01,3,87.78824383296441,87.8548276025082,-2.2117561670355883,-2.1451723974918053,0.03010448011230149,90.58348913131027,NaN,0.006322566937281027,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348038162945,1000.0049560481513,0.0018842555157376594,true,323,45,0.12228260869565218,9.870034116213688e-6,1.2230126506308182e-5,1.2942784041080995,2.5682567242380516,1.0455498620055843,29.340394483,29.701284814,253816,0,1.0018546333564469
+cost,90.0,90.0,wetted_area_production,1500,1508,0.1,4000.0,0.01,3,87.78824383296441,87.8548276025082,-2.2117561670355883,-2.1451723974918053,0.03010448011230149,90.58348913131027,NaN,0.006322566937281027,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348038162945,1000.0049560481513,0.0018842555157376594,true,323,45,0.12228260869565218,9.870034116213688e-6,1.2230126506308182e-5,1.2942784041080995,2.5682567242380516,1.0455498620055843,29.431150303,29.886821485,266008,198992,1.0049535739253523
diff --git a/validation/surface_tension_3d/contact_angle_decision.jl b/validation/surface_tension_3d/contact_angle_decision.jl
new file mode 100644
index 0000000000..03e3969307
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_decision.jl
@@ -0,0 +1,3467 @@
+using CSV
+using DataFrames
+using Printf
+using Statistics
+
+include(joinpath(@__DIR__, "..", "..", "compare_akinci", "css_sessile_drop.jl"))
+
+const PHASE2_MATRIX = joinpath(@__DIR__, "sessile_drop_matrix.csv")
+const PHASE2_SENSITIVITY = joinpath(@__DIR__, "sessile_drop_sensitivity.csv")
+const SCORECARD_PATH = joinpath(@__DIR__, "contact_angle_scorecard.csv")
+const THRESHOLD_PATH = joinpath(@__DIR__, "contact_angle_threshold_replay.csv")
+const PERTURBATION_PATH = joinpath(@__DIR__, "contact_angle_perturbation.csv")
+const TIMESTEP_PATH = joinpath(@__DIR__, "contact_angle_timestep.csv")
+const COST_PATH = joinpath(@__DIR__, "contact_angle_cost.csv")
+const SELECTED_PATH = joinpath(@__DIR__, "contact_angle_selected_matrix.csv")
+const NORMAL_COMPONENTS_PATH = joinpath(@__DIR__, "contact_angle_normal_components.csv")
+const FORCE_SIGN_PATH = joinpath(@__DIR__, "contact_angle_force_sign.csv")
+const GHOST_FORCE_SIGN_PATH = joinpath(@__DIR__,
+ "contact_angle_force_sign_ghost_geometric.csv")
+const WALL_ENERGY_FORCE_SIGN_PATH = joinpath(@__DIR__,
+ "contact_angle_force_sign_wall_energy.csv")
+const LINE_NORMALIZATION_PATH = joinpath(@__DIR__, "contact_line_normalization.csv")
+const CAP_TRANSFER_PATH = joinpath(@__DIR__, "contact_line_cap_transfer.csv")
+const WETTED_AREA_PATH = joinpath(@__DIR__, "wetted_area_measure.csv")
+const WETTED_AREA_FORCE_SIGN_PATH = joinpath(@__DIR__,
+ "contact_angle_force_sign_wetted_area.csv")
+const RECOVERY_COMPARISON_PATH = joinpath(@__DIR__,
+ "contact_angle_recovery_comparison.csv")
+const MEASURE_PROTOCOL_PATH = joinpath(@__DIR__, "contact_measure_protocol.csv")
+const EXTENDED_CAP_PATH = joinpath(@__DIR__, "contact_angle_recovery_extended.csv")
+const CORRECTED_WETTED_AREA_PATH = joinpath(@__DIR__, "wetted_area_corrected.csv")
+const EXTENDED_FORCE_SIGN_PATH = joinpath(@__DIR__,
+ "contact_angle_force_sign_extended.csv")
+const EXTENDED_COMPARISON_PATH = joinpath(@__DIR__,
+ "contact_angle_recovery_extended_comparison.csv")
+const R4_STATIC_PATH = joinpath(@__DIR__, "contact_angle_static_r4_wetted_area.csv")
+const R4_PERTURBATION_PATH = joinpath(@__DIR__,
+ "contact_angle_perturbation_r4_wetted_area.csv")
+const R4_PERTURBATION_CLASSIFIED_PATH = joinpath(@__DIR__,
+ "contact_angle_perturbation_r4_wetted_area_classified.csv")
+const R4_PERTURBATION_EXTENDED_PATH = joinpath(@__DIR__,
+ "contact_angle_perturbation_r4_wetted_area_extended.csv")
+const R4_THRESHOLD_PATH = joinpath(@__DIR__,
+ "contact_angle_threshold_r4_wetted_area.csv")
+const R4_TIMESTEP_PATH = joinpath(@__DIR__,
+ "contact_angle_timestep_r4_wetted_area.csv")
+const R4_COST_PATH = joinpath(@__DIR__, "contact_angle_cost_r4_wetted_area.csv")
+const R4_ACTIVE_COST_PATH = joinpath(@__DIR__,
+ "contact_angle_cost_r4_wetted_area_active.csv")
+const R4_SELECTED_PATH = joinpath(@__DIR__,
+ "contact_angle_selected_matrix_r4_wetted_area.csv")
+const R4_SENSITIVITY_PATH = joinpath(@__DIR__,
+ "contact_angle_sensitivity_r4_wetted_area.csv")
+const PRODUCTION_STATIC_PATH = joinpath(@__DIR__,
+ "contact_angle_static_r4_wetted_area_production.csv")
+const PRODUCTION_PERTURBATION_PATH = joinpath(@__DIR__,
+ "contact_angle_perturbation_r4_wetted_area_production.csv")
+const PRODUCTION_PERTURBATION_EXTENDED_PATH = joinpath(@__DIR__,
+ "contact_angle_perturbation_r4_wetted_area_extended_production.csv")
+const PRODUCTION_THRESHOLD_PATH = joinpath(@__DIR__,
+ "contact_angle_threshold_r4_wetted_area_production.csv")
+const PRODUCTION_TIMESTEP_PATH = joinpath(@__DIR__,
+ "contact_angle_timestep_r4_wetted_area_production.csv")
+const PRODUCTION_COST_PATH = joinpath(@__DIR__,
+ "contact_angle_cost_r4_wetted_area_production.csv")
+const PRODUCTION_ACTIVE_COST_PATH = joinpath(@__DIR__,
+ "contact_angle_cost_r4_wetted_area_active_production.csv")
+const PRODUCTION_SELECTED_PATH = joinpath(@__DIR__,
+ "contact_angle_selected_matrix_r4_wetted_area_production.csv")
+const PRODUCTION_SENSITIVITY_PATH = joinpath(@__DIR__,
+ "contact_angle_sensitivity_r4_wetted_area_production.csv")
+
+# These labels identify committed pre-D5 evidence. They are not runnable model selectors.
+const REJECTED_MECHANISMS = (:geometric, :contact_line_force)
+const HISTORICAL_MECHANISMS = (:none, REJECTED_MECHANISMS...)
+const TARGET_ANGLES = (30.0, 60.0, 90.0, 120.0, 150.0)
+const RESOLUTIONS = (750, 1500, 3000)
+const LINE_STUDY_RESOLUTIONS = (2, 4, 8)
+const CAP_PHASES = ((-0.4375, -0.4375), (-0.3125, -0.0625),
+ (-0.1875, 0.3125), (-0.0625, -0.3125),
+ (0.0625, 0.0625), (0.1875, 0.4375),
+ (0.3125, -0.1875), (0.4375, 0.1875))
+const FIXED_H_CELLS_PER_H = (1.4, 2.8, 4.2)
+const LINE_STUDY_KERNELS = (("gaussian", TrixiParticles.GaussianKernel{3}()),
+ ("schoenberg_cubic",
+ TrixiParticles.SchoenbergCubicSplineKernel{3}()),
+ ("schoenberg_quartic",
+ TrixiParticles.SchoenbergQuarticSplineKernel{3}()),
+ ("schoenberg_quintic",
+ TrixiParticles.SchoenbergQuinticSplineKernel{3}()),
+ ("wendland_c2", TrixiParticles.WendlandC2Kernel{3}()),
+ ("wendland_c4", TrixiParticles.WendlandC4Kernel{3}()),
+ ("wendland_c6", TrixiParticles.WendlandC6Kernel{3}()),
+ ("poly6", TrixiParticles.Poly6Kernel{3}()),
+ ("spiky", TrixiParticles.SpikyKernel{3}()),
+ ("laguerre_gauss",
+ TrixiParticles.LaguerreGaussKernel{3}()))
+
+require(condition, message) = condition || error(message)
+
+function quiet_css_sessile_drop(args...; kwargs...)
+ return redirect_stdout(devnull) do
+ redirect_stderr(devnull) do
+ css_sessile_drop(args...; kwargs...)
+ end
+ end
+end
+
+function weighted_quantile(values, weights, probability)
+ isempty(values) && return NaN
+ order = sortperm(values)
+ sorted_values = values[order]
+ cumulative_weight = cumsum(weights[order])
+ total_weight = last(cumulative_weight)
+ total_weight > 0 || return NaN
+ index = searchsortedfirst(cumulative_weight, probability * total_weight)
+ return sorted_values[clamp(index, 1, length(sorted_values))]
+end
+
+function raw_normal_components(result)
+ semi = result.solution.prob.p.semi
+ system = semi.systems[1]
+ v_ode, u_ode = result.solution.prob.u0.x
+
+ # First retain the production contact-line weights at the initial state.
+ TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, 0.0)
+ line_delta = haskey(system.cache, :contact_line_delta) ?
+ copy(system.cache.contact_line_delta) :
+ zeros(eltype(system), TrixiParticles.nparticles(system))
+ surface_delta = copy(system.cache.delta_s)
+ v = TrixiParticles.wrap_v(v_ode, system, semi)
+ density = collect(TrixiParticles.current_density(v, system))
+ volume = system.mass ./ density
+
+ # Repeat only the raw normal accumulation and stop before contact-angle application and
+ # normalization. The boundary contribution is accumulated identically in both arrays.
+ method = TrixiParticles.surface_normal_method(system)
+ u = TrixiParticles.wrap_u(u_ode, system, semi)
+ GC.@preserve v_ode u_ode begin
+ TrixiParticles.set_zero!(system.cache.surface_normal)
+ TrixiParticles.set_zero!(system.cache.neighbor_count)
+ TrixiParticles.reset_surface_divergence_correction!(system,
+ system.surface_tension)
+ TrixiParticles.reset_boundary_normal!(system, method)
+ TrixiParticles.foreach_system(semi) do neighbor_system
+ u_neighbor = TrixiParticles.wrap_u(u_ode, neighbor_system, semi)
+ v_neighbor = TrixiParticles.wrap_v(v_ode, neighbor_system, semi)
+ TrixiParticles.calc_normal!(system, neighbor_system, u, v,
+ v_neighbor, u_neighbor, semi, method,
+ TrixiParticles.surface_normal_method(neighbor_system))
+ end
+ end
+
+ total_normal = copy(system.cache.surface_normal)
+ wall_normal = copy(system.cache.boundary_normal)
+ fluid_normal = total_normal - wall_normal
+ support_moment = copy(system.cache.divergence_correction)
+ coordinates = Array(TrixiParticles.current_coordinates(u, system))
+ return (; total_normal, wall_normal, fluid_normal, line_delta, surface_delta,
+ support_moment, volume, coordinates)
+end
+
+function shape_acceleration_from_caches(result)
+ semi = result.solution.prob.p.semi
+ system = semi.systems[1]
+ v_ode, u_ode = result.solution.prob.u0.x
+ v = TrixiParticles.wrap_v(v_ode, system, semi)
+ u = TrixiParticles.wrap_u(u_ode, system, semi)
+ dv = zeros(eltype(v), size(v))
+ TrixiParticles.reset_interaction_caches!(semi)
+ GC.@preserve v_ode u_ode begin
+ TrixiParticles.foreach_system(semi) do neighbor_system
+ v_neighbor = TrixiParticles.wrap_v(v_ode, neighbor_system, semi)
+ u_neighbor = TrixiParticles.wrap_u(u_ode, neighbor_system, semi)
+ TrixiParticles.interact!(dv, v, u, v_neighbor, u_neighbor,
+ system, neighbor_system, semi)
+ end
+ end
+ coordinates = Array(TrixiParticles.current_coordinates(u, system))
+ return cap_shape_acceleration(Array(dv[1:3, :]), coordinates)
+end
+
+function ghost_geometric_shape_acceleration(result, components, target)
+ system = result.solution.prob.p.semi.systems[1]
+ (; total_normal, wall_normal, surface_delta) = components
+ contact_sine = sind(target)
+ abs(contact_sine) > sqrt(eps()) || return NaN
+ contact_cotangent = cosd(target) / contact_sine
+
+ for particle in TrixiParticles.eachparticle(system)
+ raw_total = total_normal[:, particle]
+ total_norm = norm(raw_total)
+ if total_norm <= eps(total_norm)
+ system.cache.surface_normal[:, particle] .= 0
+ system.cache.delta_s[particle] = 0
+ continue
+ end
+ activity = surface_delta[particle] / (2total_norm)
+ raw_wall = wall_normal[:, particle]
+ wall_norm = norm(raw_wall)
+ desired_normal = raw_total
+ if wall_norm > eps(wall_norm)
+ wall = raw_wall / wall_norm
+ tangent = raw_total - dot(raw_total, wall) * wall
+ tangent_norm = norm(tangent)
+ if tangent_norm > eps(tangent_norm)
+ desired_normal = tangent + tangent_norm * contact_cotangent * wall
+ end
+ end
+ desired_norm = norm(desired_normal)
+ if desired_norm > eps(desired_norm)
+ system.cache.surface_normal[:, particle] .= desired_normal / desired_norm
+ system.cache.delta_s[particle] = 2desired_norm * activity
+ else
+ system.cache.surface_normal[:, particle] .= 0
+ system.cache.delta_s[particle] = 0
+ end
+ end
+ return shape_acceleration_from_caches(result)
+end
+
+function wall_energy_shape_acceleration(result, target; line_delta_scale=1.0)
+ semi = result.solution.prob.p.semi
+ system = semi.systems[1]
+ v_ode, u_ode = result.solution.prob.u0.x
+ TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, 0.0)
+ v = TrixiParticles.wrap_v(v_ode, system, semi)
+ u = TrixiParticles.wrap_u(u_ode, system, semi)
+ coordinates = Array(TrixiParticles.current_coordinates(u, system))
+ acceleration = zeros(eltype(system), 3, TrixiParticles.nparticles(system))
+ sigma = system.surface_tension.surface_tension_coefficient
+ for particle in TrixiParticles.eachparticle(system)
+ delta = system.cache.contact_line_delta[particle]
+ delta > eps(delta) || continue
+ wall = system.cache.boundary_normal[:, particle]
+ interface = system.cache.surface_normal[:, particle]
+ dynamic_cosine = dot(wall, interface)
+ tangent = interface - dynamic_cosine * wall
+ tangent_norm = norm(tangent)
+ tangent_norm > eps(tangent_norm) || continue
+ direction = -tangent / tangent_norm
+ density = TrixiParticles.current_density(v, system, particle)
+ acceleration[:,
+ particle] .= sigma / density * cosd(target) *
+ line_delta_scale * delta * direction
+ end
+ return cap_shape_acceleration(acceleration, coordinates)
+end
+
+function kernel_plane_profile(kernel; samples=20_000)
+ smoothing_length = 1.0
+ support = TrixiParticles.compact_support(kernel, smoothing_length)
+ spacing = support / samples
+ radii = collect(range(0.0, support; length=samples + 1))
+ values = TrixiParticles.kernel.(Ref(kernel), radii, smoothing_length)
+
+ radial_integrand = 2pi .* radii .* values
+ convolution_profile = zeros(length(radii))
+ for index in samples:-1:1
+ convolution_profile[index] = convolution_profile[index + 1] +
+ spacing * (radial_integrand[index] +
+ radial_integrand[index + 1]) / 2
+ end
+ halfspace_color_profile = zeros(length(radii))
+ for index in samples:-1:1
+ halfspace_color_profile[index] = halfspace_color_profile[index + 1] +
+ spacing * (convolution_profile[index] +
+ convolution_profile[index + 1]) / 2
+ end
+ kernel_half_profile_integral = spacing *
+ (sum(convolution_profile) -
+ (convolution_profile[1] +
+ convolution_profile[end]) / 2)
+ mass_integrand = 4pi .* radii .^ 2 .* values
+ kernel_mass = spacing *
+ (sum(mass_integrand) -
+ (mass_integrand[1] + mass_integrand[end]) / 2)
+
+ # `kernel_grad` differentiates only inside the strict compact support. Account for a
+ # nonzero value at the truncation radius (notably Laguerre-Gauss) when deriving the
+ # half-space gradient represented by that operator.
+ cutoff_value = TrixiParticles.kernel(kernel, prevfloat(support), smoothing_length)
+ gradient_profile = convolution_profile .-
+ pi .* (support^2 .- radii .^ 2) .* cutoff_value
+ half_profile_integral = spacing *
+ (sum(gradient_profile) -
+ (gradient_profile[1] + gradient_profile[end]) / 2)
+ return (; support, spacing, profile=gradient_profile,
+ halfspace_color_profile, half_profile_integral,
+ kernel_half_profile_integral, kernel_mass, cutoff_value)
+end
+
+@inline function plane_profile_value(profile_data, distance)
+ distance >= profile_data.support && return 0.0
+ coordinate = max(distance, 0.0) / profile_data.spacing
+ lower = floor(Int, coordinate) + 1
+ fraction = coordinate - floor(coordinate)
+ return (1 - fraction) * profile_data.profile[lower] +
+ fraction * profile_data.profile[lower + 1]
+end
+
+@inline function halfspace_color_value(profile_data, distance)
+ distance >= profile_data.support && return 0.0
+ coordinate = max(distance, 0.0) / profile_data.spacing
+ lower = floor(Int, coordinate) + 1
+ fraction = coordinate - floor(coordinate)
+ return (1 - fraction) * profile_data.halfspace_color_profile[lower] +
+ fraction * profile_data.halfspace_color_profile[lower + 1]
+end
+
+function kernel_lattice_offsets(kernel, cells_per_h)
+ lattice_spacing = 1 / cells_per_h
+ support = TrixiParticles.compact_support(kernel, 1.0)
+ search_radius = ceil(Int, support / lattice_spacing)
+ offsets = NamedTuple[]
+ for z_offset in (-search_radius):search_radius,
+ x_offset in (-search_radius):search_radius
+ planar_distance2 = lattice_spacing^2 * (x_offset^2 + z_offset^2)
+ planar_distance2 < support^2 || continue
+ gradient_x = 0.0
+ gradient_z = 0.0
+ kernel_value = 0.0
+ for tangent_offset in (-search_radius):search_radius
+ pos_diff = lattice_spacing *
+ SVector(x_offset, tangent_offset, z_offset)
+ distance = norm(pos_diff)
+ distance < support || continue
+ gradient = TrixiParticles.kernel_grad(kernel, pos_diff, distance, 1.0)
+ gradient_x += lattice_spacing * gradient[1]
+ gradient_z += lattice_spacing * gradient[3]
+ kernel_value += lattice_spacing *
+ TrixiParticles.kernel(kernel, distance, 1.0)
+ end
+ push!(offsets, (; x_offset, z_offset, gradient_x, gradient_z, kernel_value))
+ end
+ return offsets
+end
+
+function planar_line_measure_row(kernel_name, profile_data, offsets, angle, cells_per_h)
+ contact_sine = sind(angle)
+ contact_cosine = cosd(angle)
+ lattice_spacing = 1 / cells_per_h
+ support = profile_data.support
+ horizontal_radius = support * (1 + abs(contact_cosine)) / contact_sine +
+ 2lattice_spacing
+ horizontal_cells = ceil(Int, horizontal_radius / lattice_spacing)
+ vertical_cells = ceil(Int, support / lattice_spacing)
+ profile_coarea_measure = 0.0
+ coarea_measure = 0.0
+ wedge_coarea_measure = 0.0
+ gated_coarea_measure = 0.0
+ divergence_measure = 0.0
+ phase_fractions = (0.0, 0.25, 0.5, 0.75)
+
+ for phase_fraction in phase_fractions
+ interface_shift = phase_fraction * lattice_spacing * contact_sine
+ boundary_colorfield = Dict{Tuple{Int, Int}, Float64}()
+ wall_layers = ceil(Int, support / lattice_spacing)
+ flooded_colorfield = 0.0
+ for wall_index in (-wall_layers):-1
+ wall_position = (wall_index + 0.5) * lattice_spacing
+ value = 0.0
+ for offset in offsets
+ fluid_wall_position = wall_position -
+ offset.z_offset * lattice_spacing
+ fluid_wall_position > 0 || continue
+ value += lattice_spacing^2 * offset.kernel_value
+ end
+ flooded_colorfield = max(flooded_colorfield, value)
+ end
+
+ function boundary_colorfield_value(x_index, wall_index)
+ return get!(boundary_colorfield, (x_index, wall_index)) do
+ boundary_x = (x_index + 0.5) * lattice_spacing
+ boundary_wall_position = (wall_index + 0.5) * lattice_spacing
+ value = 0.0
+ for source_offset in offsets
+ fluid_x = boundary_x -
+ source_offset.x_offset * lattice_spacing
+ fluid_wall_position = boundary_wall_position -
+ source_offset.z_offset * lattice_spacing
+ fluid_interface_position = contact_sine * fluid_x +
+ contact_cosine * fluid_wall_position -
+ interface_shift
+ fluid_wall_position > 0 && fluid_interface_position > 0 ||
+ continue
+ value += lattice_spacing^2 * source_offset.kernel_value
+ end
+ value
+ end
+ end
+
+ for j in 0:(vertical_cells - 1)
+ wall_distance = (j + 0.5) * lattice_spacing
+ wall_distance < support || continue
+ wall_profile = plane_profile_value(profile_data, wall_distance)
+ for i in (-horizontal_cells):(horizontal_cells - 1)
+ x = (i + 0.5) * lattice_spacing
+ interface_distance = contact_sine * x +
+ contact_cosine * wall_distance - interface_shift
+ 0 < interface_distance < support || continue
+ interface_profile = plane_profile_value(profile_data, interface_distance)
+ profile_coarea_measure += lattice_spacing^2 * contact_sine *
+ interface_profile * wall_profile
+
+ interface_gradient_x = 0.0
+ interface_gradient_z = 0.0
+ wedge_gradient_x = 0.0
+ wedge_gradient_z = 0.0
+ wall_gradient_x = 0.0
+ wall_gradient_z = 0.0
+ gated_wall_gradient_x = 0.0
+ gated_wall_gradient_z = 0.0
+ divergence_gradient = 0.0
+ delta_prime_a = contact_sine * interface_profile
+ for offset in offsets
+ neighbor_x = x - offset.x_offset * lattice_spacing
+ neighbor_wall_distance = wall_distance -
+ offset.z_offset * lattice_spacing
+ neighbor_interface_distance = contact_sine * neighbor_x +
+ contact_cosine *
+ neighbor_wall_distance -
+ interface_shift
+ if neighbor_interface_distance > 0
+ interface_gradient_x += lattice_spacing^2 * offset.gradient_x
+ interface_gradient_z += lattice_spacing^2 * offset.gradient_z
+ if neighbor_wall_distance > 0
+ wedge_gradient_x += lattice_spacing^2 * offset.gradient_x
+ wedge_gradient_z += lattice_spacing^2 * offset.gradient_z
+ end
+ end
+ if neighbor_wall_distance < 0
+ wall_gradient_x += lattice_spacing^2 * offset.gradient_x
+ wall_gradient_z += lattice_spacing^2 * offset.gradient_z
+ neighbor_x_index = i - offset.x_offset
+ neighbor_wall_index = j - offset.z_offset
+ color_fraction = boundary_colorfield_value(neighbor_x_index,
+ neighbor_wall_index) /
+ flooded_colorfield
+ if color_fraction > 0.1
+ gated_wall_gradient_x += lattice_spacing^2 *
+ offset.gradient_x
+ gated_wall_gradient_z += lattice_spacing^2 *
+ offset.gradient_z
+ end
+ delta_prime_b = 0.0
+ elseif neighbor_interface_distance > 0
+ delta_prime_b = contact_sine *
+ plane_profile_value(profile_data,
+ neighbor_interface_distance)
+ else
+ # The one-phase operator has no gas neighbors.
+ continue
+ end
+ divergence_gradient += lattice_spacing^2 *
+ (delta_prime_b - delta_prime_a) *
+ offset.gradient_z
+ end
+ coarea_measure += lattice_spacing^2 *
+ abs(interface_gradient_x * wall_gradient_z -
+ interface_gradient_z * wall_gradient_x)
+ wedge_coarea_measure += lattice_spacing^2 *
+ abs(wedge_gradient_x * wall_gradient_z -
+ wedge_gradient_z * wall_gradient_x)
+ gated_coarea_measure += lattice_spacing^2 *
+ abs(wedge_gradient_x * gated_wall_gradient_z -
+ wedge_gradient_z * gated_wall_gradient_x)
+ divergence_measure += lattice_spacing^2 *
+ max(2divergence_gradient, 0.0)
+ end
+ end
+ end
+ profile_coarea_measure /= length(phase_fractions)
+ coarea_measure /= length(phase_fractions)
+ wedge_coarea_measure /= length(phase_fractions)
+ gated_coarea_measure /= length(phase_fractions)
+ divergence_measure /= length(phase_fractions)
+
+ coarea_normalization = inv(profile_data.half_profile_integral^2)
+ divergence_normalization = inv(profile_data.half_profile_integral)
+ normalized_coarea = coarea_normalization * coarea_measure
+ normalized_wedge_coarea = coarea_normalization * wedge_coarea_measure
+ normalized_gated_coarea = coarea_normalization * gated_coarea_measure
+ normalized_divergence = divergence_normalization * divergence_measure
+ return (; kernel=kernel_name, angle, cells_per_h, lattice_spacing,
+ phase_count=length(phase_fractions),
+ support, kernel_mass=profile_data.kernel_mass,
+ cutoff_value=profile_data.cutoff_value,
+ kernel_half_profile_integral=profile_data.kernel_half_profile_integral,
+ half_profile_integral=profile_data.half_profile_integral,
+ half_profile_mass_error=abs(2profile_data.half_profile_integral /
+ profile_data.kernel_mass - 1),
+ coarea_normalization, divergence_normalization,
+ continuous_coarea=coarea_normalization *
+ profile_data.half_profile_integral^2,
+ profile_coarea_measure,
+ normalized_profile_coarea=coarea_normalization * profile_coarea_measure,
+ profile_coarea_error=abs(coarea_normalization * profile_coarea_measure - 1),
+ coarea_measure, normalized_coarea,
+ coarea_error=abs(normalized_coarea - 1),
+ wedge_coarea_measure, normalized_wedge_coarea,
+ wedge_coarea_error=abs(normalized_wedge_coarea - 1),
+ gated_coarea_measure, normalized_gated_coarea,
+ gated_coarea_error=abs(normalized_gated_coarea - 1),
+ divergence_measure, normalized_divergence,
+ divergence_error=abs(normalized_divergence - 1))
+end
+
+function run_line_normalization_study(; output_path=LINE_NORMALIZATION_PATH)
+ rows = NamedTuple[]
+ for (kernel_name, kernel) in LINE_STUDY_KERNELS
+ profile_data = kernel_plane_profile(kernel)
+ for cells_per_h in LINE_STUDY_RESOLUTIONS
+ offsets = kernel_lattice_offsets(kernel, cells_per_h)
+ for angle in TARGET_ANGLES
+ push!(rows,
+ planar_line_measure_row(kernel_name, profile_data, offsets,
+ angle, cells_per_h))
+ end
+ end
+ end
+ data = DataFrame(rows)
+ gate_columns = ((:coarea_error, :coarea_middle_pass,
+ :coarea_endpoint_decreasing),
+ (:wedge_coarea_error, :wedge_middle_pass,
+ :wedge_endpoint_decreasing),
+ (:gated_coarea_error, :gated_middle_pass,
+ :gated_endpoint_decreasing),
+ (:divergence_error, :divergence_middle_pass,
+ :divergence_endpoint_decreasing))
+ for (_, middle_column, endpoint_column) in gate_columns
+ data[!, middle_column] = falses(nrow(data))
+ data[!, endpoint_column] = falses(nrow(data))
+ end
+ middle_resolution = LINE_STUDY_RESOLUTIONS[2]
+ for indices in groupby(data, [:kernel, :angle]; sort=true)
+ order = sortperm(indices.cells_per_h)
+ first_index = parentindices(indices)[1][order[1]]
+ middle_index = parentindices(indices)[1][order[2]]
+ last_index = parentindices(indices)[1][order[3]]
+ parent_rows = parentindices(indices)[1]
+ for (error_column, middle_column, endpoint_column) in gate_columns
+ data[parent_rows, middle_column] .= data[middle_index, error_column] <= 0.2
+ data[parent_rows,
+ endpoint_column] .= data[last_index, error_column] <
+ data[first_index, error_column] ||
+ data[last_index, error_column] <= 1.0e-12
+ end
+ require(data.cells_per_h[middle_index] == middle_resolution,
+ "unexpected line-study resolution order")
+ end
+
+ CSV.write(output_path, data)
+ middle = data[data.cells_per_h .== middle_resolution, :]
+ @printf("kernel-derived coarea: %d/%d middle-resolution and %d/%d endpoint gates\n",
+ count(middle.coarea_middle_pass), nrow(middle),
+ count(middle.coarea_endpoint_decreasing), nrow(middle))
+ for (label, middle_column, endpoint_column) in
+ (("wedge coarea", :wedge_middle_pass, :wedge_endpoint_decreasing),
+ ("gated coarea", :gated_middle_pass, :gated_endpoint_decreasing),
+ ("kernel-derived divergence", :divergence_middle_pass,
+ :divergence_endpoint_decreasing))
+ @printf("%s: %d/%d middle-resolution and %d/%d endpoint gates\n",
+ label, count(middle[!, middle_column]), nrow(middle),
+ count(middle[!, endpoint_column]), nrow(middle))
+ end
+ println("Wrote kernel line-normalization study to ", output_path)
+ return data
+end
+
+@inline function scaled_plane_profile(profile_data, distance, smoothing_length)
+ 0 <= distance < profile_data.support * smoothing_length || return 0.0
+ return plane_profile_value(profile_data, distance / smoothing_length) /
+ smoothing_length
+end
+
+function analytic_cap_line_measure(setup, profile_data, smoothing_length)
+ coordinates = setup.initial_condition.coordinates
+ volume = setup.initial_condition.mass ./ setup.initial_condition.density
+ center = SVector(0.0, 0.0, setup.sphere_center_z)
+ measure = 0.0
+ for particle in axes(coordinates, 2)
+ position = SVector{3}(coordinates[:, particle])
+ radial = position - center
+ radius = norm(radial)
+ radius > eps(radius) || continue
+ interface_profile = scaled_plane_profile(profile_data,
+ setup.sphere_radius - radius,
+ smoothing_length)
+ wall_profile = scaled_plane_profile(profile_data, position[3], smoothing_length)
+ measure += volume[particle] * interface_profile * wall_profile *
+ hypot(radial[1], radial[2]) / radius
+ end
+ coarea_normalization = inv(profile_data.half_profile_integral^2)
+ return (; measure, line_length=coarea_normalization * measure,
+ particle_count=size(coordinates, 2), coarea_normalization)
+end
+
+function phase_measure_summary(values, analytic_value)
+ length(values) == length(CAP_PHASES) ||
+ error("phase summary requires $(length(CAP_PHASES)) values")
+ mean_value = mean(values)
+ relative_standard_error = std(values) /
+ (sqrt(length(values)) * analytic_value)
+ phase_values = join((@sprintf("%.17g", value) for value in values), ";")
+ return (; phase_values, phase_min=minimum(values), phase_max=maximum(values),
+ mean_value,
+ signed_error=mean_value / analytic_value - 1,
+ error=abs(mean_value / analytic_value - 1),
+ relative_standard_error)
+end
+
+function apply_phase_measure_gates!(data; error_column=:line_length_error,
+ standard_error_column=:phase_standard_error,
+ series_column=nothing,
+ middle_column=:middle_pass,
+ endpoint_column=:endpoint_pass)
+ data[!, middle_column] = falses(nrow(data))
+ data[!, endpoint_column] = falses(nrow(data))
+ group_columns = isnothing(series_column) ? [:variant, :target] :
+ [series_column, :target]
+ for group in groupby(data, group_columns; sort=true)
+ order = sortperm(group.requested_particles)
+ length(order) == 3 || error("phase-measure gate requires three resolutions")
+ parent_rows = parentindices(group)[1]
+ coarse = group[order[1], error_column]
+ middle = group[order[2], error_column]
+ fine = group[order[3], error_column]
+ coarse_se = group[order[1], standard_error_column]
+ fine_se = group[order[3], standard_error_column]
+ series = isnothing(series_column) ? "production_resolution" :
+ group[order[1], series_column]
+ endpoint_pass = if series == "fixed_h_quadrature"
+ middle_se = group[order[2], standard_error_column]
+ abs(fine - middle) <= abs(middle - coarse) +
+ 2hypot(middle_se, fine_se) && fine <= 0.2
+ else
+ fine <= 0.2 && fine <= coarse + 2hypot(coarse_se, fine_se)
+ end
+ data[parent_rows, middle_column] .= middle <= 0.2
+ data[parent_rows, endpoint_column] .= endpoint_pass
+ end
+ return data
+end
+
+function measure_protocol_row(series, target, requested_particles, smoothing_length,
+ profile_data)
+ line_lengths = Float64[]
+ particle_counts = Int[]
+ actual_cells_per_h = Float64[]
+ analytic_line_length = NaN
+ coarea_normalization = NaN
+ for lattice_phase in CAP_PHASES
+ setup = spherical_cap_initial_condition(target;
+ target_particle_count=requested_particles,
+ lattice_phase)
+ result = analytic_cap_line_measure(setup, profile_data, smoothing_length)
+ push!(line_lengths, result.line_length)
+ push!(particle_counts, result.particle_count)
+ particle_spacing = setup.initial_condition.particle_spacing
+ push!(actual_cells_per_h, smoothing_length / particle_spacing)
+ analytic_line_length = 2pi * setup.cap_radius
+ coarea_normalization = result.coarea_normalization
+ end
+ summary = phase_measure_summary(line_lengths, analytic_line_length)
+ return (; series, variant="analytic_both_control", target, requested_particles,
+ particle_count_min=minimum(particle_counts),
+ particle_count_max=maximum(particle_counts),
+ smoothing_length, cells_per_h=mean(actual_cells_per_h),
+ phase_count=length(CAP_PHASES), coarea_normalization,
+ line_length_phases=summary.phase_values,
+ line_length_phase_min=summary.phase_min,
+ line_length_phase_max=summary.phase_max,
+ line_length=summary.mean_value, analytic_line_length,
+ signed_error=summary.signed_error,
+ line_length_error=summary.error,
+ phase_standard_error=summary.relative_standard_error)
+end
+
+function run_measure_protocol(; output_path=MEASURE_PROTOCOL_PATH)
+ kernel = TrixiParticles.WendlandC2Kernel{3}()
+ profile_data = kernel_plane_profile(kernel)
+ rows = NamedTuple[]
+ drop_volume = 1.0e-6
+ for target in TARGET_ANGLES, resolution in RESOLUTIONS
+ particle_spacing = cbrt(drop_volume / resolution)
+ push!(rows,
+ measure_protocol_row("production_resolution", target, resolution,
+ 1.4particle_spacing, profile_data))
+ end
+
+ fixed_smoothing_length = 1.4cbrt(drop_volume / RESOLUTIONS[2])
+ for target in TARGET_ANGLES, cells_per_h in FIXED_H_CELLS_PER_H
+ resolution = round(Int, RESOLUTIONS[2] * (cells_per_h / 1.4)^3)
+ push!(rows,
+ measure_protocol_row("fixed_h_quadrature", target, resolution,
+ fixed_smoothing_length, profile_data))
+ end
+
+ data = DataFrame(rows)
+ apply_phase_measure_gates!(data; series_column=:series)
+ data.protocol_pass = falses(nrow(data))
+ for target in TARGET_ANGLES
+ target_rows = data[data.target .== target, :]
+ pass = all(target_rows.middle_pass .& target_rows.endpoint_pass)
+ data[data.target .== target, :protocol_pass] .= pass
+ end
+ sort!(data, [:series, :requested_particles, :target])
+ CSV.write(output_path, data)
+
+ production_middle = data[(data.series .== "production_resolution") .& (data.requested_particles .== RESOLUTIONS[2]),
+ :]
+ quadrature_middle_resolution = round(Int,
+ RESOLUTIONS[2] *
+ (FIXED_H_CELLS_PER_H[2] / 1.4)^3)
+ quadrature_middle = data[(data.series .== "fixed_h_quadrature") .& (data.requested_particles .== quadrature_middle_resolution),
+ :]
+ @printf("phase-averaged exact control: production middle %d/5, endpoints %d/5; fixed-h middle %d/5, endpoints %d/5\n",
+ count(production_middle.middle_pass),
+ count(production_middle.endpoint_pass),
+ count(quadrature_middle.middle_pass),
+ count(quadrature_middle.endpoint_pass))
+ println("Wrote amended contact-measure protocol to ", output_path)
+ return data
+end
+
+function analytic_cap_gradients(result, components, setup, profile_data)
+ system = result.solution.prob.p.semi.systems[1]
+ smoothing_length = TrixiParticles.initial_smoothing_length(system)
+ coordinates = components.coordinates
+ interface_gradient = zeros(eltype(coordinates), size(coordinates))
+ wall_gradient = zeros(eltype(coordinates), size(coordinates))
+ center = SVector(0.0, 0.0, setup.sphere_center_z)
+
+ for particle in axes(coordinates, 2)
+ position = SVector{3}(coordinates[:, particle])
+ radial = position - center
+ radius = norm(radial)
+ interface_distance = setup.sphere_radius - radius
+ interface_profile = scaled_plane_profile(profile_data, interface_distance,
+ smoothing_length)
+ if radius > eps(radius) && interface_profile > 0
+ interface_gradient[:, particle] .= -interface_profile * radial / radius
+ end
+
+ wall_profile = scaled_plane_profile(profile_data, position[3], smoothing_length)
+ wall_gradient[3, particle] = -wall_profile
+ end
+ return (; interface_gradient, wall_gradient)
+end
+
+function compatible_indicator_gradients(result, components)
+ semi = result.solution.prob.p.semi
+ system, boundary_system = semi.systems
+ v_ode, u_ode = result.solution.prob.u0.x
+ v = TrixiParticles.wrap_v(v_ode, system, semi)
+ u = TrixiParticles.wrap_u(u_ode, system, semi)
+ u_boundary = TrixiParticles.wrap_u(u_ode, boundary_system, semi)
+ coordinates = TrixiParticles.current_coordinates(u, system)
+ boundary_coordinates = TrixiParticles.current_coordinates(u_boundary, boundary_system)
+ colorfield = boundary_system.boundary_model.cache.colorfield
+ colorfield_reference = maximum(colorfield)
+ wall_gradient = zeros(eltype(system), size(components.fluid_normal))
+ continuation_gradient = similar(wall_gradient)
+ fill!(continuation_gradient, 0)
+
+ TrixiParticles.foreach_point_neighbor(system, boundary_system, coordinates,
+ boundary_coordinates,
+ semi) do particle, neighbor,
+ pos_diff, distance
+ density = TrixiParticles.current_density(v, system, particle)
+ volume = TrixiParticles.hydrodynamic_mass(system, particle) / density
+ gradient = TrixiParticles.smoothing_kernel_grad(system, pos_diff, distance,
+ particle)
+ wetness = clamp(colorfield[neighbor] / colorfield_reference, 0, 1)
+ wall_gradient[:, particle] .+= volume * gradient
+ continuation_gradient[:, particle] .+= volume * wetness * gradient
+ end
+ interface_gradient = components.fluid_normal + continuation_gradient
+ return (; interface_gradient, wall_gradient, colorfield_reference)
+end
+
+function geometry_wall_gradient(result, components, profile_data)
+ system = result.solution.prob.p.semi.systems[1]
+ smoothing_length = TrixiParticles.initial_smoothing_length(system)
+ wall_gradient = zeros(eltype(system), size(components.fluid_normal))
+ for particle in axes(components.coordinates, 2)
+ wall_profile = scaled_plane_profile(profile_data,
+ components.coordinates[3, particle],
+ smoothing_length)
+ wall_gradient[3, particle] = -wall_profile
+ end
+ return wall_gradient
+end
+
+@inline function young_ghost_fraction(surface_fraction, tangent_gradient, depth, angle)
+ tangent_gradient > eps(tangent_gradient) || return surface_fraction
+ contact_sine = sind(angle)
+ if abs(contact_sine) <= sqrt(eps(contact_sine))
+ return cosd(angle) > 0 ? 1.0 : 0.0
+ end
+ return clamp(surface_fraction +
+ depth * tangent_gradient * cosd(angle) / contact_sine, 0, 1)
+end
+
+function young_boundary_gradients(result, components, target, profile_data)
+ semi = result.solution.prob.p.semi
+ system, boundary_system = semi.systems
+ v_ode, u_ode = result.solution.prob.u0.x
+ v = TrixiParticles.wrap_v(v_ode, system, semi)
+ u = TrixiParticles.wrap_u(u_ode, system, semi)
+ u_boundary = TrixiParticles.wrap_u(u_ode, boundary_system, semi)
+ coordinates = TrixiParticles.current_coordinates(u, system)
+ boundary_coordinates = Array(TrixiParticles.current_coordinates(u_boundary,
+ boundary_system))
+ particle_spacing = system.cache.reference_particle_spacing
+ colorfield = boundary_system.boundary_model.cache.colorfield
+ colorfield_reference = canonical_flooded_reference(result, profile_data).reference
+ color_fraction = clamp.(colorfield ./ colorfield_reference, 0, 1)
+ exposed_height = maximum(boundary_coordinates[3, :])
+ exposed = isapprox.(boundary_coordinates[3, :], exposed_height;
+ atol=10eps(abs(exposed_height) + particle_spacing))
+ minimum_x = minimum(boundary_coordinates[1, exposed])
+ minimum_y = minimum(boundary_coordinates[2, exposed])
+ surface_fraction = Dict{Tuple{Int, Int}, Float64}()
+ for particle in findall(exposed)
+ key = (round(Int,
+ (boundary_coordinates[1, particle] - minimum_x) /
+ particle_spacing),
+ round(Int,
+ (boundary_coordinates[2, particle] - minimum_y) /
+ particle_spacing))
+ surface_fraction[key] = color_fraction[particle]
+ end
+ tangent_gradient = Dict{Tuple{Int, Int}, Float64}()
+ for (key, center) in surface_fraction
+ i, j = key
+ gradient_x = (get(surface_fraction, (i + 1, j), center) -
+ get(surface_fraction, (i - 1, j), center)) /
+ (2particle_spacing)
+ gradient_y = (get(surface_fraction, (i, j + 1), center) -
+ get(surface_fraction, (i, j - 1), center)) /
+ (2particle_spacing)
+ tangent_gradient[key] = hypot(gradient_x, gradient_y)
+ end
+
+ # Impose Young's normal derivative on scalar ghost values before assembling the gradient.
+ ghost_fraction = similar(color_fraction)
+ for particle in eachindex(ghost_fraction)
+ key = (round(Int,
+ (boundary_coordinates[1, particle] - minimum_x) /
+ particle_spacing),
+ round(Int,
+ (boundary_coordinates[2, particle] - minimum_y) /
+ particle_spacing))
+ surface_value = surface_fraction[key]
+ depth = -boundary_coordinates[3, particle]
+ ghost_fraction[particle] = young_ghost_fraction(surface_value,
+ tangent_gradient[key], depth,
+ target)
+ end
+
+ continuation_gradient = zeros(eltype(system), size(components.fluid_normal))
+ TrixiParticles.foreach_point_neighbor(system, boundary_system, coordinates,
+ boundary_coordinates,
+ semi) do particle, neighbor,
+ pos_diff, distance
+ density = TrixiParticles.current_density(v, system, particle)
+ volume = TrixiParticles.hydrodynamic_mass(system, particle) / density
+ gradient = TrixiParticles.smoothing_kernel_grad(system, pos_diff, distance,
+ particle)
+ continuation_gradient[:, particle] .+= volume * ghost_fraction[neighbor] *
+ gradient
+ end
+ interface_gradient = components.fluid_normal + continuation_gradient
+ wall_gradient = geometry_wall_gradient(result, components, profile_data)
+ return (; interface_gradient, wall_gradient, colorfield_reference,
+ ghost_fraction_min=minimum(ghost_fraction),
+ ghost_fraction_max=maximum(ghost_fraction))
+end
+
+function gradient_measure_metrics(components, interface_gradient, wall_gradient,
+ coarea_normalization)
+ measure = 0.0
+ weighted_angle = 0.0
+ valid_particles = 0
+ for particle in eachindex(components.volume)
+ interface = SVector{3}(interface_gradient[:, particle])
+ wall = SVector{3}(wall_gradient[:, particle])
+ interface_norm = norm(interface)
+ wall_norm = norm(wall)
+ interface_norm > eps(interface_norm) && wall_norm > eps(wall_norm) || continue
+ weight = components.volume[particle] * norm(cross(interface, wall))
+ weight > eps(weight) || continue
+ angle = acosd(clamp(dot(interface, wall) / (interface_norm * wall_norm), -1, 1))
+ measure += weight
+ weighted_angle += weight * angle
+ valid_particles += 1
+ end
+ line_length = coarea_normalization * measure
+ angle = measure > eps(measure) ? weighted_angle / measure : NaN
+ return (; measure, line_length, angle, valid_particles)
+end
+
+function run_extended_cap_recovery(; output_path=EXTENDED_CAP_PATH)
+ kernel = TrixiParticles.WendlandC2Kernel{3}()
+ profile_data = kernel_plane_profile(kernel)
+ coarea_normalization = inv(profile_data.half_profile_integral^2)
+ variants = ("analytic_both_control", "compatible_indicator",
+ "compatible_geometry_wall", "young_color_boundary")
+ rows = NamedTuple[]
+ for resolution in RESOLUTIONS, target in TARGET_ANGLES
+ line_lengths = Dict(variant => Float64[] for variant in variants)
+ angles = Dict(variant => Float64[] for variant in variants)
+ valid_particles = Dict(variant => Int[] for variant in variants)
+ particle_counts = Int[]
+ ghost_minimum = Float64[]
+ ghost_maximum = Float64[]
+ analytic_line_length = NaN
+ for lattice_phase in CAP_PHASES
+ result = quiet_css_sessile_drop(target, 0.0, nothing;
+ target_particle_count=resolution,
+ mechanism=:contact_line_force,
+ initial_contact_angle=target,
+ boundary_contact_threshold=0.1,
+ damping_coefficient=4000.0,
+ lattice_phase)
+ components = raw_normal_components(result)
+ setup = spherical_cap_initial_condition(target;
+ target_particle_count=resolution,
+ lattice_phase)
+ analytic = analytic_cap_gradients(result, components, setup, profile_data)
+ compatible = compatible_indicator_gradients(result, components)
+ geometry_wall = geometry_wall_gradient(result, components, profile_data)
+ young = young_boundary_gradients(result, components, target, profile_data)
+ candidate_gradients = (("analytic_both_control",
+ analytic.interface_gradient,
+ analytic.wall_gradient),
+ ("compatible_indicator",
+ compatible.interface_gradient,
+ compatible.wall_gradient),
+ ("compatible_geometry_wall",
+ compatible.interface_gradient,
+ geometry_wall),
+ ("young_color_boundary",
+ young.interface_gradient,
+ young.wall_gradient))
+ for (variant, interface_gradient, wall_gradient) in candidate_gradients
+ metrics = gradient_measure_metrics(components, interface_gradient,
+ wall_gradient,
+ coarea_normalization)
+ push!(line_lengths[variant], metrics.line_length)
+ push!(angles[variant], metrics.angle)
+ push!(valid_particles[variant], metrics.valid_particles)
+ end
+ push!(particle_counts, result.particle_count)
+ push!(ghost_minimum, young.ghost_fraction_min)
+ push!(ghost_maximum, young.ghost_fraction_max)
+ analytic_line_length = 2pi * setup.cap_radius
+ end
+ for variant in variants
+ summary = phase_measure_summary(line_lengths[variant], analytic_line_length)
+ angle = sum(line_lengths[variant] .* angles[variant]) /
+ sum(line_lengths[variant])
+ angle_standard_error = std(angles[variant]) / sqrt(length(CAP_PHASES))
+ push!(rows,
+ (; variant, target, requested_particles=resolution,
+ particle_count_min=minimum(particle_counts),
+ particle_count_max=maximum(particle_counts),
+ phase_count=length(CAP_PHASES), coarea_normalization,
+ line_length_phases=summary.phase_values,
+ line_length=summary.mean_value, analytic_line_length,
+ signed_error=summary.signed_error,
+ line_length_error=summary.error,
+ phase_standard_error=summary.relative_standard_error,
+ angle_phases=join((@sprintf("%.17g", value)
+ for value in angles[variant]), ";"),
+ angle, angle_error=abs(angle - target), angle_standard_error,
+ valid_particles=round(Int, mean(valid_particles[variant])),
+ ghost_fraction_min=variant == "young_color_boundary" ?
+ minimum(ghost_minimum) : NaN,
+ ghost_fraction_max=variant == "young_color_boundary" ?
+ maximum(ghost_maximum) : NaN))
+ end
+ end
+ data = DataFrame(rows)
+ apply_phase_measure_gates!(data)
+ data.angle_middle_pass = falses(nrow(data))
+ data.angle_endpoint_pass = falses(nrow(data))
+ for group in groupby(data, [:variant, :target]; sort=true)
+ order = sortperm(group.requested_particles)
+ parent_rows = parentindices(group)[1]
+ coarse_error = group.angle_error[order[1]]
+ fine_error = group.angle_error[order[3]]
+ coarse_se = group.angle_standard_error[order[1]]
+ fine_se = group.angle_standard_error[order[3]]
+ data[parent_rows, :angle_middle_pass] .= group.angle_error[order[2]] <= 5
+ data[parent_rows,
+ :angle_endpoint_pass] .= fine_error <= 5 &&
+ fine_error <=
+ coarse_error +
+ 2hypot(coarse_se, fine_se)
+ end
+ protocol = CSV.read(MEASURE_PROTOCOL_PATH, DataFrame)
+ protocol_valid = all(protocol.protocol_pass)
+ data.protocol_valid = fill(protocol_valid, nrow(data))
+ data.measure_eligible = protocol_valid .& data.middle_pass .& data.endpoint_pass
+ data.static_eligible = data.measure_eligible .& data.angle_middle_pass .&
+ data.angle_endpoint_pass
+ sort!(data, [:variant, :requested_particles, :target])
+ CSV.write(output_path, data)
+ for variant in variants
+ middle = data[(data.variant .== variant) .& (data.requested_particles .== RESOLUTIONS[2]),
+ :]
+ @printf("%-28s measure %d/5 middle, %d/5 endpoint; angle %d/5 middle, %d/5 endpoint\n",
+ variant, count(middle.middle_pass), count(middle.endpoint_pass),
+ count(middle.angle_middle_pass), count(middle.angle_endpoint_pass))
+ end
+ println("Wrote extended cap recovery evidence to ", output_path)
+ return data
+end
+
+function coarea_wall_energy_shape_acceleration(result, target, components,
+ interface_gradient, wall_gradient,
+ coarea_normalization)
+ semi = result.solution.prob.p.semi
+ system = semi.systems[1]
+ v_ode, _ = result.solution.prob.u0.x
+ v = TrixiParticles.wrap_v(v_ode, system, semi)
+ acceleration = zeros(eltype(system), ndims(system), TrixiParticles.nparticles(system))
+ sigma = system.surface_tension.surface_tension_coefficient
+ for particle in TrixiParticles.eachparticle(system)
+ interface = SVector{3}(interface_gradient[:, particle])
+ wall = SVector{3}(wall_gradient[:, particle])
+ wall_norm = norm(wall)
+ wall_norm > eps(wall_norm) || continue
+ wall /= wall_norm
+ tangent = interface - dot(interface, wall) * wall
+ tangent_norm = norm(tangent)
+ tangent_norm > eps(tangent_norm) || continue
+ line_delta = coarea_normalization *
+ norm(cross(interface, SVector{3}(wall_gradient[:, particle])))
+ density = TrixiParticles.current_density(v, system, particle)
+ acceleration[:,
+ particle] .= -sigma * cosd(target) * line_delta /
+ density * tangent / tangent_norm
+ end
+ return cap_shape_acceleration(acceleration, components.coordinates)
+end
+
+function young_boundary_shape_acceleration(result, gradients)
+ system = result.solution.prob.p.semi.systems[1]
+ system.cache.surface_normal .= gradients.interface_gradient
+ system.cache.boundary_normal .= 0
+ for particle in TrixiParticles.eachparticle(system)
+ wall = SVector{3}(gradients.wall_gradient[:, particle])
+ wall_norm = norm(wall)
+ wall_norm > eps(wall_norm) || continue
+ system.cache.boundary_normal[:, particle] .= wall / wall_norm
+ end
+ method = TrixiParticles.surface_normal_method(system)
+ TrixiParticles.remove_invalid_normals!(system, system.surface_tension, method)
+ system.cache.contact_line_delta .= 0
+ system.cache.contact_line_delta_prime .= 0
+ return shape_acceleration_from_caches(result)
+end
+
+function run_extended_force_sign(; area_path=CORRECTED_WETTED_AREA_PATH,
+ cap_path=EXTENDED_CAP_PATH,
+ output_path=EXTENDED_FORCE_SIGN_PATH)
+ area = CSV.read(area_path, DataFrame)
+ cap = CSV.read(cap_path, DataFrame)
+ area_middle = area[area.requested_particles .== RESOLUTIONS[2], :]
+ area_eligible = all(area_middle.measure_eligible)
+ compatible_middle = cap[(cap.variant .== "compatible_geometry_wall") .& (cap.requested_particles .== RESOLUTIONS[2]),
+ :]
+ compatible_eligible = all(compatible_middle.measure_eligible)
+ young_middle = cap[(cap.variant .== "young_color_boundary") .& (cap.requested_particles .== RESOLUTIONS[2]),
+ :]
+ young_eligible = all(young_middle.static_eligible)
+
+ kernel = TrixiParticles.WendlandC2Kernel{3}()
+ profile_data = kernel_plane_profile(kernel)
+ coarea_normalization = inv(profile_data.half_profile_integral^2)
+ offset_data = kernel_lattice_color_offsets(kernel, 64)
+ edge_data = Dict(target => canonical_wetted_edge_data(profile_data, offset_data,
+ target)
+ for target in TARGET_ANGLES)
+ angle_pairs = ((60.0, 90.0), (90.0, 60.0), (90.0, 120.0), (120.0, 90.0))
+ rows = NamedTuple[]
+ for (target, initial_angle) in angle_pairs
+ control = quiet_css_sessile_drop(target, 0.0, nothing;
+ target_particle_count=1500, mechanism=:none,
+ initial_contact_angle=initial_angle,
+ boundary_contact_threshold=0.1,
+ damping_coefficient=4000.0)
+ scaffold = quiet_css_sessile_drop(target, 0.0, nothing;
+ target_particle_count=1500,
+ mechanism=:contact_line_force,
+ initial_contact_angle=initial_angle,
+ boundary_contact_threshold=0.1,
+ damping_coefficient=4000.0)
+ components = raw_normal_components(scaffold)
+ compatible = compatible_indicator_gradients(scaffold, components)
+ geometry_wall = geometry_wall_gradient(scaffold, components, profile_data)
+ young = young_boundary_gradients(scaffold, components, target, profile_data)
+ expected_direction = sign(target - initial_angle)
+
+ corrected_area_acceleration = corrected_wetted_area_shape_acceleration(control,
+ target,
+ profile_data,
+ edge_data[target])
+ compatible_acceleration = coarea_wall_energy_shape_acceleration(scaffold, target,
+ components,
+ compatible.interface_gradient,
+ geometry_wall,
+ coarea_normalization)
+ young_acceleration = young_boundary_shape_acceleration(scaffold, young)
+ candidates = (("corrected_wetted_area",
+ control.shape_acceleration + corrected_area_acceleration,
+ corrected_area_acceleration, area_eligible, true,
+ target == 90 ? iszero(corrected_area_acceleration) : missing),
+ ("compatible_geometry_wall",
+ control.shape_acceleration + compatible_acceleration,
+ compatible_acceleration, compatible_eligible, true,
+ target == 90 ? iszero(compatible_acceleration) : missing),
+ ("young_color_boundary", young_acceleration,
+ young_acceleration - control.shape_acceleration,
+ all(young_middle.measure_eligible), young_eligible, missing))
+ for (variant, acceleration, contact_acceleration, measure_eligible,
+ static_eligible, zero_at_90) in candidates
+
+ total_sign_pass = expected_direction * acceleration > 0
+ contact_sign_pass = expected_direction * contact_acceleration > 0
+ push!(rows,
+ (; kind="force_sign", variant, target, initial_angle,
+ requested_particles=1500, particle_count=control.particle_count,
+ control_acceleration=control.shape_acceleration,
+ contact_shape_acceleration=contact_acceleration, acceleration,
+ expected_direction, total_sign_pass, contact_sign_pass,
+ wall_zero_at_90=zero_at_90, measure_eligible, static_eligible,
+ sign_pass=total_sign_pass))
+ end
+ end
+ data = DataFrame(rows)
+ sort!(data, [:variant, :target, :initial_angle])
+ CSV.write(output_path, data)
+ for variant in unique(data.variant)
+ variant_rows = data[data.variant .== variant, :]
+ println(variant, " fixed total/contact signs: ",
+ count(variant_rows.total_sign_pass), "/", nrow(variant_rows), " / ",
+ count(variant_rows.contact_sign_pass), "/", nrow(variant_rows),
+ "; measure eligible: ", all(variant_rows.measure_eligible),
+ "; static eligible: ", all(variant_rows.static_eligible))
+ end
+ println("Wrote extended fixed-particle signs to ", output_path)
+ return data
+end
+
+@inline smoothstep01(value) = value^2 * (3 - 2value)
+@inline smoothstep01_derivative(value) = 6value * (1 - value)
+
+function kernel_lattice_color_offsets(kernel, cells_per_h)
+ lattice_spacing = 1 / cells_per_h
+ support = TrixiParticles.compact_support(kernel, 1.0)
+ search_radius = ceil(Int, support / lattice_spacing)
+ offsets = NamedTuple[]
+ for z_offset in (-search_radius):search_radius,
+ x_offset in (-search_radius):search_radius
+ planar_distance2 = lattice_spacing^2 * (x_offset^2 + z_offset^2)
+ planar_distance2 < support^2 || continue
+ kernel_value = 0.0
+ for tangent_offset in (-search_radius):search_radius
+ distance = lattice_spacing *
+ sqrt(x_offset^2 + tangent_offset^2 + z_offset^2)
+ distance < support || continue
+ kernel_value += lattice_spacing *
+ TrixiParticles.kernel(kernel, distance, 1.0)
+ end
+ push!(offsets, (; x_offset, z_offset, kernel_value))
+ end
+ return (; offsets, lattice_spacing, support)
+end
+
+function canonical_wetted_edge_data(profile_data, offset_data, angle;
+ production_cells_per_h=1.4)
+ contact_sine = sind(angle)
+ abs(contact_sine) > sqrt(eps()) ||
+ return (; normalized_shift=0.0, lattice_reference=0.0,
+ continuum_reference=0.0)
+ contact_cotangent = cosd(angle) / contact_sine
+ boundary_distance = inv(2production_cells_per_h)
+ # Each reduced kernel sample enters the wetted wedge at one horizontal threshold, so the
+ # smoothed edge profile is a cumulative sum rather than a sampled convolution.
+ thresholds = Float64[]
+ weights = Float64[]
+ for offset in offset_data.offsets
+ source_z = -boundary_distance -
+ offset.z_offset * offset_data.lattice_spacing
+ source_z > 0 || continue
+ push!(thresholds,
+ offset.x_offset * offset_data.lattice_spacing +
+ contact_cotangent * source_z)
+ push!(weights,
+ offset_data.lattice_spacing^2 * offset.kernel_value)
+ end
+ order = sortperm(thresholds)
+ thresholds = thresholds[order]
+ weights = weights[order]
+ lattice_reference = sum(weights)
+ breaks = sort!(unique!([thresholds; 0.0]))
+ cumulative = 0.0
+ event = 1
+ normalized_shift = 0.0
+ for interval in 1:(length(breaks) - 1)
+ left = breaks[interval]
+ right = breaks[interval + 1]
+ while event <= length(thresholds) && thresholds[event] <= left
+ cumulative += weights[event]
+ event += 1
+ end
+ fraction = clamp(cumulative / lattice_reference, 0, 1)
+ step = (left + right) / 2 > 0 ? 1.0 : 0.0
+ normalized_shift += (right - left) * (smoothstep01(fraction) - step)
+ end
+ continuum_reference = halfspace_color_value(profile_data, boundary_distance)
+ return (; normalized_shift, lattice_reference, continuum_reference)
+end
+
+function canonical_flooded_reference(result, profile_data)
+ semi = result.solution.prob.p.semi
+ system, boundary_system = semi.systems
+ v_ode, u_ode = result.solution.prob.u0.x
+ v = TrixiParticles.wrap_v(v_ode, system, semi)
+ u_boundary = TrixiParticles.wrap_u(u_ode, boundary_system, semi)
+ boundary_coordinates = TrixiParticles.current_coordinates(u_boundary, boundary_system)
+ particle_spacing = system.cache.reference_particle_spacing
+ smoothing_length = TrixiParticles.initial_smoothing_length(system)
+ exposed_height = maximum(boundary_coordinates[3, :])
+ wall_distance = -exposed_height
+ particle = first(TrixiParticles.eachparticle(system))
+ particle_volume = TrixiParticles.hydrodynamic_mass(system, particle) /
+ TrixiParticles.current_density(v, system, particle)
+ volume_scale = particle_volume / particle_spacing^3
+ reference = volume_scale *
+ halfspace_color_value(profile_data, wall_distance / smoothing_length)
+ return (; reference, volume_scale, wall_distance, smoothing_length)
+end
+
+function canonical_wetted_area_data(result, target, profile_data, edge_data)
+ semi = result.solution.prob.p.semi
+ system, boundary_system = semi.systems
+ v_ode, u_ode = result.solution.prob.u0.x
+ TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, 0.0)
+ u_boundary = TrixiParticles.wrap_u(u_ode, boundary_system, semi)
+ boundary_coordinates = Array(TrixiParticles.current_coordinates(u_boundary,
+ boundary_system))
+ colorfield = boundary_system.boundary_model.cache.colorfield
+ reference_data = canonical_flooded_reference(result, profile_data)
+ particle_spacing = system.cache.reference_particle_spacing
+ exposed_height = maximum(boundary_coordinates[3, :])
+ exposed = isapprox.(boundary_coordinates[3, :], exposed_height;
+ atol=10eps(abs(exposed_height) + particle_spacing))
+ color_fraction = clamp.(colorfield ./ reference_data.reference, 0, 1)
+ particle_area = particle_spacing^2
+ raw_area = particle_area * sum(smoothstep01, color_fraction[exposed])
+ raw_radius = sqrt(raw_area / pi)
+ edge_shift = edge_data.normalized_shift * reference_data.smoothing_length
+ # Remove the canonical planar edge displacement from the disk's effective radius.
+ corrected_radius = max(raw_radius - edge_shift, 0.0)
+ corrected_area = pi * corrected_radius^2
+ area_derivative = raw_radius > eps(raw_radius) ? corrected_radius / raw_radius : 0.0
+ return (; raw_area, corrected_area, raw_radius, corrected_radius,
+ edge_shift, area_derivative, particle_area, exposed, color_fraction,
+ colorfield_reference=reference_data.reference,
+ observed_maximum=maximum(colorfield), reference_data.volume_scale,
+ reference_data.wall_distance, reference_data.smoothing_length,
+ boundary_coordinates, target)
+end
+
+function corrected_wetted_area_shape_acceleration(result, target, profile_data, edge_data)
+ semi = result.solution.prob.p.semi
+ system, boundary_system = semi.systems
+ v_ode, u_ode = result.solution.prob.u0.x
+ TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, 0.0)
+ v = TrixiParticles.wrap_v(v_ode, system, semi)
+ u = TrixiParticles.wrap_u(u_ode, system, semi)
+ u_boundary = TrixiParticles.wrap_u(u_ode, boundary_system, semi)
+ coordinates = TrixiParticles.current_coordinates(u, system)
+ boundary_coordinates = TrixiParticles.current_coordinates(u_boundary, boundary_system)
+ area_data = canonical_wetted_area_data(result, target, profile_data, edge_data)
+ acceleration = zeros(eltype(system), ndims(system), TrixiParticles.nparticles(system))
+ coefficient = system.surface_tension.surface_tension_coefficient * cosd(target) *
+ area_data.particle_area * area_data.area_derivative /
+ area_data.colorfield_reference
+
+ TrixiParticles.foreach_point_neighbor(system, boundary_system, coordinates,
+ boundary_coordinates,
+ semi) do particle, neighbor,
+ pos_diff, distance
+ area_data.exposed[neighbor] || return
+ fraction = area_data.color_fraction[neighbor]
+ derivative = smoothstep01_derivative(fraction)
+ derivative > eps(derivative) || return
+ density = TrixiParticles.current_density(v, system, particle)
+ gradient = TrixiParticles.smoothing_kernel_grad(system, pos_diff, distance,
+ particle)
+ acceleration[:, particle] .+= coefficient / density * derivative * gradient
+ end
+ return cap_shape_acceleration(acceleration, Array(coordinates))
+end
+
+function run_corrected_wetted_area(; output_path=CORRECTED_WETTED_AREA_PATH)
+ kernel = TrixiParticles.WendlandC2Kernel{3}()
+ profile_data = kernel_plane_profile(kernel)
+ offset_data = kernel_lattice_color_offsets(kernel, 64)
+ edge_data = Dict(target => canonical_wetted_edge_data(profile_data, offset_data,
+ target)
+ for target in TARGET_ANGLES)
+ rows = NamedTuple[]
+ for resolution in RESOLUTIONS, target in TARGET_ANGLES
+ raw_areas = Float64[]
+ corrected_areas = Float64[]
+ references = Float64[]
+ observed_maxima = Float64[]
+ particle_counts = Int[]
+ exposed_counts = Int[]
+ edge_shifts = Float64[]
+ area_derivatives = Float64[]
+ analytic_area = NaN
+ for lattice_phase in CAP_PHASES
+ result = quiet_css_sessile_drop(target, 0.0, nothing;
+ target_particle_count=resolution,
+ mechanism=:none,
+ initial_contact_angle=target,
+ boundary_contact_threshold=0.1,
+ damping_coefficient=4000.0,
+ lattice_phase)
+ area_data = canonical_wetted_area_data(result, target, profile_data,
+ edge_data[target])
+ setup = spherical_cap_initial_condition(target;
+ target_particle_count=resolution,
+ lattice_phase)
+ analytic_area = pi * setup.cap_radius^2
+ push!(raw_areas, area_data.raw_area)
+ push!(corrected_areas, area_data.corrected_area)
+ push!(references, area_data.colorfield_reference)
+ push!(observed_maxima, area_data.observed_maximum)
+ push!(particle_counts, result.particle_count)
+ push!(exposed_counts, count(area_data.exposed))
+ push!(edge_shifts, area_data.edge_shift)
+ push!(area_derivatives, area_data.area_derivative)
+ end
+ raw = phase_measure_summary(raw_areas, analytic_area)
+ corrected = phase_measure_summary(corrected_areas, analytic_area)
+ push!(rows,
+ (; variant="corrected_wetted_area", target,
+ requested_particles=resolution,
+ particle_count_min=minimum(particle_counts),
+ particle_count_max=maximum(particle_counts),
+ exposed_particles=round(Int, mean(exposed_counts)),
+ phase_count=length(CAP_PHASES),
+ colorfield_reference=mean(references),
+ observed_maximum=mean(observed_maxima),
+ reference_ratio=mean(references ./ observed_maxima),
+ normalized_edge_shift=edge_data[target].normalized_shift,
+ edge_shift=mean(edge_shifts),
+ area_derivative=mean(area_derivatives),
+ raw_area_phases=raw.phase_values,
+ raw_area=raw.mean_value,
+ raw_area_error=raw.error,
+ raw_phase_standard_error=raw.relative_standard_error,
+ corrected_area_phases=corrected.phase_values,
+ corrected_area=corrected.mean_value, analytic_area,
+ corrected_area_error=corrected.error,
+ phase_standard_error=corrected.relative_standard_error))
+ end
+ data = DataFrame(rows)
+ apply_phase_measure_gates!(data; error_column=:corrected_area_error)
+ apply_phase_measure_gates!(data; error_column=:raw_area_error,
+ standard_error_column=:raw_phase_standard_error,
+ middle_column=:raw_middle_pass,
+ endpoint_column=:raw_endpoint_pass)
+ protocol = CSV.read(MEASURE_PROTOCOL_PATH, DataFrame)
+ protocol_valid = all(protocol.protocol_pass)
+ data.protocol_valid = fill(protocol_valid, nrow(data))
+ data.measure_eligible = protocol_valid .& data.middle_pass .& data.endpoint_pass
+ sort!(data, [:requested_particles, :target])
+ CSV.write(output_path, data)
+ middle = data[data.requested_particles .== RESOLUTIONS[2], :]
+ @printf("corrected wetted area: middle %d/5, endpoints %d/5, max middle error %.3f; protocol %s\n",
+ count(middle.middle_pass), count(middle.endpoint_pass),
+ maximum(middle.corrected_area_error), protocol_valid ? "valid" : "invalid")
+ println("Wrote corrected wetted-area evidence to ", output_path)
+ return data
+end
+
+function wetted_area_data(result)
+ semi = result.solution.prob.p.semi
+ system, boundary_system = semi.systems
+ v_ode, u_ode = result.solution.prob.u0.x
+ TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, 0.0)
+ u_boundary = TrixiParticles.wrap_u(u_ode, boundary_system, semi)
+ boundary_coordinates = Array(TrixiParticles.current_coordinates(u_boundary,
+ boundary_system))
+ colorfield = boundary_system.boundary_model.cache.colorfield
+ colorfield_reference = maximum(colorfield)
+ particle_spacing = system.cache.reference_particle_spacing
+ exposed_height = maximum(boundary_coordinates[3, :])
+ exposed = isapprox.(boundary_coordinates[3, :], exposed_height;
+ atol=10eps(abs(exposed_height) + particle_spacing))
+ color_fraction = clamp.(colorfield ./ colorfield_reference, 0, 1)
+ particle_area = particle_spacing^2
+ wetted_area = particle_area * sum(smoothstep01, color_fraction[exposed])
+ return (; wetted_area, particle_area, exposed, color_fraction,
+ colorfield_reference, boundary_coordinates)
+end
+
+function wetted_area_shape_acceleration(result, target)
+ semi = result.solution.prob.p.semi
+ system, boundary_system = semi.systems
+ v_ode, u_ode = result.solution.prob.u0.x
+ TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, 0.0)
+ v = TrixiParticles.wrap_v(v_ode, system, semi)
+ u = TrixiParticles.wrap_u(u_ode, system, semi)
+ u_boundary = TrixiParticles.wrap_u(u_ode, boundary_system, semi)
+ coordinates = TrixiParticles.current_coordinates(u, system)
+ boundary_coordinates = TrixiParticles.current_coordinates(u_boundary, boundary_system)
+ area_data = wetted_area_data(result)
+ acceleration = zeros(eltype(system), ndims(system), TrixiParticles.nparticles(system))
+ coefficient = system.surface_tension.surface_tension_coefficient * cosd(target) *
+ area_data.particle_area / area_data.colorfield_reference
+
+ TrixiParticles.foreach_point_neighbor(system, boundary_system, coordinates,
+ boundary_coordinates,
+ semi) do particle, neighbor,
+ pos_diff, distance
+ area_data.exposed[neighbor] || return
+ fraction = area_data.color_fraction[neighbor]
+ derivative = smoothstep01_derivative(fraction)
+ derivative > eps(derivative) || return
+ density = TrixiParticles.current_density(v, system, particle)
+ gradient = TrixiParticles.smoothing_kernel_grad(system, pos_diff, distance,
+ particle)
+ acceleration[:, particle] .+= coefficient / density * derivative * gradient
+ end
+ return cap_shape_acceleration(acceleration, Array(coordinates))
+end
+
+function run_wetted_area_measure(; output_path=WETTED_AREA_PATH)
+ rows = NamedTuple[]
+ for resolution in RESOLUTIONS, target in TARGET_ANGLES
+ result = css_sessile_drop(target, 0.0, nothing;
+ target_particle_count=resolution,
+ mechanism=:none,
+ initial_contact_angle=target,
+ boundary_contact_threshold=0.1,
+ damping_coefficient=4000.0)
+ area_data = wetted_area_data(result)
+ setup = spherical_cap_initial_condition(target;
+ target_particle_count=resolution)
+ analytic_area = pi * setup.cap_radius^2
+ push!(rows,
+ (; target, requested_particles=resolution,
+ particle_count=result.particle_count,
+ exposed_particles=count(area_data.exposed),
+ colorfield_reference=area_data.colorfield_reference,
+ wetted_area=area_data.wetted_area, analytic_area,
+ area_error=abs(area_data.wetted_area / analytic_area - 1)))
+ end
+ data = DataFrame(rows)
+ data.middle_pass = data.area_error .<= 0.2
+ data.endpoint_decreasing = falses(nrow(data))
+ for group in groupby(data, :target)
+ coarse = only(group[group.requested_particles .== first(RESOLUTIONS), :area_error])
+ fine = only(group[group.requested_particles .== last(RESOLUTIONS), :area_error])
+ data[parentindices(group)[1],
+ :endpoint_decreasing] .= fine < coarse || fine <= 1.0e-12
+ end
+ sort!(data, [:requested_particles, :target])
+ CSV.write(output_path, data)
+ middle = data[data.requested_particles .== RESOLUTIONS[2], :]
+ @printf("wetted area: middle %d/5, endpoints %d/5, max middle error %.3f\n",
+ count(middle.middle_pass), count(middle.endpoint_decreasing),
+ maximum(middle.area_error))
+ println("Wrote wetted-area measure to ", output_path)
+ return data
+end
+
+function run_wetted_area_force_sign(; measure_path=WETTED_AREA_PATH,
+ output_path=WETTED_AREA_FORCE_SIGN_PATH)
+ measure = isfile(measure_path) ? CSV.read(measure_path, DataFrame) :
+ run_wetted_area_measure(; output_path=measure_path)
+ measure_middle = measure[measure.requested_particles .== RESOLUTIONS[2], :]
+ measure_eligible = all(measure_middle.middle_pass .&
+ measure_middle.endpoint_decreasing)
+ angle_pairs = ((60.0, 90.0), (90.0, 60.0), (90.0, 120.0), (120.0, 90.0))
+ rows = NamedTuple[]
+ for (target, initial_angle) in angle_pairs
+ control = css_sessile_drop(target, 0.0, nothing;
+ target_particle_count=1500, mechanism=:none,
+ initial_contact_angle=initial_angle,
+ boundary_contact_threshold=0.1,
+ damping_coefficient=4000.0)
+ wall_acceleration = wetted_area_shape_acceleration(control, target)
+ acceleration = control.shape_acceleration + wall_acceleration
+ expected_direction = sign(target - initial_angle)
+ push!(rows,
+ (; kind="force_sign", variant="wetted_area", target, initial_angle,
+ requested_particles=1500, particle_count=control.particle_count,
+ control_acceleration=control.shape_acceleration, wall_acceleration,
+ acceleration, expected_direction,
+ wall_zero_at_90=target == 90 ? iszero(wall_acceleration) : missing,
+ sign_pass=expected_direction * acceleration > 0,
+ measure_eligible))
+ end
+ data = DataFrame(rows)
+ CSV.write(output_path, data)
+ println("wetted-area total fixed-particle signs: ", count(data.sign_pass), "/",
+ nrow(data), "; measure eligible: ", measure_eligible)
+ println("Wrote wetted-area force signs to ", output_path)
+ return data
+end
+
+function run_recovery_comparison(; cap_path=CAP_TRANSFER_PATH,
+ area_path=WETTED_AREA_PATH,
+ area_sign_path=WETTED_AREA_FORCE_SIGN_PATH,
+ output_path=RECOVERY_COMPARISON_PATH)
+ cap = CSV.read(cap_path, DataFrame)
+ area = CSV.read(area_path, DataFrame)
+ area_sign = CSV.read(area_sign_path, DataFrame)
+ rows = NamedTuple[]
+ cap_methods = (("R6-D production discrete", "attribution", "production_discrete"),
+ ("R6-D analytic wall", "attribution", "analytic_wall"),
+ ("R6-D analytic interface", "attribution", "analytic_interface"),
+ ("R6-D analytic both", "attribution", "analytic_both"),
+ ("R6-C compatible indicator", "candidate", "compatible_indicator"),
+ ("support-moment diagnostic", "attribution", "support_moment"))
+ for (method, role, variant) in cap_methods
+ middle = cap[(cap.variant .== variant) .& (cap.requested_particles .== RESOLUTIONS[2]),
+ :]
+ middle_passes = count(middle.middle_pass)
+ endpoint_passes = count(middle.endpoint_decreasing)
+ measure_gate = middle_passes == length(TARGET_ANGLES) &&
+ endpoint_passes == length(TARGET_ANGLES)
+ push!(rows,
+ (; method, role, measure="contact_line", middle_passes,
+ endpoint_passes, measure_gate, sign_cases=0, sign_passes=0,
+ zero_at_90=missing, eligible=false,
+ max_middle_error=maximum(middle.line_length_error)))
+ end
+
+ area_middle = area[area.requested_particles .== RESOLUTIONS[2], :]
+ middle_passes = count(area_middle.middle_pass)
+ endpoint_passes = count(area_middle.endpoint_decreasing)
+ measure_gate = middle_passes == length(TARGET_ANGLES) &&
+ endpoint_passes == length(TARGET_ANGLES)
+ zero_values = collect(skipmissing(area_sign.wall_zero_at_90))
+ zero_at_90 = length(zero_values) == 2 && all(zero_values)
+ sign_passes = count(area_sign.sign_pass)
+ push!(rows,
+ (; method="R6-W wetted-area energy", role="candidate", measure="wetted_area",
+ middle_passes, endpoint_passes, measure_gate,
+ sign_cases=nrow(area_sign), sign_passes, zero_at_90,
+ eligible=measure_gate && sign_passes == nrow(area_sign),
+ max_middle_error=maximum(area_middle.area_error)))
+
+ data = DataFrame(rows)
+ CSV.write(output_path, data)
+ println(data)
+ println("Wrote R6 recovery comparison to ", output_path)
+ return data
+end
+
+function run_extended_recovery_comparison(; cap_path=EXTENDED_CAP_PATH,
+ area_path=CORRECTED_WETTED_AREA_PATH,
+ sign_path=EXTENDED_FORCE_SIGN_PATH,
+ output_path=EXTENDED_COMPARISON_PATH)
+ cap = CSV.read(cap_path, DataFrame)
+ area = CSV.read(area_path, DataFrame)
+ signs = CSV.read(sign_path, DataFrame)
+ rows = NamedTuple[]
+
+ cap_methods = (("exact-profile protocol control", "control",
+ "analytic_both_control"),
+ ("R7-CG compatible + geometry wall", "candidate",
+ "compatible_geometry_wall"),
+ ("R7-Y Young color boundary", "candidate",
+ "young_color_boundary"))
+ for (method, role, variant) in cap_methods
+ middle = cap[(cap.variant .== variant) .& (cap.requested_particles .== RESOLUTIONS[2]),
+ :]
+ sign_rows = signs[signs.variant .== variant, :]
+ middle_passes = count(middle.middle_pass)
+ endpoint_passes = count(middle.endpoint_pass)
+ measure_gate = middle_passes == length(TARGET_ANGLES) &&
+ endpoint_passes == length(TARGET_ANGLES)
+ angle_middle_passes = count(middle.angle_middle_pass)
+ angle_endpoint_passes = count(middle.angle_endpoint_pass)
+ angle_gate = angle_middle_passes == length(TARGET_ANGLES) &&
+ angle_endpoint_passes == length(TARGET_ANGLES)
+ sign_cases = nrow(sign_rows)
+ sign_passes = count(sign_rows.sign_pass)
+ zero_values = collect(skipmissing(sign_rows.wall_zero_at_90))
+ zero_at_90 = isempty(zero_values) ? missing : all(zero_values)
+ eligible_for_dynamics = role == "candidate" && measure_gate && angle_gate &&
+ sign_cases == 4 && sign_passes == 4
+ push!(rows,
+ (; method, role, measure="contact_line", middle_passes,
+ endpoint_passes, measure_gate, angle_middle_passes,
+ angle_endpoint_passes, angle_gate, sign_cases, sign_passes,
+ zero_at_90, eligible_for_dynamics,
+ max_middle_error=maximum(middle.line_length_error)))
+ end
+
+ middle = area[area.requested_particles .== RESOLUTIONS[2], :]
+ sign_rows = signs[signs.variant .== "corrected_wetted_area", :]
+ middle_passes = count(middle.middle_pass)
+ endpoint_passes = count(middle.endpoint_pass)
+ measure_gate = middle_passes == length(TARGET_ANGLES) &&
+ endpoint_passes == length(TARGET_ANGLES)
+ sign_cases = nrow(sign_rows)
+ sign_passes = count(sign_rows.sign_pass)
+ zero_values = collect(skipmissing(sign_rows.wall_zero_at_90))
+ zero_at_90 = length(zero_values) == 2 && all(zero_values)
+ push!(rows,
+ (; method="R7-W corrected wetted-area energy", role="candidate",
+ measure="wetted_area", middle_passes, endpoint_passes, measure_gate,
+ angle_middle_passes=0, angle_endpoint_passes=0, angle_gate=true,
+ sign_cases, sign_passes, zero_at_90,
+ eligible_for_dynamics=measure_gate && sign_cases == 4 &&
+ sign_passes == 4 && zero_at_90,
+ max_middle_error=maximum(middle.corrected_area_error)))
+
+ data = DataFrame(rows)
+ CSV.write(output_path, data)
+ println(data)
+ println("Wrote extended R7 recovery comparison to ", output_path)
+ return data
+end
+
+function cap_transfer_row(variant, target, resolution, setup, components,
+ interface_gradient, wall_gradient, coarea_normalization;
+ particle_scale=ones(length(components.volume)))
+ measure = 0.0
+ for particle in eachindex(components.volume)
+ measure += components.volume[particle] * particle_scale[particle] *
+ norm(cross(interface_gradient[:, particle],
+ wall_gradient[:, particle]))
+ end
+ line_length = coarea_normalization * measure
+ analytic_line_length = 2pi * setup.cap_radius
+ return (; variant=String(variant), target, requested_particles=resolution,
+ particle_count=length(components.volume), measure, coarea_normalization,
+ line_length, analytic_line_length,
+ line_length_error=abs(line_length / analytic_line_length - 1))
+end
+
+function run_cap_transfer_diagnostics(; output_path=CAP_TRANSFER_PATH)
+ kernel = TrixiParticles.WendlandC2Kernel{3}()
+ profile_data = kernel_plane_profile(kernel)
+ coarea_normalization = inv(profile_data.half_profile_integral^2)
+ rows = NamedTuple[]
+ for resolution in RESOLUTIONS, target in TARGET_ANGLES
+ result = css_sessile_drop(target, 0.0, nothing;
+ target_particle_count=resolution,
+ mechanism=:contact_line_force,
+ initial_contact_angle=target,
+ boundary_contact_threshold=0.1,
+ damping_coefficient=4000.0)
+ components = raw_normal_components(result)
+ setup = spherical_cap_initial_condition(target;
+ target_particle_count=resolution)
+ analytic = analytic_cap_gradients(result, components, setup, profile_data)
+ compatible = compatible_indicator_gradients(result, components)
+ push!(rows,
+ cap_transfer_row(:production_discrete, target, resolution, setup,
+ components, components.fluid_normal,
+ components.wall_normal, coarea_normalization))
+ push!(rows,
+ cap_transfer_row(:analytic_wall, target, resolution, setup,
+ components, components.fluid_normal,
+ analytic.wall_gradient, coarea_normalization))
+ push!(rows,
+ cap_transfer_row(:analytic_interface, target, resolution, setup,
+ components, analytic.interface_gradient,
+ components.wall_normal, coarea_normalization))
+ push!(rows,
+ cap_transfer_row(:analytic_both, target, resolution, setup,
+ components, analytic.interface_gradient,
+ analytic.wall_gradient, coarea_normalization))
+ push!(rows,
+ cap_transfer_row(:compatible_indicator, target, resolution, setup,
+ components, compatible.interface_gradient,
+ compatible.wall_gradient, coarea_normalization))
+ inverse_support = map(components.support_moment) do moment
+ moment > sqrt(eps(moment)) ? inv(moment) : 0.0
+ end
+ push!(rows,
+ cap_transfer_row(:support_moment, target, resolution, setup,
+ components, components.fluid_normal,
+ components.wall_normal, coarea_normalization;
+ particle_scale=inverse_support))
+ end
+
+ data = DataFrame(rows)
+ data.middle_pass = data.line_length_error .<= 0.2
+ data.endpoint_decreasing = falses(nrow(data))
+ for group in groupby(data, [:variant, :target])
+ coarse = only(group[group.requested_particles .== first(RESOLUTIONS),
+ :line_length_error])
+ fine = only(group[group.requested_particles .== last(RESOLUTIONS),
+ :line_length_error])
+ parent_rows = parentindices(group)[1]
+ data[parent_rows, :endpoint_decreasing] .= fine < coarse || fine <= 1.0e-12
+ end
+ sort!(data, [:variant, :requested_particles, :target])
+ CSV.write(output_path, data)
+ for variant in unique(data.variant)
+ middle = data[(data.variant .== variant) .& (data.requested_particles .== RESOLUTIONS[2]),
+ :]
+ @printf("%-22s middle %d/5, endpoints %d/5, max middle error %.3f\n",
+ variant, count(middle.middle_pass),
+ count(middle.endpoint_decreasing), maximum(middle.line_length_error))
+ end
+ println("Wrote cap-transfer diagnostics to ", output_path)
+ return data
+end
+
+function normal_component_row(target, initial_angle, resolution, variant, candidate_normal,
+ components, local_angle)
+ (; total_normal, wall_normal, line_delta, surface_delta, support_moment,
+ volume) = components
+ active_particles = findall(>(0), line_delta)
+ valid_particles = filter(active_particles) do particle
+ dot(wall_normal[:, particle], wall_normal[:, particle]) > eps() &&
+ dot(candidate_normal[:, particle], candidate_normal[:, particle]) > eps()
+ end
+ total_weight = sum(line_delta[active_particles])
+ valid_weight = sum(line_delta[valid_particles])
+ angles = Float64[]
+ weights = Float64[]
+ for particle in valid_particles
+ wall = wall_normal[:, particle]
+ candidate = candidate_normal[:, particle]
+ cosine = dot(wall, candidate) / sqrt(dot(wall, wall) * dot(candidate, candidate))
+ push!(angles, acosd(clamp(cosine, -1, 1)))
+ push!(weights, line_delta[particle])
+ end
+
+ weight_sum = sum(weights)
+ angle_mean = weight_sum > 0 ? sum(weights .* angles) / weight_sum : NaN
+ angle_median = weighted_quantile(angles, weights, 0.5)
+ angle_p10 = weighted_quantile(angles, weights, 0.1)
+ angle_p90 = weighted_quantile(angles, weights, 0.9)
+ reference_cosine_error = cosd(target) - cosd(local_angle)
+ wrong_sign_weight = if isapprox(reference_cosine_error, 0.0; atol=1.0e-12)
+ NaN
+ else
+ wrong_weight = 0.0
+ for (weight, angle) in zip(weights, angles)
+ if reference_cosine_error * (cosd(target) - cosd(angle)) <= 0
+ wrong_weight += weight
+ end
+ end
+ weight_sum > 0 ? wrong_weight / weight_sum : NaN
+ end
+ setup = spherical_cap_initial_condition(initial_angle;
+ target_particle_count=resolution)
+ analytic_line_length = 2pi * setup.cap_radius
+ discrete_line_length = sum(volume .* line_delta)
+ cross_line_length = 0.0
+ cross_line_length_raw = 0.0
+ corrected_cross_line_length = 0.0
+ for particle in eachindex(volume)
+ total_norm = norm(total_normal[:, particle])
+ total_norm > eps(total_norm) || continue
+ cross_gradient = norm(cross(total_normal[:, particle], wall_normal[:, particle]))
+ activity = surface_delta[particle] / (2total_norm)
+ cross_line_length += volume[particle] * activity * cross_gradient
+ cross_line_length_raw += volume[particle] * cross_gradient
+ correction = support_moment[particle]
+ if correction > sqrt(eps(correction))
+ corrected_cross_line_length += volume[particle] * cross_gradient / correction
+ end
+ end
+
+ return (; kind="normal", variant=String(variant), target, initial_angle,
+ requested_particles=resolution, particle_count=length(volume), local_angle,
+ angle_mean, angle_median, angle_p10, angle_p90,
+ mean_error=abs(angle_mean - local_angle),
+ median_error=abs(angle_median - local_angle), wrong_sign_weight,
+ valid_weight_fraction=total_weight > 0 ? valid_weight / total_weight : 0.0,
+ line_particles=length(active_particles),
+ valid_particles=length(valid_particles),
+ line_weight=total_weight, discrete_line_length, analytic_line_length,
+ line_length_error=abs(discrete_line_length / analytic_line_length - 1),
+ cross_line_length,
+ cross_error_1x=abs(cross_line_length / analytic_line_length - 1),
+ cross_error_2x=abs(2cross_line_length / analytic_line_length - 1),
+ cross_error_4x=abs(4cross_line_length / analytic_line_length - 1),
+ cross_line_length_raw,
+ raw_cross_error_4x=abs(4cross_line_length_raw / analytic_line_length - 1),
+ corrected_cross_line_length,
+ corrected_cross_error_4x=abs(4corrected_cross_line_length /
+ analytic_line_length - 1))
+end
+
+function run_decision_case(kind, target, initial_angle, mechanism;
+ resolution=1500, threshold=0.1,
+ damping=4000.0, final_time=0.01, repeat=1,
+ variant=:baseline)
+ result = nothing
+ total_runtime = @elapsed result = css_sessile_drop(target, final_time, nothing;
+ target_particle_count=resolution,
+ mechanism,
+ initial_contact_angle=initial_angle,
+ boundary_contact_threshold=threshold,
+ damping_coefficient=damping)
+ initial_error = result.initial_circle.angle - target
+ final_error = result.circle.angle - target
+ error_reduction = abs(initial_error) > eps() ?
+ 1 - abs(final_error) / abs(initial_error) : NaN
+ cache = result.solution.prob.p.semi.systems[1].cache
+ scalar_size = sizeof(eltype(result.solution.prob.p.semi.systems[1]))
+ contact_scalars = mechanism == :none ? 0 : mechanism == :geometric ? 3 : 5
+
+ return (; kind=String(kind), variant=String(variant), target, initial_angle,
+ mechanism=String(mechanism), requested_particles=resolution,
+ particle_count=result.particle_count, threshold, damping, final_time, repeat,
+ initial_circle=result.initial_circle.angle,
+ final_circle=result.circle.angle, initial_error, final_error, error_reduction,
+ cap_angle=result.final.angle, normal_angle=result.measured_contact_angle,
+ line_angle=result.line_contact_angle,
+ shape_acceleration=result.shape_acceleration,
+ boundary_shape_acceleration=result.boundary_shape_acceleration,
+ below_wall=result.below_wall, density_min=result.density_range[1],
+ density_max=result.density_range[2], rms_speed=result.rms_speed,
+ settled=result.settled, accepted_steps=result.accepted_steps,
+ rejected_steps=result.rejected_steps,
+ rejected_fraction=result.rejected_fraction,
+ minimum_dt=result.minimum_dt, dt_reference=result.dt_reference,
+ eta_p01=result.eta_p01, eta_median=result.eta_median,
+ eta_tail_head=result.eta_tail_head,
+ solver_runtime=result.runtime, total_runtime,
+ cache_bytes=Base.summarysize(cache),
+ contact_cache_bytes=contact_scalars * result.particle_count * scalar_size)
+end
+
+function run_normal_diagnostics(; output_path=NORMAL_COMPONENTS_PATH)
+ off_target = Dict(60.0 => (90.0,), 90.0 => (60.0, 120.0), 120.0 => (90.0,))
+ rows = NamedTuple[]
+ for resolution in RESOLUTIONS, initial_angle in TARGET_ANGLES
+ result = css_sessile_drop(initial_angle, 0.0, nothing;
+ target_particle_count=resolution,
+ mechanism=:contact_line_force,
+ initial_contact_angle=initial_angle,
+ boundary_contact_threshold=0.1,
+ damping_coefficient=4000.0)
+ components = raw_normal_components(result)
+ targets = Float64[initial_angle]
+ if resolution == 1500
+ for (target, initial_angles) in off_target
+ initial_angle in initial_angles && push!(targets, target)
+ end
+ end
+ for target in unique(targets)
+ push!(rows,
+ normal_component_row(target, initial_angle, resolution,
+ :baseline_total, components.total_normal,
+ components, result.initial_circle.angle))
+ push!(rows,
+ normal_component_row(target, initial_angle, resolution,
+ :fluid_only, components.fluid_normal,
+ components, result.initial_circle.angle))
+ end
+ end
+ data = DataFrame(rows)
+ sort!(data, [:variant, :requested_particles, :initial_angle, :target])
+ CSV.write(output_path, data)
+
+ for variant in ("baseline_total", "fluid_only")
+ static_middle = data[(data.variant .== variant) .& (data.requested_particles .== 1500) .& (data.target .== data.initial_angle),
+ :]
+ off_target_rows = data[(data.variant .== variant) .& (data.target .!= data.initial_angle),
+ :]
+ @printf("%-15s static max mean error %.3f deg, off-target max wrong weight %.3f, min coverage %.3f\n",
+ variant, maximum(static_middle.mean_error),
+ maximum(off_target_rows.wrong_sign_weight),
+ minimum(data[data.variant .== variant, :valid_weight_fraction]))
+ end
+ println("Wrote raw normal diagnostics to ", output_path)
+ return data
+end
+
+function run_force_sign_cases(; variant=:baseline, output_path=FORCE_SIGN_PATH)
+ angle_pairs = ((60.0, 90.0), (90.0, 60.0), (90.0, 120.0), (120.0, 90.0))
+ rows = [run_decision_case(:force_sign, target, initial, mechanism;
+ final_time=0.0, variant)
+ for (target, initial) in angle_pairs for mechanism in HISTORICAL_MECHANISMS]
+ data = DataFrame(rows)
+ data.contact_shape_acceleration = fill(NaN, nrow(data))
+ data.expected_direction = sign.(data.target .- data.initial_angle)
+ data.sign_pass = falses(nrow(data))
+
+ for (target, initial) in angle_pairs
+ group = findall((data.target .== target) .& (data.initial_angle .== initial))
+ control = only(filter(index -> data.mechanism[index] == "none", group))
+ for index in group
+ data.contact_shape_acceleration[index] = data.shape_acceleration[index] -
+ data.shape_acceleration[control]
+ data.sign_pass[index] = data.mechanism[index] == "none" ||
+ data.expected_direction[index] *
+ data.contact_shape_acceleration[index] > 0
+ end
+ end
+ CSV.write(output_path, data)
+ for mechanism in String.(REJECTED_MECHANISMS)
+ mechanism_rows = data[data.mechanism .== mechanism, :]
+ println(mechanism, " fixed-particle signs: ", count(mechanism_rows.sign_pass),
+ "/", nrow(mechanism_rows))
+ end
+ println("Wrote fixed-particle force signs to ", output_path)
+ return data
+end
+
+function run_ghost_geometric_force_sign(; output_path=GHOST_FORCE_SIGN_PATH)
+ angle_pairs = ((60.0, 90.0), (90.0, 60.0), (90.0, 120.0), (120.0, 90.0))
+ rows = NamedTuple[]
+ for (target, initial_angle) in angle_pairs
+ control = css_sessile_drop(target, 0.0, nothing;
+ target_particle_count=1500, mechanism=:none,
+ initial_contact_angle=initial_angle,
+ boundary_contact_threshold=0.1,
+ damping_coefficient=4000.0)
+ geometric = css_sessile_drop(target, 0.0, nothing;
+ target_particle_count=1500, mechanism=:geometric,
+ initial_contact_angle=initial_angle,
+ boundary_contact_threshold=0.1,
+ damping_coefficient=4000.0)
+ components = raw_normal_components(geometric)
+ acceleration = ghost_geometric_shape_acceleration(geometric, components, target)
+ contact_acceleration = acceleration - control.shape_acceleration
+ expected_direction = sign(target - initial_angle)
+ push!(rows,
+ (; kind="force_sign", variant="ghost_geometric", target, initial_angle,
+ requested_particles=1500, particle_count=geometric.particle_count,
+ control_acceleration=control.shape_acceleration, acceleration,
+ contact_shape_acceleration=contact_acceleration, expected_direction,
+ sign_pass=expected_direction * contact_acceleration > 0))
+ end
+ data = DataFrame(rows)
+ CSV.write(output_path, data)
+ println("ghost geometric fixed-particle signs: ", count(data.sign_pass), "/",
+ nrow(data))
+ println("Wrote ghost-geometric force signs to ", output_path)
+ return data
+end
+
+function run_wall_energy_force_sign(; output_path=WALL_ENERGY_FORCE_SIGN_PATH)
+ angle_pairs = ((60.0, 90.0), (90.0, 60.0), (90.0, 120.0), (120.0, 90.0))
+ rows = NamedTuple[]
+ for (target, initial_angle) in angle_pairs
+ control = css_sessile_drop(target, 0.0, nothing;
+ target_particle_count=1500, mechanism=:none,
+ initial_contact_angle=initial_angle,
+ boundary_contact_threshold=0.1,
+ damping_coefficient=4000.0)
+ clf = css_sessile_drop(target, 0.0, nothing;
+ target_particle_count=1500,
+ mechanism=:contact_line_force,
+ initial_contact_angle=initial_angle,
+ boundary_contact_threshold=0.1,
+ damping_coefficient=4000.0)
+ expected_direction = sign(target - initial_angle)
+ for line_delta_scale in (1.0, 2.0)
+ wall_acceleration = wall_energy_shape_acceleration(clf, target;
+ line_delta_scale)
+ acceleration = control.shape_acceleration + wall_acceleration
+ push!(rows,
+ (; kind="force_sign", variant="wall_energy_$(Int(line_delta_scale))x",
+ target, initial_angle, requested_particles=1500,
+ particle_count=clf.particle_count,
+ control_acceleration=control.shape_acceleration,
+ wall_acceleration, acceleration,
+ contact_shape_acceleration=wall_acceleration, expected_direction,
+ sign_pass=expected_direction * acceleration > 0))
+ end
+ end
+ data = DataFrame(rows)
+ CSV.write(output_path, data)
+ for variant in unique(data.variant)
+ rows_variant = data[data.variant .== variant, :]
+ println(variant, " total fixed-particle signs: ", count(rows_variant.sign_pass),
+ "/", nrow(rows_variant))
+ end
+ println("Wrote wall-energy force signs to ", output_path)
+ return data
+end
+
+function validate_phase2_inputs(matrix, sensitivity)
+ require(nrow(matrix) == 30, "Phase 2 matrix must contain exactly 30 rows")
+ require(nrow(sensitivity) == 8,
+ "Phase 2 sensitivity matrix must contain exactly eight rows")
+
+ for mechanism in String.(REJECTED_MECHANISMS), target in TARGET_ANGLES,
+ resolution in RESOLUTIONS
+ rows = matrix[(matrix.mechanism .== mechanism) .& (matrix.target .== target) .& (matrix.requested_particles .== resolution),
+ :]
+ require(nrow(rows) == 1,
+ "missing or duplicate Phase 2 row for $mechanism/$target/$resolution")
+ end
+ for mechanism in String.(REJECTED_MECHANISMS), threshold in (0.0, 0.1),
+ damping in (2000.0, 8000.0)
+ rows = sensitivity[(sensitivity.mechanism .== mechanism) .& (sensitivity.boundary_contact_threshold .== threshold) .& (sensitivity.damping_coefficient .== damping),
+ :]
+ require(nrow(rows) == 1,
+ "missing or duplicate sensitivity row for $mechanism/$threshold/$damping")
+ end
+
+ for column in (:circle_angle, :circle_error, :rms_speed, :density_min,
+ :density_max, :minimum_dt, :runtime)
+ require(all(isfinite, matrix[!, column]), "non-finite Phase 2 field: $column")
+ end
+ require(all(matrix.circle_error .<= 5), "a Phase 2 angle row exceeds 5 degrees")
+ require(all(matrix.below_wall .== 0), "a Phase 2 row penetrates the wall")
+ require(all((matrix.density_min .>= 980) .& (matrix.density_max .<= 1020)),
+ "a Phase 2 row violates density bounds")
+ require(all(matrix.rms_speed .< 5.0e-3), "a Phase 2 row is not settled")
+ require(all(matrix.accepted_steps .< 2000), "a Phase 2 row exceeds the step budget")
+ rejected_fraction = matrix.rejected_steps ./
+ (matrix.accepted_steps .+ matrix.rejected_steps)
+ require(all(rejected_fraction .<= 0.25),
+ "a Phase 2 row exceeds the rejection budget")
+ require(all(sensitivity.pass), "a Phase 2 sensitivity row failed")
+
+ return nothing
+end
+
+function score_phase2(; output_path=SCORECARD_PATH)
+ matrix = CSV.read(PHASE2_MATRIX, DataFrame)
+ sensitivity = CSV.read(PHASE2_SENSITIVITY, DataFrame)
+ validate_phase2_inputs(matrix, sensitivity)
+ perturbation = isfile(PERTURBATION_PATH) ? CSV.read(PERTURBATION_PATH, DataFrame) :
+ DataFrame()
+ threshold_replay = isfile(THRESHOLD_PATH) ? CSV.read(THRESHOLD_PATH, DataFrame) :
+ DataFrame()
+ timestep = isfile(TIMESTEP_PATH) ? CSV.read(TIMESTEP_PATH, DataFrame) : DataFrame()
+ cost = isfile(COST_PATH) ? CSV.read(COST_PATH, DataFrame) : DataFrame()
+ control_runtime = isempty(cost) ? NaN :
+ median(cost[cost.mechanism .== "none", :solver_runtime])
+
+ rows = NamedTuple[]
+ for mechanism in String.(REJECTED_MECHANISMS)
+ mechanism_rows = matrix[matrix.mechanism .== mechanism, :]
+ resolution_metrics = Dict{Int, NamedTuple}()
+ monotone_targets = true
+ for resolution in RESOLUTIONS
+ data = mechanism_rows[mechanism_rows.requested_particles .== resolution, :]
+ sort!(data, :target)
+ monotone_targets &= all(diff(data.circle_angle) .> 0)
+ resolution_metrics[resolution] = (; mae=mean(data.circle_error),
+ maximum=maximum(data.circle_error))
+ end
+ endpoint_regressions = 0
+ for target in TARGET_ANGLES
+ data = mechanism_rows[mechanism_rows.target .== target, :]
+ coarse = only(data[data.requested_particles .== 750, :circle_error])
+ fine = only(data[data.requested_particles .== 3000, :circle_error])
+ endpoint_regressions += fine > coarse
+ end
+ sensitivity_rows = sensitivity[sensitivity.mechanism .== mechanism, :]
+ sensitivity_span = maximum(sensitivity_rows.circle_angle) -
+ minimum(sensitivity_rows.circle_angle)
+ rejected_fraction = mechanism_rows.rejected_steps ./
+ (mechanism_rows.accepted_steps .+
+ mechanism_rows.rejected_steps)
+ coarse = resolution_metrics[750]
+ medium = resolution_metrics[1500]
+ fine = resolution_metrics[3000]
+ static_eligible = monotone_targets && coarse.mae >= fine.mae &&
+ coarse.maximum >= fine.maximum && sensitivity_span <= 1 &&
+ all(rejected_fraction .<= 0.25)
+ response_rows = isempty(perturbation) ? DataFrame() :
+ perturbation[perturbation.mechanism .== mechanism, :]
+ response_cases = nrow(response_rows)
+ response_passes = response_cases == 0 ? 0 : count(response_rows.response_pass)
+ acceleration_passes = response_cases == 0 ? 0 :
+ count(response_rows.acceleration_toward_target)
+ motion_passes = response_cases == 0 ? 0 :
+ count(response_rows.motion_toward_target)
+ mean_error_reduction = response_cases == 0 ? NaN :
+ mean(response_rows.error_reduction)
+ replay_rows = isempty(threshold_replay) ? DataFrame() :
+ threshold_replay[threshold_replay.mechanism .== mechanism, :]
+ threshold_pass = nrow(replay_rows) == length(TARGET_ANGLES) && all(replay_rows.pass)
+ timestep_rows = isempty(timestep) ? DataFrame() :
+ timestep[timestep.mechanism .== mechanism, :]
+ timestep_pass = nrow(timestep_rows) == 2 && all(timestep_rows.pass)
+ cost_rows = isempty(cost) ? DataFrame() : cost[cost.mechanism .== mechanism, :]
+ median_runtime = isempty(cost_rows) ? NaN : median(cost_rows.solver_runtime)
+ runtime_mad = isempty(cost_rows) ? NaN :
+ median(abs.(cost_rows.solver_runtime .- median_runtime))
+ runtime_overhead = median_runtime / control_runtime
+ contact_cache_bytes = isempty(cost_rows) ? 0 :
+ round(Int, median(cost_rows.contact_cache_bytes))
+ eligible = static_eligible && threshold_pass && timestep_pass &&
+ response_cases == 4 && response_passes == 4
+ push!(rows,
+ (; mechanism, eligible, static_cells=nrow(mechanism_rows), static_eligible,
+ monotone_targets, mae_750=coarse.mae, max_750=coarse.maximum,
+ mae_1500=medium.mae, max_1500=medium.maximum,
+ mae_3000=fine.mae, max_3000=fine.maximum,
+ endpoint_regressions, sensitivity_span,
+ threshold_pass, timestep_pass, response_cases, response_passes,
+ acceleration_passes, motion_passes, mean_error_reduction,
+ median_runtime, runtime_mad, runtime_overhead, contact_cache_bytes,
+ worst_rms_speed=maximum(mechanism_rows.rms_speed),
+ density_min=minimum(mechanism_rows.density_min),
+ density_max=maximum(mechanism_rows.density_max),
+ max_rejected_fraction=maximum(rejected_fraction),
+ minimum_dt=minimum(mechanism_rows.minimum_dt),
+ mean_runtime=mean(mechanism_rows.runtime),
+ total_runtime=sum(mechanism_rows.runtime)))
+ end
+ scorecard = DataFrame(rows)
+ require(all(scorecard.static_eligible),
+ "at least one mechanism fails static eligibility")
+ CSV.write(output_path, scorecard)
+ println(scorecard)
+ println("Wrote Phase 3 scorecard to ", output_path)
+ return scorecard
+end
+
+function run_threshold_replay(; output_path=THRESHOLD_PATH)
+ rows = [run_decision_case(:threshold, target, target, mechanism)
+ for mechanism in REJECTED_MECHANISMS for target in TARGET_ANGLES]
+ data = DataFrame(rows)
+ data.pass = (abs.(data.final_error) .<= 5) .& (data.below_wall .== 0) .&
+ (data.density_min .>= 980) .& (data.density_max .<= 1020) .&
+ (data.rms_speed .< 5.0e-3) .& (data.rejected_fraction .<= 0.25)
+ require(all(data.pass), "recommended-threshold replay failed")
+ CSV.write(output_path, data)
+ println("Wrote threshold replay to ", output_path)
+ return data
+end
+
+function run_perturbation_matrix(; output_path=PERTURBATION_PATH)
+ angle_pairs = ((60.0, 90.0), (90.0, 60.0), (90.0, 120.0), (120.0, 90.0))
+ rows = [run_decision_case(:perturbation, target, initial, mechanism)
+ for (target, initial) in angle_pairs for mechanism in HISTORICAL_MECHANISMS]
+ data = DataFrame(rows)
+ data.control_error_reduction = fill(NaN, nrow(data))
+ data.contact_shape_acceleration = fill(NaN, nrow(data))
+ data.motion_toward_target = falses(nrow(data))
+ data.beats_control = falses(nrow(data))
+ data.acceleration_toward_target = falses(nrow(data))
+ data.response_pass = falses(nrow(data))
+
+ for (target, initial) in angle_pairs
+ group = findall((data.target .== target) .& (data.initial_angle .== initial))
+ control = only(filter(index -> data.mechanism[index] == "none", group))
+ direction = sign(target - initial)
+ for index in group
+ data.control_error_reduction[index] = data.error_reduction[control]
+ data.contact_shape_acceleration[index] = data.shape_acceleration[index] -
+ data.shape_acceleration[control]
+ data.motion_toward_target[index] = direction * (data.final_circle[index] -
+ data.initial_circle[index]) > 0
+ if data.mechanism[index] == "none"
+ data.beats_control[index] = true
+ data.acceleration_toward_target[index] = true
+ data.response_pass[index] = true
+ else
+ data.beats_control[index] = data.error_reduction[index] >
+ data.error_reduction[control]
+ data.acceleration_toward_target[index] = direction *
+ data.contact_shape_acceleration[index] >
+ 0
+ data.response_pass[index] = data.error_reduction[index] > 0 &&
+ data.motion_toward_target[index] &&
+ data.beats_control[index] &&
+ data.acceleration_toward_target[index] &&
+ data.below_wall[index] == 0 &&
+ 980 <= data.density_min[index] <= 1020 &&
+ 980 <= data.density_max[index] <= 1020 &&
+ data.rejected_fraction[index] <= 0.25
+ end
+ end
+ end
+ CSV.write(output_path, data)
+ println("Wrote perturbation matrix to ", output_path)
+ return data
+end
+
+function run_timestep_cases(; output_path=TIMESTEP_PATH)
+ rows = NamedTuple[]
+ for mechanism in REJECTED_MECHANISMS,
+ (target, resolution) in ((90.0, 1500),
+ (30.0, 3000))
+ push!(rows, run_decision_case(:timestep, target, target, mechanism; resolution))
+ end
+ data = DataFrame(rows)
+ data.pass = (data.eta_p01 .>= 0.05) .& (data.eta_tail_head .>= 0.5) .&
+ (data.rejected_fraction .<= 0.25) .& (data.below_wall .== 0) .&
+ (data.density_min .>= 980) .& (data.density_max .<= 1020)
+ require(all(data.pass), "a representative timestep case failed")
+ CSV.write(output_path, data)
+ println("Wrote timestep diagnostics to ", output_path)
+ return data
+end
+
+function run_cost_cases(; output_path=COST_PATH)
+ for mechanism in HISTORICAL_MECHANISMS
+ run_decision_case(:warmup, 90.0, 90.0, mechanism;
+ resolution=200, final_time=0.001)
+ end
+
+ orders = ((:none, :geometric, :contact_line_force),
+ (:geometric, :contact_line_force, :none),
+ (:contact_line_force, :none, :geometric))
+ rows = NamedTuple[]
+ for (repeat, order) in enumerate(orders), mechanism in order
+ push!(rows, run_decision_case(:cost, 90.0, 90.0, mechanism; repeat))
+ end
+ data = DataFrame(rows)
+ control_median = median(data[data.mechanism .== "none", :solver_runtime])
+ data.normalized_runtime = data.solver_runtime ./ control_median
+ CSV.write(output_path, data)
+
+ for mechanism in String.(HISTORICAL_MECHANISMS)
+ values = data[data.mechanism .== mechanism, :solver_runtime]
+ center = median(values)
+ @printf("%-18s median %.3f s MAD %.3f s overhead %.3f\n",
+ mechanism, center, median(abs.(values .- center)), center / control_median)
+ end
+ println("Wrote repeated cost measurements to ", output_path)
+ return data
+end
+
+function run_selected_matrix(mechanism; output_path=SELECTED_PATH)
+ require(mechanism in REJECTED_MECHANISMS,
+ "historical mechanism must be geometric or CLF")
+ rows = [run_decision_case(:selected, target, target, mechanism; resolution)
+ for resolution in RESOLUTIONS for target in TARGET_ANGLES]
+ data = DataFrame(rows)
+ data.pass = (abs.(data.final_error) .<= 5) .& (data.below_wall .== 0) .&
+ (data.density_min .>= 980) .& (data.density_max .<= 1020) .&
+ (data.rms_speed .< 5.0e-3) .& (data.rejected_fraction .<= 0.25) .&
+ (data.eta_p01 .>= 0.05) .& (data.eta_tail_head .>= 0.5)
+ require(all(data.pass), "selected-mechanism replay failed")
+ CSV.write(output_path, data)
+ println("Wrote selected-mechanism matrix to ", output_path)
+ return data
+end
+
+function r4_wetted_area_parameters(; smoothing_length_ratio=1.4)
+ kernel = TrixiParticles.WendlandC2Kernel{3}()
+ profile_data = kernel_plane_profile(kernel)
+ offset_data = kernel_lattice_color_offsets(kernel, 64)
+ edge_data = Dict(target => canonical_wetted_edge_data(profile_data, offset_data,
+ target;
+ production_cells_per_h=smoothing_length_ratio)
+ for target in TARGET_ANGLES)
+ boundary_distance = inv(2smoothing_length_ratio)
+ flooded_reference = halfspace_color_value(profile_data, boundary_distance)
+ return (; edge_data, flooded_reference, smoothing_length_ratio)
+end
+
+function r4_wetted_area_model(target, parameters)
+ edge = parameters.edge_data[target]
+ return CorrectedWettedAreaContact(target, edge.normalized_shift,
+ parameters.flooded_reference)
+end
+
+function run_r4_simulation(target, initial_angle, parameters; active=true,
+ resolution=1500, threshold=0.1, damping=4000.0,
+ final_time=0.01)
+ mechanism = active ? :corrected_wetted_area : :none
+ validation_contact_model = active ? r4_wetted_area_model(target, parameters) : nothing
+ return quiet_css_sessile_drop(target, final_time, nothing;
+ target_particle_count=resolution, mechanism,
+ initial_contact_angle=initial_angle,
+ boundary_contact_threshold=threshold,
+ damping_coefficient=damping,
+ smoothing_length_ratio=parameters.smoothing_length_ratio,
+ validation_contact_model)
+end
+
+function r4_perturbed_energy(model, system, boundary_system, coordinates,
+ boundary_coordinates, density, density_rate,
+ displacement, pairs, epsilon)
+ colorfield = copy(boundary_system.boundary_model.cache.initial_colorfield)
+ for (particle, neighbor) in pairs
+ model.exposed[neighbor] || continue
+ distance2 = zero(eltype(system))
+ for dim in 1:TrixiParticles.ndims(system)
+ difference = coordinates[dim, particle] +
+ epsilon * displacement[dim, particle] -
+ boundary_coordinates[dim, neighbor]
+ distance2 += difference^2
+ end
+ perturbed_density = density[particle] + epsilon * density_rate[particle]
+ colorfield[neighbor] += TrixiParticles.hydrodynamic_mass(system, particle) /
+ perturbed_density *
+ TrixiParticles.smoothing_kernel(system, sqrt(distance2),
+ particle)
+ end
+
+ particle_spacing = system.cache.reference_particle_spacing
+ raw_area = zero(eltype(system))
+ for boundary_particle in TrixiParticles.eachparticle(boundary_system)
+ model.exposed[boundary_particle] || continue
+ fraction = clamp(colorfield[boundary_particle] / model.flooded_reference, 0, 1)
+ raw_area += particle_spacing^2 * smoothstep01(fraction)
+ end
+ raw_radius = sqrt(raw_area / pi)
+ edge_shift = model.normalized_edge_shift *
+ TrixiParticles.initial_smoothing_length(system)
+ corrected_radius = max(raw_radius - edge_shift, zero(raw_radius))
+ contact_cosine = r4_contact_cosine(model)
+ iszero(contact_cosine) && return zero(contact_cosine)
+ return -system.surface_tension.surface_tension_coefficient * contact_cosine * pi *
+ corrected_radius^2
+end
+
+function r4_directional_energy_gradient(result)
+ semi = result.solution.prob.p.semi
+ system = semi.systems[1]
+ v_ode, u_ode = result.solution.prob.u0.x
+ TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, 0.0)
+ model = TrixiParticles.surface_normal_method(system).contact_model
+ boundary_system = r4_wetted_area_boundary_system(semi)
+ v = TrixiParticles.wrap_v(v_ode, system, semi)
+ u = TrixiParticles.wrap_u(u_ode, system, semi)
+ u_boundary = TrixiParticles.wrap_u(u_ode, boundary_system, semi)
+ coordinates = Array(TrixiParticles.current_coordinates(u, system))
+ boundary_coordinates = Array(TrixiParticles.current_coordinates(u_boundary,
+ boundary_system))
+ density = [TrixiParticles.current_density(v, system, particle)
+ for particle in TrixiParticles.eachparticle(system)]
+
+ displacement = similar(coordinates)
+ displacement_scale = max(maximum(abs, coordinates),
+ system.cache.reference_particle_spacing)
+ for particle in TrixiParticles.eachparticle(system)
+ displacement[1, particle] = -coordinates[1, particle] / displacement_scale
+ displacement[2, particle] = -coordinates[2, particle] / displacement_scale
+ displacement[3, particle] = 2coordinates[3, particle] / displacement_scale
+ end
+
+ density_rate = zeros(eltype(system), TrixiParticles.nparticles(system))
+ TrixiParticles.foreach_point_neighbor(system, system, coordinates, coordinates, semi;
+ points=TrixiParticles.each_integrated_particle(system),
+ parallelization_backend=TrixiParticles.SerialBackend()) do particle,
+ neighbor,
+ pos_diff,
+ distance
+ gradient = TrixiParticles.smoothing_kernel_grad(system, pos_diff, distance,
+ particle)
+ velocity_dot_gradient = zero(eltype(system))
+ for dim in 1:TrixiParticles.ndims(system)
+ velocity_dot_gradient += (displacement[dim, particle] -
+ displacement[dim, neighbor]) * gradient[dim]
+ end
+ mass_b = TrixiParticles.hydrodynamic_mass(system, neighbor)
+ density_rate[particle] += density[particle] / density[neighbor] * mass_b *
+ velocity_dot_gradient
+ end
+
+ pairs = Tuple{Int, Int}[]
+ TrixiParticles.foreach_point_neighbor(system, boundary_system, coordinates,
+ boundary_coordinates, semi;
+ points=TrixiParticles.each_integrated_particle(system),
+ parallelization_backend=TrixiParticles.SerialBackend()) do particle,
+ neighbor,
+ pos_diff,
+ distance
+ push!(pairs, (particle, neighbor))
+ end
+
+ epsilon = 1.0e-5 * system.cache.reference_particle_spacing
+ energy_plus = r4_perturbed_energy(model, system, boundary_system, coordinates,
+ boundary_coordinates, density, density_rate,
+ displacement, pairs, epsilon)
+ energy_minus = r4_perturbed_energy(model, system, boundary_system, coordinates,
+ boundary_coordinates, density, density_rate,
+ displacement, pairs, -epsilon)
+ finite_difference = (energy_plus - energy_minus) / (2epsilon)
+ analytic_derivative = zero(eltype(system))
+ explicit_derivative = zero(eltype(system))
+ density_derivative = zero(eltype(system))
+ for particle in TrixiParticles.eachparticle(system),
+ dim in 1:TrixiParticles.ndims(system)
+ explicit_derivative -= model.explicit_force[dim, particle] *
+ displacement[dim, particle]
+ density_derivative -= model.density_force[dim, particle] *
+ displacement[dim, particle]
+ end
+ analytic_derivative = explicit_derivative + density_derivative
+ derivative_scale = max(abs(finite_difference), abs(analytic_derivative))
+ relative_error = iszero(derivative_scale) ? zero(derivative_scale) :
+ abs(finite_difference - analytic_derivative) / derivative_scale
+ return (; epsilon, energy_plus, energy_minus, finite_difference,
+ analytic_derivative, explicit_derivative, density_derivative,
+ relative_error, fluid_wall_pairs=length(pairs))
+end
+
+function r4_static_row(kind, target, initial_angle, result, diagnostics;
+ gradient=nothing, control_acceleration=NaN)
+ expected_direction = sign(target - initial_angle)
+ contact_shape_acceleration = isfinite(control_acceleration) ?
+ result.shape_acceleration - control_acceleration : NaN
+ total_sign_pass = kind == "force_sign" ?
+ expected_direction * result.shape_acceleration > 0 : true
+ contact_sign_pass = kind == "force_sign" ?
+ (target == 90 ? iszero(contact_shape_acceleration) :
+ expected_direction * contact_shape_acceleration > 0) : true
+ gradient_error = isnothing(gradient) ? NaN : gradient.relative_error
+ gradient_pass = isnothing(gradient) ? true : gradient_error <= 1.0e-5
+ zero_at_90 = target == 90 ?
+ iszero(diagnostics.energy) &&
+ iszero(diagnostics.explicit_force_scale) &&
+ iszero(diagnostics.density_force_scale) &&
+ iszero(diagnostics.wall_force_scale) : true
+ reaction_pass = diagnostics.explicit_reaction_residual <= 1.0e-12 &&
+ diagnostics.density_resultant_residual <= 1.0e-12 &&
+ diagnostics.total_momentum_residual <= 1.0e-12
+ finite_pass = all(isfinite,
+ (diagnostics.energy, diagnostics.raw_area,
+ diagnostics.corrected_area, diagnostics.area_derivative,
+ result.shape_acceleration,
+ diagnostics.explicit_reaction_residual,
+ diagnostics.density_resultant_residual,
+ diagnostics.total_momentum_residual))
+ stage_pass = gradient_pass && zero_at_90 && reaction_pass && finite_pass &&
+ total_sign_pass
+ return (; kind, target, initial_angle, requested_particles=1500,
+ particle_count=result.particle_count, energy=diagnostics.energy,
+ raw_area=diagnostics.raw_area, corrected_area=diagnostics.corrected_area,
+ area_derivative=diagnostics.area_derivative,
+ finite_difference=isnothing(gradient) ? NaN : gradient.finite_difference,
+ analytic_derivative=isnothing(gradient) ? NaN : gradient.analytic_derivative,
+ explicit_derivative=isnothing(gradient) ? NaN : gradient.explicit_derivative,
+ density_derivative=isnothing(gradient) ? NaN : gradient.density_derivative,
+ gradient_relative_error=gradient_error, gradient_pass,
+ shape_acceleration=result.shape_acceleration, control_acceleration,
+ contact_shape_acceleration, expected_direction, total_sign_pass,
+ contact_sign_pass, zero_at_90,
+ explicit_reaction_residual=diagnostics.explicit_reaction_residual,
+ density_resultant_residual=diagnostics.density_resultant_residual,
+ total_momentum_residual=diagnostics.total_momentum_residual,
+ explicit_force_scale=diagnostics.explicit_force_scale,
+ density_force_scale=diagnostics.density_force_scale,
+ wall_force_scale=diagnostics.wall_force_scale,
+ reaction_pass, finite_pass, stage_pass)
+end
+
+function run_r4_static_gate(; output_path=R4_STATIC_PATH)
+ parameters = r4_wetted_area_parameters()
+ rows = NamedTuple[]
+ for target in TARGET_ANGLES
+ result = run_r4_simulation(target, target, parameters; final_time=0.0)
+ gradient = r4_directional_energy_gradient(result)
+ diagnostics = corrected_wetted_area_contact_diagnostics(TrixiParticles.surface_normal_method(result.solution.prob.p.semi.systems[1]).contact_model)
+ push!(rows,
+ r4_static_row("energy_gradient", target, target, result,
+ diagnostics; gradient))
+ end
+
+ angle_pairs = ((60.0, 90.0), (90.0, 60.0),
+ (90.0, 120.0), (120.0, 90.0))
+ for (target, initial_angle) in angle_pairs
+ control = run_r4_simulation(target, initial_angle, parameters; active=false,
+ final_time=0.0)
+ result = run_r4_simulation(target, initial_angle, parameters; final_time=0.0)
+ diagnostics = corrected_wetted_area_contact_diagnostics(TrixiParticles.surface_normal_method(result.solution.prob.p.semi.systems[1]).contact_model)
+ push!(rows,
+ r4_static_row("force_sign", target, initial_angle, result,
+ diagnostics;
+ control_acceleration=control.shape_acceleration))
+ end
+ data = DataFrame(rows)
+ CSV.write(output_path, data)
+ println("R4-W static gate: ", count(data.stage_pass), "/", nrow(data),
+ "; force signs ", count(data[data.kind .== "force_sign", :total_sign_pass]),
+ "/4; max gradient error ",
+ maximum(data[data.kind .== "energy_gradient", :gradient_relative_error]))
+ println("Wrote R4-W static evidence to ", output_path)
+ require(all(data.stage_pass), "R4-W algebra/static gate failed")
+ return data
+end
+
+function require_r4_static_gate()
+ require(isfile(R4_STATIC_PATH), "run `r4_static` before R4 dynamics")
+ data = CSV.read(R4_STATIC_PATH, DataFrame)
+ require(count(data.kind .== "energy_gradient") == 5 &&
+ count(data.kind .== "force_sign") == 4 && all(data.stage_pass),
+ "R4-W static evidence does not pass")
+ return data
+end
+
+function r4_dynamic_row(kind, target, initial_angle, result; active=true,
+ resolution=1500, threshold=0.1, damping=4000.0,
+ final_time=0.01, repeat=1, total_runtime=result.runtime)
+ initial_error = result.initial_circle.angle - target
+ final_error = result.circle.angle - target
+ error_reduction = abs(initial_error) > eps() ?
+ 1 - abs(final_error) / abs(initial_error) : NaN
+ diagnostics = active ? result.initial_contact_diagnostics : nothing
+ final_diagnostics = active ? result.final_contact_diagnostics : nothing
+ return (; kind=String(kind), target, initial_angle,
+ mechanism=active ? "r4_wetted_area" : "none",
+ requested_particles=resolution, particle_count=result.particle_count,
+ threshold, damping, final_time, repeat,
+ initial_circle=result.initial_circle.angle,
+ final_circle=result.circle.angle, initial_error, final_error,
+ error_reduction, cap_angle=result.final.angle,
+ normal_angle=result.measured_contact_angle,
+ shape_acceleration=result.shape_acceleration,
+ boundary_shape_acceleration=result.boundary_shape_acceleration,
+ wetting_energy=active ? diagnostics.energy : 0.0,
+ explicit_reaction_residual=active ?
+ diagnostics.explicit_reaction_residual : 0.0,
+ density_resultant_residual=active ?
+ diagnostics.density_resultant_residual : 0.0,
+ total_momentum_residual=active ? diagnostics.total_momentum_residual : 0.0,
+ max_explicit_reaction_residual=active ?
+ final_diagnostics.max_explicit_reaction_residual :
+ 0.0,
+ max_density_resultant_residual=active ?
+ final_diagnostics.max_density_resultant_residual :
+ 0.0,
+ max_total_momentum_residual=active ?
+ final_diagnostics.max_total_momentum_residual : 0.0,
+ below_wall=result.below_wall, density_min=result.density_range[1],
+ density_max=result.density_range[2], rms_speed=result.rms_speed,
+ settled=result.settled, accepted_steps=result.accepted_steps,
+ rejected_steps=result.rejected_steps,
+ rejected_fraction=result.rejected_fraction,
+ minimum_dt=result.minimum_dt, dt_reference=result.dt_reference,
+ eta_p01=result.eta_p01, eta_median=result.eta_median,
+ eta_tail_head=result.eta_tail_head, solver_runtime=result.runtime,
+ total_runtime,
+ cache_bytes=Base.summarysize(result.solution.prob.p.semi.systems[1].cache),
+ contact_cache_bytes=active ? diagnostics.cache_bytes : 0)
+end
+
+function run_r4_dynamic_case(kind, target, initial_angle, parameters; active=true,
+ resolution=1500, threshold=0.1, damping=4000.0,
+ final_time=0.01, repeat=1)
+ result = nothing
+ total_runtime = @elapsed result = run_r4_simulation(target, initial_angle,
+ parameters; active, resolution,
+ threshold, damping, final_time)
+ return r4_dynamic_row(kind, target, initial_angle, result; active, resolution,
+ threshold, damping, final_time, repeat, total_runtime)
+end
+
+function r4_perturbation_data(parameters; final_time=0.01)
+ angle_pairs = ((60.0, 90.0), (90.0, 60.0),
+ (90.0, 120.0), (120.0, 90.0))
+ rows = [run_r4_dynamic_case(:perturbation, target, initial, parameters; active,
+ final_time)
+ for (target, initial) in angle_pairs for active in (false, true)]
+ data = DataFrame(rows)
+ data.control_error_reduction = fill(NaN, nrow(data))
+ data.contact_shape_acceleration = fill(NaN, nrow(data))
+ data.motion_toward_target = falses(nrow(data))
+ data.beats_control = falses(nrow(data))
+ data.acceleration_toward_target = falses(nrow(data))
+ data.reaction_pass = falses(nrow(data))
+ data.response_pass = falses(nrow(data))
+ for (target, initial) in angle_pairs
+ group = findall((data.target .== target) .& (data.initial_angle .== initial))
+ control = only(filter(index -> data.mechanism[index] == "none", group))
+ direction = sign(target - initial)
+ for index in group
+ data.control_error_reduction[index] = data.error_reduction[control]
+ data.contact_shape_acceleration[index] = data.shape_acceleration[index] -
+ data.shape_acceleration[control]
+ data.motion_toward_target[index] = direction *
+ (data.final_circle[index] -
+ data.initial_circle[index]) > 0
+ data.beats_control[index] = data.mechanism[index] == "none" ||
+ data.error_reduction[index] >
+ data.error_reduction[control]
+ data.acceleration_toward_target[index] = data.mechanism[index] == "none" ||
+ direction *
+ data.contact_shape_acceleration[index] >
+ 0
+ data.reaction_pass[index] = data.max_explicit_reaction_residual[index] <=
+ 1.0e-12 &&
+ data.max_density_resultant_residual[index] <=
+ 1.0e-12 &&
+ data.max_total_momentum_residual[index] <= 1.0e-12
+ data.response_pass[index] = data.mechanism[index] == "none" ||
+ (data.error_reduction[index] > 0 &&
+ data.motion_toward_target[index] &&
+ data.beats_control[index] &&
+ data.acceleration_toward_target[index] &&
+ data.below_wall[index] == 0 &&
+ 980 <= data.density_min[index] <= 1020 &&
+ 980 <= data.density_max[index] <= 1020 &&
+ data.rejected_fraction[index] <= 0.25 &&
+ data.reaction_pass[index])
+ end
+ end
+ return data
+end
+
+function run_r4_perturbation_gate(; output_path=R4_PERTURBATION_PATH,
+ final_time=0.01)
+ require_r4_static_gate()
+ parameters = r4_wetted_area_parameters()
+ data = r4_perturbation_data(parameters; final_time)
+ CSV.write(output_path, data)
+ candidates = data[data.mechanism .== "r4_wetted_area", :]
+ println("R4-W perturbation gate: ", count(candidates.response_pass), "/4",
+ "; acceleration ", count(candidates.acceleration_toward_target), "/4",
+ "; motion ", count(candidates.motion_toward_target), "/4")
+ println("Wrote R4-W perturbation evidence to ", output_path)
+ require(nrow(candidates) == 4 && all(candidates.response_pass),
+ "R4-W perturbation gate failed")
+ return data
+end
+
+function classify_r4_wetted_area_perturbation!(data)
+ angle_pairs = ((60.0, 90.0), (90.0, 60.0),
+ (90.0, 120.0), (120.0, 90.0))
+ data.control_equivalent = falses(nrow(data))
+ data.control_comparison_pass = falses(nrow(data))
+ data.effective_acceleration_toward_target = falses(nrow(data))
+ data.formulation_response_pass = falses(nrow(data))
+ for (target, initial) in angle_pairs
+ group = findall((data.target .== target) .& (data.initial_angle .== initial))
+ control = only(filter(index -> data.mechanism[index] == "none", group))
+ direction = sign(target - initial)
+ for index in group
+ if data.mechanism[index] == "none"
+ data.control_equivalent[index] = true
+ data.control_comparison_pass[index] = true
+ data.effective_acceleration_toward_target[index] = true
+ data.formulation_response_pass[index] = true
+ continue
+ end
+
+ zero_target = target == 90
+ data.control_equivalent[index] = zero_target &&
+ iszero(data.contact_shape_acceleration[index]) &&
+ data.final_circle[index] ==
+ data.final_circle[control] &&
+ data.error_reduction[index] ==
+ data.error_reduction[control]
+ data.control_comparison_pass[index] = zero_target ?
+ data.control_equivalent[index] :
+ data.beats_control[index]
+ data.effective_acceleration_toward_target[index] = if zero_target
+ data.control_equivalent[index] &&
+ direction * data.shape_acceleration[index] > 0
+ else
+ direction * data.contact_shape_acceleration[index] > 0
+ end
+ data.formulation_response_pass[index] = data.error_reduction[index] > 0 &&
+ data.motion_toward_target[index] &&
+ data.control_comparison_pass[index] &&
+ data.effective_acceleration_toward_target[index] &&
+ data.below_wall[index] == 0 &&
+ 980 <= data.density_min[index] <=
+ 1020 &&
+ 980 <= data.density_max[index] <=
+ 1020 &&
+ data.rejected_fraction[index] <= 0.25 &&
+ data.reaction_pass[index]
+ end
+ end
+ return data
+end
+
+function classify_initial_r4_perturbation(; input_path=R4_PERTURBATION_PATH,
+ output_path=R4_PERTURBATION_CLASSIFIED_PATH)
+ require(isfile(input_path), "run `r4_perturbation` before classification")
+ data = CSV.read(input_path, DataFrame)
+ classify_r4_wetted_area_perturbation!(data)
+ CSV.write(output_path, data)
+ candidates = data[data.mechanism .== "r4_wetted_area", :]
+ println("R4-W formulation-consistent initial classification: ",
+ count(candidates.formulation_response_pass), "/4; acceleration ",
+ count(candidates.effective_acceleration_toward_target), "/4; motion ",
+ count(candidates.motion_toward_target), "/4")
+ println("Wrote classified initial evidence to ", output_path)
+ return data
+end
+
+function run_r4_perturbation_extension(; output_path=R4_PERTURBATION_EXTENDED_PATH,
+ final_time=0.02)
+ require(final_time == 0.02, "the sole R4-W extension is frozen at 0.02 s")
+ require_r4_static_gate()
+ initial = classify_initial_r4_perturbation()
+ initial_candidates = initial[initial.mechanism .== "r4_wetted_area", :]
+ unresolved = initial_candidates[.!initial_candidates.formulation_response_pass, :]
+ require(nrow(initial_candidates) == 4 &&
+ all(initial_candidates.effective_acceleration_toward_target),
+ "R4-W is not eligible for the uniform extension")
+ require(all(abs.(unresolved.final_circle .- unresolved.initial_circle) .< 1),
+ "unresolved R4-W motion exceeds estimator resolution")
+
+ parameters = r4_wetted_area_parameters()
+ data = r4_perturbation_data(parameters; final_time)
+ classify_r4_wetted_area_perturbation!(data)
+ CSV.write(output_path, data)
+ candidates = data[data.mechanism .== "r4_wetted_area", :]
+ println("R4-W extended perturbation gate: ",
+ count(candidates.formulation_response_pass), "/4; acceleration ",
+ count(candidates.effective_acceleration_toward_target), "/4; motion ",
+ count(candidates.motion_toward_target), "/4")
+ println("Wrote extended R4-W perturbation evidence to ", output_path)
+ require(nrow(candidates) == 4 && all(candidates.formulation_response_pass),
+ "extended R4-W perturbation gate failed")
+ return data
+end
+
+function require_r4_perturbation_gate()
+ require(isfile(R4_PERTURBATION_EXTENDED_PATH),
+ "run the permitted `r4_perturbation_extended` replay first")
+ data = CSV.read(R4_PERTURBATION_EXTENDED_PATH, DataFrame)
+ candidates = data[data.mechanism .== "r4_wetted_area", :]
+ require(nrow(candidates) == 4 && all(candidates.formulation_response_pass),
+ "R4-W perturbation evidence does not pass")
+ return data
+end
+
+function run_r4_threshold_gate(; output_path=R4_THRESHOLD_PATH)
+ require_r4_perturbation_gate()
+ parameters = r4_wetted_area_parameters()
+ rows = [run_r4_dynamic_case(:threshold, target, target, parameters)
+ for target in TARGET_ANGLES]
+ data = DataFrame(rows)
+ data.reaction_pass = (data.max_explicit_reaction_residual .<= 1.0e-12) .&
+ (data.max_density_resultant_residual .<= 1.0e-12) .&
+ (data.max_total_momentum_residual .<= 1.0e-12)
+ data.pass = (abs.(data.final_error) .<= 5) .& (data.below_wall .== 0) .&
+ (data.density_min .>= 980) .& (data.density_max .<= 1020) .&
+ (data.rms_speed .< 5.0e-3) .& (data.rejected_fraction .<= 0.25) .&
+ data.reaction_pass
+ CSV.write(output_path, data)
+ println("R4-W threshold gate: ", count(data.pass), "/5")
+ println("Wrote R4-W threshold evidence to ", output_path)
+ require(all(data.pass), "R4-W threshold replay failed")
+ return data
+end
+
+function require_r4_threshold_gate()
+ require(isfile(R4_THRESHOLD_PATH), "run `r4_threshold` first")
+ data = CSV.read(R4_THRESHOLD_PATH, DataFrame)
+ require(nrow(data) == 5 && all(data.pass),
+ "R4-W threshold evidence does not pass")
+ return data
+end
+
+function run_r4_timestep_gate(; output_path=R4_TIMESTEP_PATH)
+ require_r4_threshold_gate()
+ parameters = r4_wetted_area_parameters()
+ rows = [run_r4_dynamic_case(:timestep, target, target, parameters; resolution)
+ for (target, resolution) in ((90.0, 1500), (30.0, 3000))]
+ data = DataFrame(rows)
+ data.reaction_pass = (data.max_explicit_reaction_residual .<= 1.0e-12) .&
+ (data.max_density_resultant_residual .<= 1.0e-12) .&
+ (data.max_total_momentum_residual .<= 1.0e-12)
+ data.pass = (data.eta_p01 .>= 0.05) .& (data.eta_tail_head .>= 0.5) .&
+ (data.rejected_fraction .<= 0.25) .& (data.below_wall .== 0) .&
+ (data.density_min .>= 980) .& (data.density_max .<= 1020) .&
+ data.reaction_pass
+ CSV.write(output_path, data)
+ println("R4-W timestep gate: ", count(data.pass), "/2")
+ println("Wrote R4-W timestep evidence to ", output_path)
+ require(all(data.pass), "R4-W timestep gate failed")
+ return data
+end
+
+function require_r4_timestep_gate()
+ require(isfile(R4_TIMESTEP_PATH), "run `r4_timestep` first")
+ data = CSV.read(R4_TIMESTEP_PATH, DataFrame)
+ require(nrow(data) == 2 && all(data.pass),
+ "R4-W timestep evidence does not pass")
+ return data
+end
+
+function run_r4_cost_cases(; output_path=R4_COST_PATH, target=90.0)
+ require_r4_timestep_gate()
+ parameters = r4_wetted_area_parameters()
+ for active in (false, true)
+ run_r4_dynamic_case(:warmup, target, target, parameters; active,
+ resolution=200, final_time=0.001)
+ end
+ orders = ((false, true), (true, false), (false, true))
+ rows = NamedTuple[]
+ for (repeat, order) in enumerate(orders), active in order
+ push!(rows, run_r4_dynamic_case(:cost, target, target, parameters; active,
+ repeat))
+ end
+ data = DataFrame(rows)
+ control_median = median(data[data.mechanism .== "none", :solver_runtime])
+ data.normalized_runtime = data.solver_runtime ./ control_median
+ CSV.write(output_path, data)
+ for mechanism in ("none", "r4_wetted_area")
+ values = data[data.mechanism .== mechanism, :solver_runtime]
+ center = median(values)
+ @printf("%-18s median %.3f s MAD %.3f s overhead %.3f\n",
+ mechanism, center, median(abs.(values .- center)),
+ center / control_median)
+ end
+ println("Wrote R4-W ", target, "-degree cost evidence to ", output_path)
+ require(all(isfinite, data.solver_runtime), "non-finite R4-W cost measurement")
+ return data
+end
+
+function require_r4_cost_cases()
+ require(isfile(R4_COST_PATH), "run `r4_cost` first")
+ require(isfile(R4_ACTIVE_COST_PATH), "run `r4_cost_active` first")
+ zero_path = CSV.read(R4_COST_PATH, DataFrame)
+ active_path = CSV.read(R4_ACTIVE_COST_PATH, DataFrame)
+ require(nrow(zero_path) == 6 && nrow(active_path) == 6 &&
+ all(isfinite, zero_path.solver_runtime) &&
+ all(isfinite, active_path.solver_runtime),
+ "R4-W cost evidence is incomplete")
+ return (; zero_path, active_path)
+end
+
+function run_r4_selected_matrix(; output_path=R4_SELECTED_PATH)
+ require_r4_cost_cases()
+ parameters = r4_wetted_area_parameters()
+ rows = [run_r4_dynamic_case(:selected, target, target, parameters; resolution)
+ for resolution in RESOLUTIONS for target in TARGET_ANGLES]
+ data = DataFrame(rows)
+ data.pass = (abs.(data.final_error) .<= 5) .& (data.below_wall .== 0) .&
+ (data.density_min .>= 980) .& (data.density_max .<= 1020) .&
+ (data.rms_speed .< 5.0e-3) .& (data.rejected_fraction .<= 0.25) .&
+ (data.eta_p01 .>= 0.05) .& (data.eta_tail_head .>= 0.5) .&
+ (data.max_explicit_reaction_residual .<= 1.0e-12) .&
+ (data.max_density_resultant_residual .<= 1.0e-12) .&
+ (data.max_total_momentum_residual .<= 1.0e-12)
+ CSV.write(output_path, data)
+ println("R4-W selected matrix: ", count(data.pass), "/15")
+ println("Wrote R4-W selected matrix to ", output_path)
+ require(all(data.pass), "R4-W selected matrix failed")
+ return data
+end
+
+function run_r4_sensitivity(; output_path=R4_SENSITIVITY_PATH)
+ require(isfile(R4_SELECTED_PATH), "run `r4_selected` first")
+ selected = CSV.read(R4_SELECTED_PATH, DataFrame)
+ require(nrow(selected) == 15 && all(selected.pass),
+ "R4-W selected matrix does not pass")
+ parameters = r4_wetted_area_parameters()
+ rows = [run_r4_dynamic_case(:sensitivity, 90.0, 90.0, parameters;
+ threshold, damping)
+ for threshold in (0.0, 0.1) for damping in (2000.0, 8000.0)]
+ data = DataFrame(rows)
+ angle_span = maximum(data.final_circle) - minimum(data.final_circle)
+ data.pass = (abs.(data.final_error) .<= 5) .& (data.below_wall .== 0) .&
+ (data.density_min .>= 980) .& (data.density_max .<= 1020) .&
+ (data.rms_speed .< 5.0e-3) .& (data.rejected_fraction .<= 0.25) .&
+ (data.max_total_momentum_residual .<= 1.0e-12) .&
+ (angle_span .<= 1)
+ data.angle_span = fill(angle_span, nrow(data))
+ CSV.write(output_path, data)
+ println("R4-W sensitivity: ", count(data.pass), "/4; span ", angle_span,
+ " deg")
+ println("Wrote R4-W sensitivity evidence to ", output_path)
+ require(all(data.pass), "R4-W sensitivity matrix failed")
+ return data
+end
+
+function run_production_wetted_area_simulation(target, initial_angle; active=true,
+ resolution=1500, threshold=0.1,
+ damping=4000.0, final_time=0.01)
+ mechanism = active ? :wetted_area : :none
+ return quiet_css_sessile_drop(target, final_time, nothing;
+ target_particle_count=resolution, mechanism,
+ initial_contact_angle=initial_angle,
+ boundary_contact_threshold=threshold,
+ damping_coefficient=damping,
+ smoothing_length_ratio=1.4)
+end
+
+function production_contact_boundaries(semi)
+ boundaries = Any[]
+ for system in semi.systems
+ cache = TrixiParticles.wetted_area_boundary_cache(system)
+ isnothing(cache) || push!(boundaries, system)
+ end
+ return boundaries
+end
+
+function production_contact_rhs_diagnostics(result; state=result.solution.prob.u0.x)
+ semi = result.solution.prob.p.semi
+ system = only(filter(candidate -> candidate isa TrixiParticles.AbstractFluidSystem,
+ collect(semi.systems)))
+ boundaries = production_contact_boundaries(semi)
+ v_ode, u_ode = state
+ TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, 0.0)
+ conjugate = copy(system.cache.wetted_area_density_conjugate)
+ weights = [copy(boundary.boundary_model.cache.wetted_area_weight)
+ for boundary in boundaries]
+ zero_conjugate = zero(conjugate)
+ zero_weights = map(zero, weights)
+
+ function interaction(conjugate_values, weight_values)
+ copyto!(system.cache.wetted_area_density_conjugate, conjugate_values)
+ for (boundary, values) in zip(boundaries, weight_values)
+ copyto!(boundary.boundary_model.cache.wetted_area_weight, values)
+ end
+ dv_ode = zero(v_ode)
+ TrixiParticles.system_interaction!(dv_ode, v_ode, u_ode, semi)
+ dv = TrixiParticles.wrap_v(dv_ode, system, semi)
+ acceleration = Array(dv[1:3, :])
+ reactions = [copy(boundary.boundary_model.cache.wetted_area_reaction)
+ for boundary in boundaries]
+ return (; acceleration, reactions)
+ end
+
+ baseline = interaction(zero_conjugate, zero_weights)
+ explicit = interaction(zero_conjugate, weights)
+ density = interaction(conjugate, zero_weights)
+ full = interaction(conjugate, weights)
+ masses = system.mass
+ explicit_force = (explicit.acceleration - baseline.acceleration) .* transpose(masses)
+ density_force = (density.acceleration - baseline.acceleration) .* transpose(masses)
+ full_force = (full.acceleration - baseline.acceleration) .* transpose(masses)
+
+ explicit_resultant, explicit_scale = r4_force_resultant(explicit_force)
+ density_resultant, density_scale = r4_force_resultant(density_force)
+ full_resultant, full_scale = r4_force_resultant(full_force)
+ wall_resultant = zeros(eltype(system), 3)
+ wall_scale = zero(eltype(system))
+ for reaction in explicit.reactions
+ resultant, scale = r4_force_resultant(reaction)
+ wall_resultant .+= resultant
+ wall_scale += scale
+ end
+ explicit_reaction_residual = r4_relative_residual(explicit_resultant +
+ wall_resultant,
+ explicit_scale + wall_scale)
+ density_resultant_residual = r4_relative_residual(density_resultant, density_scale)
+ total_momentum_residual = r4_relative_residual(full_resultant + wall_resultant,
+ full_scale + wall_scale)
+ force_consistency_residual = r4_relative_residual(vec(sum(full_force - explicit_force -
+ density_force; dims=2)),
+ full_scale + explicit_scale +
+ density_scale)
+ raw_area = system.cache.wetted_area_raw_area[]
+ corrected_area = system.cache.wetted_area[]
+ raw_radius = sqrt(raw_area / pi)
+ corrected_radius = sqrt(corrected_area / pi)
+ area_derivative = raw_radius > eps(raw_radius) ? corrected_radius / raw_radius : 0.0
+ contact_cache_bytes = wetted_area_contact_diagnostics(system.surface_normal_method.contact_model,
+ system,
+ first(boundaries)).cache_bytes
+ return (; energy=system.cache.wetted_area_energy[], raw_area, corrected_area,
+ area_derivative, explicit_force, density_force, full_force,
+ explicit_resultant, density_resultant, wall_resultant,
+ explicit_force_scale=explicit_scale, density_force_scale=density_scale,
+ wall_force_scale=wall_scale,
+ explicit_reaction_residual, density_resultant_residual,
+ total_momentum_residual, force_consistency_residual,
+ max_explicit_reaction_residual=explicit_reaction_residual,
+ max_density_resultant_residual=density_resultant_residual,
+ max_total_momentum_residual=total_momentum_residual,
+ cache_bytes=contact_cache_bytes)
+end
+
+function production_directional_energy_gradient(result, diagnostics)
+ semi = result.solution.prob.p.semi
+ system = semi.systems[1]
+ boundary_system = only(production_contact_boundaries(semi))
+ v_ode, u_ode = result.solution.prob.u0.x
+ TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, 0.0)
+ v = TrixiParticles.wrap_v(v_ode, system, semi)
+ u = TrixiParticles.wrap_u(u_ode, system, semi)
+ u_boundary = TrixiParticles.wrap_u(u_ode, boundary_system, semi)
+ coordinates = Array(TrixiParticles.current_coordinates(u, system))
+ boundary_coordinates = Array(TrixiParticles.current_coordinates(u_boundary,
+ boundary_system))
+ density = collect(TrixiParticles.current_density(v, system))
+ displacement = similar(coordinates)
+ displacement_scale = max(maximum(abs, coordinates),
+ system.cache.reference_particle_spacing)
+ for particle in TrixiParticles.eachparticle(system)
+ displacement[1, particle] = -coordinates[1, particle] / displacement_scale
+ displacement[2, particle] = -coordinates[2, particle] / displacement_scale
+ displacement[3, particle] = 2coordinates[3, particle] / displacement_scale
+ end
+
+ density_rate = zeros(eltype(system), TrixiParticles.nparticles(system))
+ TrixiParticles.foreach_point_neighbor(system, system, coordinates, coordinates, semi;
+ points=TrixiParticles.each_integrated_particle(system),
+ parallelization_backend=TrixiParticles.SerialBackend()) do particle,
+ neighbor,
+ pos_diff,
+ distance
+ gradient = TrixiParticles.smoothing_kernel_grad(system, pos_diff, distance,
+ particle)
+ mass_b = TrixiParticles.hydrodynamic_mass(system, neighbor)
+ density_rate[particle] += density[particle] / density[neighbor] * mass_b *
+ dot(displacement[:, particle] -
+ displacement[:, neighbor], gradient)
+ end
+ pairs = Tuple{Int, Int}[]
+ TrixiParticles.foreach_point_neighbor(system, boundary_system, coordinates,
+ boundary_coordinates, semi;
+ points=TrixiParticles.each_integrated_particle(system),
+ parallelization_backend=TrixiParticles.SerialBackend()) do particle,
+ neighbor,
+ pos_diff,
+ distance
+ push!(pairs, (particle, neighbor))
+ end
+
+ boundary_cache = boundary_system.boundary_model.cache
+ function perturbed_energy(epsilon)
+ colorfield = copy(boundary_cache.initial_colorfield)
+ for (particle, neighbor) in pairs
+ distance2 = zero(eltype(system))
+ for dim in 1:3
+ difference = coordinates[dim, particle] +
+ epsilon * displacement[dim, particle] -
+ boundary_coordinates[dim, neighbor]
+ distance2 += difference^2
+ end
+ perturbed_density = density[particle] + epsilon * density_rate[particle]
+ colorfield[neighbor] += system.mass[particle] / perturbed_density *
+ TrixiParticles.smoothing_kernel(system,
+ sqrt(distance2),
+ particle)
+ end
+ raw_area = zero(eltype(system))
+ for particle in TrixiParticles.eachparticle(boundary_system)
+ measure = boundary_cache.wetted_area_surface_measure[particle]
+ iszero(measure) && continue
+ reference = boundary_cache.wetted_area_flooded_reference[particle]
+ fraction = clamp(colorfield[particle] / reference, 0, 1)
+ raw_area += measure * TrixiParticles.cubic_smoothstep(fraction)
+ end
+ raw_radius = sqrt(raw_area / pi)
+ edge_shift = system.cache.wetted_area_normalized_edge_shift[] *
+ TrixiParticles.initial_smoothing_length(system)
+ corrected_radius = max(raw_radius - edge_shift, zero(raw_radius))
+ coefficient = TrixiParticles.wetted_area_coefficient(system.surface_tension,
+ system.surface_normal_method.contact_model)
+ return -coefficient * pi * corrected_radius^2
+ end
+
+ epsilon = 1.0e-5system.cache.reference_particle_spacing
+ energy_plus = perturbed_energy(epsilon)
+ energy_minus = perturbed_energy(-epsilon)
+ finite_difference = (energy_plus - energy_minus) / (2epsilon)
+ explicit_derivative = zero(eltype(system))
+ density_derivative = zero(eltype(system))
+ for particle in TrixiParticles.eachparticle(system), dim in 1:3
+ explicit_derivative -= diagnostics.explicit_force[dim, particle] *
+ displacement[dim, particle]
+ density_derivative -= diagnostics.density_force[dim, particle] *
+ displacement[dim, particle]
+ end
+ analytic_derivative = explicit_derivative + density_derivative
+ derivative_scale = max(abs(finite_difference), abs(analytic_derivative))
+ relative_error = iszero(derivative_scale) ? zero(derivative_scale) :
+ abs(finite_difference - analytic_derivative) / derivative_scale
+ return (; epsilon, energy_plus, energy_minus, finite_difference,
+ analytic_derivative, explicit_derivative, density_derivative,
+ relative_error, fluid_wall_pairs=length(pairs))
+end
+
+function run_production_static_gate(; output_path=PRODUCTION_STATIC_PATH)
+ rows = NamedTuple[]
+ for target in TARGET_ANGLES
+ result = run_production_wetted_area_simulation(target, target; final_time=0.0)
+ diagnostics = production_contact_rhs_diagnostics(result)
+ gradient = production_directional_energy_gradient(result, diagnostics)
+ push!(rows,
+ r4_static_row("energy_gradient", target, target, result,
+ diagnostics; gradient))
+ end
+ angle_pairs = ((60.0, 90.0), (90.0, 60.0),
+ (90.0, 120.0), (120.0, 90.0))
+ for (target, initial_angle) in angle_pairs
+ control = run_production_wetted_area_simulation(target, initial_angle;
+ active=false, final_time=0.0)
+ result = run_production_wetted_area_simulation(target, initial_angle;
+ final_time=0.0)
+ diagnostics = production_contact_rhs_diagnostics(result)
+ push!(rows,
+ r4_static_row("force_sign", target, initial_angle, result,
+ diagnostics;
+ control_acceleration=control.shape_acceleration))
+ end
+ data = DataFrame(rows)
+ data.mechanism = fill("wetted_area_production", nrow(data))
+ CSV.write(output_path, data)
+ println("Production wetted-area static gate: ", count(data.stage_pass), "/",
+ nrow(data), "; max gradient error ",
+ maximum(data[data.kind .== "energy_gradient", :gradient_relative_error]))
+ require(all(data.stage_pass), "production wetted-area algebra/static gate failed")
+ return data
+end
+
+function production_dynamic_row(kind, target, initial_angle, result,
+ initial_diagnostics, final_diagnostics; active=true,
+ resolution=1500, threshold=0.1, damping=4000.0,
+ final_time=0.01, repeat=1,
+ total_runtime=result.runtime)
+ initial_error = result.initial_circle.angle - target
+ final_error = result.circle.angle - target
+ error_reduction = abs(initial_error) > eps() ?
+ 1 - abs(final_error) / abs(initial_error) : NaN
+ max_explicit_residual = active ?
+ max(initial_diagnostics.explicit_reaction_residual,
+ final_diagnostics.explicit_reaction_residual) : 0.0
+ max_density_residual = active ?
+ max(initial_diagnostics.density_resultant_residual,
+ final_diagnostics.density_resultant_residual) : 0.0
+ max_total_residual = active ?
+ max(initial_diagnostics.total_momentum_residual,
+ final_diagnostics.total_momentum_residual) : 0.0
+ return (; kind=String(kind), target, initial_angle,
+ mechanism=active ? "wetted_area_production" : "none",
+ requested_particles=resolution, particle_count=result.particle_count,
+ threshold, damping, final_time, repeat,
+ initial_circle=result.initial_circle.angle,
+ final_circle=result.circle.angle, initial_error, final_error,
+ error_reduction, cap_angle=result.final.angle,
+ normal_angle=result.measured_contact_angle,
+ shape_acceleration=result.shape_acceleration,
+ boundary_shape_acceleration=result.boundary_shape_acceleration,
+ wetting_energy=active ? initial_diagnostics.energy : 0.0,
+ explicit_reaction_residual=active ?
+ initial_diagnostics.explicit_reaction_residual : 0.0,
+ density_resultant_residual=active ?
+ initial_diagnostics.density_resultant_residual : 0.0,
+ total_momentum_residual=active ?
+ initial_diagnostics.total_momentum_residual : 0.0,
+ max_explicit_reaction_residual=max_explicit_residual,
+ max_density_resultant_residual=max_density_residual,
+ max_total_momentum_residual=max_total_residual,
+ force_consistency_residual=active ?
+ max(initial_diagnostics.force_consistency_residual,
+ final_diagnostics.force_consistency_residual) :
+ 0.0,
+ below_wall=result.below_wall, density_min=result.density_range[1],
+ density_max=result.density_range[2], rms_speed=result.rms_speed,
+ settled=result.settled, accepted_steps=result.accepted_steps,
+ rejected_steps=result.rejected_steps,
+ rejected_fraction=result.rejected_fraction,
+ minimum_dt=result.minimum_dt, dt_reference=result.dt_reference,
+ eta_p01=result.eta_p01, eta_median=result.eta_median,
+ eta_tail_head=result.eta_tail_head, solver_runtime=result.runtime,
+ total_runtime,
+ cache_bytes=Base.summarysize(result.solution.prob.p.semi.systems[1].cache),
+ contact_cache_bytes=active ? initial_diagnostics.cache_bytes : 0)
+end
+
+function run_production_dynamic_case(kind, target, initial_angle; active=true,
+ resolution=1500, threshold=0.1,
+ damping=4000.0, final_time=0.01, repeat=1)
+ result = nothing
+ initial_diagnostics = nothing
+ final_diagnostics = nothing
+ total_runtime = @elapsed begin
+ result = run_production_wetted_area_simulation(target, initial_angle; active,
+ resolution, threshold, damping,
+ final_time)
+ if active
+ initial_diagnostics = production_contact_rhs_diagnostics(result;
+ state=result.solution.prob.u0.x)
+ final_diagnostics = production_contact_rhs_diagnostics(result;
+ state=last(result.solution.u).x)
+ end
+ end
+ return production_dynamic_row(kind, target, initial_angle, result,
+ initial_diagnostics, final_diagnostics; active,
+ resolution, threshold, damping, final_time, repeat,
+ total_runtime)
+end
+
+function production_perturbation_data(; final_time=0.01)
+ angle_pairs = ((60.0, 90.0), (90.0, 60.0),
+ (90.0, 120.0), (120.0, 90.0))
+ rows = [run_production_dynamic_case(:perturbation, target, initial; active,
+ final_time)
+ for (target, initial) in angle_pairs for active in (false, true)]
+ data = DataFrame(rows)
+ data.control_error_reduction = fill(NaN, nrow(data))
+ data.contact_shape_acceleration = fill(NaN, nrow(data))
+ data.motion_toward_target = falses(nrow(data))
+ data.beats_control = falses(nrow(data))
+ data.acceleration_toward_target = falses(nrow(data))
+ data.reaction_pass = falses(nrow(data))
+ data.response_pass = falses(nrow(data))
+ for (target, initial) in angle_pairs
+ group = findall((data.target .== target) .& (data.initial_angle .== initial))
+ control = only(filter(index -> data.mechanism[index] == "none", group))
+ direction = sign(target - initial)
+ for index in group
+ data.control_error_reduction[index] = data.error_reduction[control]
+ data.contact_shape_acceleration[index] = data.shape_acceleration[index] -
+ data.shape_acceleration[control]
+ data.motion_toward_target[index] = direction *
+ (data.final_circle[index] -
+ data.initial_circle[index]) > 0
+ data.beats_control[index] = data.mechanism[index] == "none" ||
+ data.error_reduction[index] >
+ data.error_reduction[control]
+ data.acceleration_toward_target[index] = data.mechanism[index] == "none" ||
+ direction *
+ data.contact_shape_acceleration[index] >
+ 0
+ data.reaction_pass[index] = data.max_explicit_reaction_residual[index] <=
+ 1.0e-12 &&
+ data.max_density_resultant_residual[index] <=
+ 1.0e-12 &&
+ data.max_total_momentum_residual[index] <= 1.0e-12
+ data.response_pass[index] = data.mechanism[index] == "none" ||
+ (data.error_reduction[index] > 0 &&
+ data.motion_toward_target[index] &&
+ data.beats_control[index] &&
+ data.acceleration_toward_target[index] &&
+ data.below_wall[index] == 0 &&
+ 980 <= data.density_min[index] <= 1020 &&
+ 980 <= data.density_max[index] <= 1020 &&
+ data.rejected_fraction[index] <= 0.25 &&
+ data.reaction_pass[index])
+ end
+ end
+ classify_r4_wetted_area_perturbation!(data)
+ return data
+end
+
+function run_production_perturbation_gate(; output_path=PRODUCTION_PERTURBATION_PATH,
+ final_time=0.01)
+ require(isfile(PRODUCTION_STATIC_PATH), "run `production_static` first")
+ static = CSV.read(PRODUCTION_STATIC_PATH, DataFrame)
+ require(all(static.stage_pass), "production static evidence does not pass")
+ data = production_perturbation_data(; final_time)
+ CSV.write(output_path, data)
+ candidates = data[data.mechanism .== "wetted_area_production", :]
+ println("Production wetted-area perturbation: formulation-consistent ",
+ count(candidates.formulation_response_pass), "/4")
+ return data
+end
+
+function run_production_perturbation_extension(;
+ output_path=PRODUCTION_PERTURBATION_EXTENDED_PATH,
+ final_time=0.02)
+ require(final_time == 0.02, "the production replay keeps the frozen 0.02 s extension")
+ require(isfile(PRODUCTION_PERTURBATION_PATH),
+ "run `production_perturbation` first")
+ data = production_perturbation_data(; final_time)
+ CSV.write(output_path, data)
+ candidates = data[data.mechanism .== "wetted_area_production", :]
+ println("Production wetted-area extended perturbation: ",
+ count(candidates.formulation_response_pass), "/4")
+ require(nrow(candidates) == 4 && all(candidates.formulation_response_pass),
+ "production extended perturbation gate failed")
+ return data
+end
+
+function require_production_perturbation_gate()
+ require(isfile(PRODUCTION_PERTURBATION_EXTENDED_PATH),
+ "run `production_perturbation_extended` first")
+ data = CSV.read(PRODUCTION_PERTURBATION_EXTENDED_PATH, DataFrame)
+ candidates = data[data.mechanism .== "wetted_area_production", :]
+ require(nrow(candidates) == 4 && all(candidates.formulation_response_pass),
+ "production perturbation evidence does not pass")
+ return data
+end
+
+function run_production_threshold_gate(; output_path=PRODUCTION_THRESHOLD_PATH)
+ require_production_perturbation_gate()
+ rows = [run_production_dynamic_case(:threshold, target, target)
+ for target in TARGET_ANGLES]
+ data = DataFrame(rows)
+ data.reaction_pass = (data.max_explicit_reaction_residual .<= 1.0e-12) .&
+ (data.max_density_resultant_residual .<= 1.0e-12) .&
+ (data.max_total_momentum_residual .<= 1.0e-12) .&
+ (data.force_consistency_residual .<= 1.0e-12)
+ data.pass = (abs.(data.final_error) .<= 5) .& (data.below_wall .== 0) .&
+ (data.density_min .>= 980) .& (data.density_max .<= 1020) .&
+ (data.rms_speed .< 5.0e-3) .& (data.rejected_fraction .<= 0.25) .&
+ data.reaction_pass
+ CSV.write(output_path, data)
+ println("Production wetted-area threshold gate: ", count(data.pass), "/5")
+ require(all(data.pass), "production threshold replay failed")
+ return data
+end
+
+function require_production_threshold_gate()
+ require(isfile(PRODUCTION_THRESHOLD_PATH), "run `production_threshold` first")
+ data = CSV.read(PRODUCTION_THRESHOLD_PATH, DataFrame)
+ require(nrow(data) == 5 && all(data.pass),
+ "production threshold evidence does not pass")
+ return data
+end
+
+function run_production_timestep_gate(; output_path=PRODUCTION_TIMESTEP_PATH)
+ require_production_threshold_gate()
+ rows = [run_production_dynamic_case(:timestep, target, target; resolution)
+ for (target, resolution) in ((90.0, 1500), (30.0, 3000))]
+ data = DataFrame(rows)
+ data.reaction_pass = (data.max_explicit_reaction_residual .<= 1.0e-12) .&
+ (data.max_density_resultant_residual .<= 1.0e-12) .&
+ (data.max_total_momentum_residual .<= 1.0e-12) .&
+ (data.force_consistency_residual .<= 1.0e-12)
+ data.pass = (data.eta_p01 .>= 0.05) .& (data.eta_tail_head .>= 0.5) .&
+ (data.rejected_fraction .<= 0.25) .& (data.below_wall .== 0) .&
+ (data.density_min .>= 980) .& (data.density_max .<= 1020) .&
+ data.reaction_pass
+ CSV.write(output_path, data)
+ println("Production wetted-area timestep gate: ", count(data.pass), "/2")
+ require(all(data.pass), "production timestep gate failed")
+ return data
+end
+
+function require_production_timestep_gate()
+ require(isfile(PRODUCTION_TIMESTEP_PATH), "run `production_timestep` first")
+ data = CSV.read(PRODUCTION_TIMESTEP_PATH, DataFrame)
+ require(nrow(data) == 2 && all(data.pass),
+ "production timestep evidence does not pass")
+ return data
+end
+
+function run_production_cost_cases(; output_path=PRODUCTION_COST_PATH, target=90.0)
+ require_production_timestep_gate()
+ for active in (false, true)
+ run_production_dynamic_case(:warmup, target, target; active,
+ resolution=200, final_time=0.001)
+ end
+ orders = ((false, true), (true, false), (false, true))
+ rows = NamedTuple[]
+ for (repeat, order) in enumerate(orders), active in order
+ push!(rows, run_production_dynamic_case(:cost, target, target; active, repeat))
+ end
+ data = DataFrame(rows)
+ control_median = median(data[data.mechanism .== "none", :solver_runtime])
+ data.normalized_runtime = data.solver_runtime ./ control_median
+ CSV.write(output_path, data)
+ for mechanism in ("none", "wetted_area_production")
+ values = data[data.mechanism .== mechanism, :solver_runtime]
+ center = median(values)
+ @printf("%-26s median %.3f s MAD %.3f s overhead %.3f\n",
+ mechanism, center, median(abs.(values .- center)),
+ center / control_median)
+ end
+ if target == 60
+ active_median = median(data[data.mechanism .== "wetted_area_production",
+ :solver_runtime])
+ require(active_median / control_median <= 1.2,
+ "production active wetting overhead exceeds 20%")
+ end
+ require(all(isfinite, data.solver_runtime), "non-finite production cost measurement")
+ return data
+end
+
+function require_production_cost_cases()
+ require(isfile(PRODUCTION_COST_PATH), "run `production_cost` first")
+ require(isfile(PRODUCTION_ACTIVE_COST_PATH), "run `production_cost_active` first")
+ zero_path = CSV.read(PRODUCTION_COST_PATH, DataFrame)
+ active_path = CSV.read(PRODUCTION_ACTIVE_COST_PATH, DataFrame)
+ control_median = median(active_path[active_path.mechanism .== "none",
+ :solver_runtime])
+ active_median = median(active_path[active_path.mechanism .== "wetted_area_production",
+ :solver_runtime])
+ require(nrow(zero_path) == 6 && nrow(active_path) == 6 &&
+ active_median / control_median <= 1.2,
+ "production cost evidence is incomplete or exceeds the active gate")
+ return (; zero_path, active_path)
+end
+
+function run_production_selected_matrix(; output_path=PRODUCTION_SELECTED_PATH)
+ require_production_cost_cases()
+ rows = [run_production_dynamic_case(:selected, target, target; resolution)
+ for resolution in RESOLUTIONS for target in TARGET_ANGLES]
+ data = DataFrame(rows)
+ data.pass = (abs.(data.final_error) .<= 5) .& (data.below_wall .== 0) .&
+ (data.density_min .>= 980) .& (data.density_max .<= 1020) .&
+ (data.rms_speed .< 5.0e-3) .& (data.rejected_fraction .<= 0.25) .&
+ (data.eta_p01 .>= 0.05) .& (data.eta_tail_head .>= 0.5) .&
+ (data.max_explicit_reaction_residual .<= 1.0e-12) .&
+ (data.max_density_resultant_residual .<= 1.0e-12) .&
+ (data.max_total_momentum_residual .<= 1.0e-12) .&
+ (data.force_consistency_residual .<= 1.0e-12)
+ CSV.write(output_path, data)
+ println("Production wetted-area selected matrix: ", count(data.pass), "/15")
+ require(all(data.pass), "production selected matrix failed")
+ return data
+end
+
+function run_production_sensitivity(; output_path=PRODUCTION_SENSITIVITY_PATH)
+ require(isfile(PRODUCTION_SELECTED_PATH), "run `production_selected` first")
+ selected = CSV.read(PRODUCTION_SELECTED_PATH, DataFrame)
+ require(nrow(selected) == 15 && all(selected.pass),
+ "production selected matrix does not pass")
+ rows = [run_production_dynamic_case(:sensitivity, 90.0, 90.0;
+ threshold, damping)
+ for threshold in (0.0, 0.1) for damping in (2000.0, 8000.0)]
+ data = DataFrame(rows)
+ angle_span = maximum(data.final_circle) - minimum(data.final_circle)
+ data.pass = (abs.(data.final_error) .<= 5) .& (data.below_wall .== 0) .&
+ (data.density_min .>= 980) .& (data.density_max .<= 1020) .&
+ (data.rms_speed .< 5.0e-3) .& (data.rejected_fraction .<= 0.25) .&
+ (data.max_total_momentum_residual .<= 1.0e-12) .&
+ (data.force_consistency_residual .<= 1.0e-12) .& (angle_span .<= 1)
+ data.angle_span = fill(angle_span, nrow(data))
+ CSV.write(output_path, data)
+ println("Production wetted-area sensitivity: ", count(data.pass),
+ "/4; span ", angle_span, " deg")
+ require(all(data.pass), "production sensitivity matrix failed")
+ return data
+end
+
+if abspath(PROGRAM_FILE) == @__FILE__
+ isempty(ARGS) && error("usage: contact_angle_decision.jl " *
+ "score|r4_static|r4_perturbation|r4_perturbation_extended|r4_threshold|r4_timestep|r4_cost|r4_cost_active|r4_selected|r4_sensitivity|production_static|production_perturbation|production_perturbation_extended|production_threshold|production_timestep|production_cost|production_cost_active|production_selected|production_sensitivity")
+ mode = Symbol(ARGS[1])
+ if mode == :score
+ score_phase2()
+ elseif mode == :r4_static
+ run_r4_static_gate()
+ elseif mode == :r4_perturbation
+ run_r4_perturbation_gate()
+ elseif mode == :r4_perturbation_extended
+ run_r4_perturbation_extension()
+ elseif mode == :r4_threshold
+ run_r4_threshold_gate()
+ elseif mode == :r4_timestep
+ run_r4_timestep_gate()
+ elseif mode == :r4_cost
+ run_r4_cost_cases()
+ elseif mode == :r4_cost_active
+ run_r4_cost_cases(; output_path=R4_ACTIVE_COST_PATH, target=60.0)
+ elseif mode == :r4_selected
+ run_r4_selected_matrix()
+ elseif mode == :r4_sensitivity
+ run_r4_sensitivity()
+ elseif mode == :production_static
+ run_production_static_gate()
+ elseif mode == :production_perturbation
+ run_production_perturbation_gate()
+ elseif mode == :production_perturbation_extended
+ run_production_perturbation_extension()
+ elseif mode == :production_threshold
+ run_production_threshold_gate()
+ elseif mode == :production_timestep
+ run_production_timestep_gate()
+ elseif mode == :production_cost
+ run_production_cost_cases()
+ elseif mode == :production_cost_active
+ run_production_cost_cases(; output_path=PRODUCTION_ACTIVE_COST_PATH,
+ target=60.0)
+ elseif mode == :production_selected
+ run_production_selected_matrix()
+ elseif mode == :production_sensitivity
+ run_production_sensitivity()
+ else
+ error("unknown mode: $mode")
+ end
+end
diff --git a/validation/surface_tension_3d/contact_angle_force_sign.csv b/validation/surface_tension_3d/contact_angle_force_sign.csv
new file mode 100644
index 0000000000..bad2ba42a5
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_force_sign.csv
@@ -0,0 +1,13 @@
+kind,variant,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,line_angle,shape_acceleration,boundary_shape_acceleration,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,contact_shape_acceleration,expected_direction,sign_pass
+force_sign,baseline,60.0,90.0,none,1500,1508,0.1,4000.0,0.0,1,87.78824383296441,87.78824383296441,27.78824383296441,27.78824383296441,0.0,90.47465615947603,NaN,NaN,0.006340809670341996,0.0,0,1000.0018423817778,1000.0018423817778,0.0,true,0,0,0.0,Inf,1.2230126506308182e-5,NaN,NaN,NaN,5.247618787,15.37099357,253840,0,0.0,-1.0,true
+force_sign,baseline,60.0,90.0,geometric,1500,1508,0.1,4000.0,0.0,1,87.78824383296441,87.78824383296441,27.78824383296441,27.78824383296441,0.0,90.47465615947603,59.999999999999986,NaN,0.007932551738815042,0.0,0,1000.0018423817778,1000.0018423817778,0.0,true,0,0,0.0,Inf,1.2230126506308182e-5,NaN,NaN,NaN,1.690055666,5.950380953,290088,36192,0.001591742068473046,-1.0,false
+force_sign,baseline,60.0,90.0,contact_line_force,1500,1508,0.1,4000.0,0.0,1,87.78824383296441,87.78824383296441,27.78824383296441,27.78824383296441,0.0,90.47465615947603,73.80141281517759,73.83682148011965,0.003911756545216449,0.0,0,1000.0018423817778,1000.0018423817778,0.0,true,0,0,0.0,Inf,1.2230126506308182e-5,NaN,NaN,NaN,1.701522283,6.324263793,314312,60320,-0.0024290531251255467,-1.0,true
+force_sign,baseline,90.0,60.0,none,1500,1480,0.1,4000.0,0.0,1,59.240154735427446,59.240154735427446,-30.759845264572554,-30.759845264572554,0.0,65.53689179478333,NaN,NaN,0.025617239827415973,0.0,0,1000.0012502506095,1000.0012502506095,0.0,true,0,0,0.0,Inf,1.2230126506308182e-5,NaN,NaN,NaN,0.027779988,0.596648369,249136,0,0.0,1.0,true
+force_sign,baseline,90.0,60.0,geometric,1500,1480,0.1,4000.0,0.0,1,59.240154735427446,59.240154735427446,-30.759845264572554,-30.759845264572554,0.0,65.53689179478333,90.00000000000003,NaN,0.020729744901776415,0.0,0,1000.0012502506095,1000.0012502506095,0.0,true,0,0,0.0,Inf,1.2230126506308182e-5,NaN,NaN,NaN,0.027970288,0.599715648,284712,35520,-0.004887494925639557,1.0,false
+force_sign,baseline,90.0,60.0,contact_line_force,1500,1480,0.1,4000.0,0.0,1,59.240154735427446,59.240154735427446,-30.759845264572554,-30.759845264572554,0.0,65.53689179478333,50.982086175992,52.86475463455987,0.040914849860637315,0.0,0,1000.0012502506095,1000.0012502506095,0.0,true,0,0,0.0,Inf,1.2230126506308182e-5,NaN,NaN,NaN,0.032702634,0.605382652,308488,59200,0.015297610033221342,1.0,true
+force_sign,baseline,90.0,120.0,none,1500,1492,0.1,4000.0,0.0,1,118.05922431859437,118.05922431859437,28.059224318594374,28.059224318594374,0.0,116.67757595244969,NaN,NaN,-0.003958004005173301,0.0,0,1000.0021934467691,1000.0021934467691,0.0,true,0,0,0.0,Inf,1.2230126506308182e-5,NaN,NaN,NaN,0.027328409,0.377087746,251152,0,0.0,-1.0,true
+force_sign,baseline,90.0,120.0,geometric,1500,1492,0.1,4000.0,0.0,1,118.05922431859437,118.05922431859437,28.059224318594374,28.059224318594374,0.0,116.67757595244969,90.00000000000003,NaN,-0.004292680633811273,0.0,0,1000.0021934467691,1000.0021934467691,0.0,true,0,0,0.0,Inf,1.2230126506308182e-5,NaN,NaN,NaN,0.026955822,0.373489033,287016,35808,-0.00033467662863797144,-1.0,true
+force_sign,baseline,90.0,120.0,contact_line_force,1500,1492,0.1,4000.0,0.0,1,118.05922431859437,118.05922431859437,28.059224318594374,28.059224318594374,0.0,116.67757595244969,94.4796863704986,84.29971773813573,-0.003518965619245596,0.0,0,1000.0021934467691,1000.0021934467691,0.0,true,0,0,0.0,Inf,1.2230126506308182e-5,NaN,NaN,NaN,0.03215803,0.390287939,310984,59680,0.00043903838592770526,-1.0,false
+force_sign,baseline,120.0,90.0,none,1500,1508,0.1,4000.0,0.0,1,87.78824383296441,87.78824383296441,-32.21175616703559,-32.21175616703559,0.0,90.47465615947603,NaN,NaN,0.006322566937236813,0.0,0,1000.0018423817778,1000.0018423817778,0.0,true,0,0,0.0,Inf,1.2230126506308182e-5,NaN,NaN,NaN,0.027048688,0.374261372,253840,0,0.0,1.0,true
+force_sign,baseline,120.0,90.0,geometric,1500,1508,0.1,4000.0,0.0,1,87.78824383296441,87.78824383296441,-32.21175616703559,-32.21175616703559,0.0,90.47465615947603,120.00000000000003,NaN,0.008732417820200731,0.0,0,1000.0018423817778,1000.0018423817778,0.0,true,0,0,0.0,Inf,1.2230126506308182e-5,NaN,NaN,NaN,0.026985175,0.37957372,290088,36192,0.002409850882963918,1.0,true
+force_sign,baseline,120.0,90.0,contact_line_force,1500,1508,0.1,4000.0,0.0,1,87.78824383296441,87.78824383296441,-32.21175616703559,-32.21175616703559,0.0,90.47465615947603,73.66743545088994,73.730913637743,0.01503906887541758,0.0,0,1000.0018423817778,1000.0018423817778,0.0,true,0,0,0.0,Inf,1.2230126506308182e-5,NaN,NaN,NaN,0.031853832,0.392893791,314312,60320,0.008716501938180767,1.0,true
diff --git a/validation/surface_tension_3d/contact_angle_force_sign_extended.csv b/validation/surface_tension_3d/contact_angle_force_sign_extended.csv
new file mode 100644
index 0000000000..fe2961845f
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_force_sign_extended.csv
@@ -0,0 +1,13 @@
+kind,variant,target,initial_angle,requested_particles,particle_count,control_acceleration,contact_shape_acceleration,acceleration,expected_direction,total_sign_pass,contact_sign_pass,wall_zero_at_90,measure_eligible,static_eligible,sign_pass
+force_sign,compatible_geometry_wall,60.0,90.0,1500,1508,0.006340809670341996,-0.012653859782893098,-0.006313050112551102,-1.0,true,true,,false,true,true
+force_sign,compatible_geometry_wall,90.0,60.0,1500,1480,0.025617239827415973,0.0,0.025617239827415973,1.0,true,false,true,false,true,true
+force_sign,compatible_geometry_wall,90.0,120.0,1500,1492,-0.003958004005173301,0.0,-0.003958004005173301,-1.0,true,false,true,false,true,true
+force_sign,compatible_geometry_wall,120.0,90.0,1500,1508,0.006322566937236813,0.01264640425265222,0.018968971189889033,1.0,true,true,,false,true,true
+force_sign,corrected_wetted_area,60.0,90.0,1500,1508,0.006340809670341996,-0.015145983956990525,-0.00880517428664853,-1.0,true,true,,true,true,true
+force_sign,corrected_wetted_area,90.0,60.0,1500,1480,0.025617239827415973,0.0,0.025617239827415973,1.0,true,false,true,true,true,true
+force_sign,corrected_wetted_area,90.0,120.0,1500,1492,-0.003958004005173301,0.0,-0.003958004005173301,-1.0,true,false,true,true,true,true
+force_sign,corrected_wetted_area,120.0,90.0,1500,1508,0.006322566937236813,0.014293615002345904,0.020616181939582718,1.0,true,true,,true,true,true
+force_sign,young_color_boundary,60.0,90.0,1500,1508,0.006340809670341996,-0.0002557485980451144,0.006085061072296881,-1.0,false,true,,true,false,false
+force_sign,young_color_boundary,90.0,60.0,1500,1480,0.025617239827415973,-0.0009147279890239259,0.024702511838392047,1.0,true,false,,true,false,true
+force_sign,young_color_boundary,90.0,120.0,1500,1492,-0.003958004005173301,0.0002092548554881177,-0.0037487491496851835,-1.0,true,false,,true,false,true
+force_sign,young_color_boundary,120.0,90.0,1500,1508,0.006322566937236813,-0.00016942843555894233,0.006153138501677871,1.0,true,false,,true,false,true
diff --git a/validation/surface_tension_3d/contact_angle_force_sign_ghost_geometric.csv b/validation/surface_tension_3d/contact_angle_force_sign_ghost_geometric.csv
new file mode 100644
index 0000000000..d15937ab71
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_force_sign_ghost_geometric.csv
@@ -0,0 +1,5 @@
+kind,variant,target,initial_angle,requested_particles,particle_count,control_acceleration,acceleration,contact_shape_acceleration,expected_direction,sign_pass
+force_sign,ghost_geometric,60.0,90.0,1500,1508,0.006340809670341996,0.007883430057117386,0.0015426203867753901,-1.0,false
+force_sign,ghost_geometric,90.0,60.0,1500,1480,0.025617239827415973,0.021089704525308152,-0.004527535302107821,1.0,false
+force_sign,ghost_geometric,90.0,120.0,1500,1492,-0.003958004005173301,-0.00424373859189013,-0.00028573458671682893,-1.0,true
+force_sign,ghost_geometric,120.0,90.0,1500,1508,0.006322566937236813,0.00875141505196569,0.0024288481147288774,1.0,true
diff --git a/validation/surface_tension_3d/contact_angle_force_sign_wall_energy.csv b/validation/surface_tension_3d/contact_angle_force_sign_wall_energy.csv
new file mode 100644
index 0000000000..55a4925b18
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_force_sign_wall_energy.csv
@@ -0,0 +1,9 @@
+kind,variant,target,initial_angle,requested_particles,particle_count,control_acceleration,wall_acceleration,acceleration,contact_shape_acceleration,expected_direction,sign_pass
+force_sign,wall_energy_1x,60.0,90.0,1500,1508,0.006340809670341996,-0.005512745609287822,0.0008280640610541738,-0.005512745609287822,-1.0,false
+force_sign,wall_energy_2x,60.0,90.0,1500,1508,0.006340809670341996,-0.011025491218575644,-0.004684681548233648,-0.011025491218575644,-1.0,true
+force_sign,wall_energy_1x,90.0,60.0,1500,1480,0.025617239827415973,0.0,0.025617239827415973,0.0,1.0,true
+force_sign,wall_energy_2x,90.0,60.0,1500,1480,0.025617239827415973,0.0,0.025617239827415973,0.0,1.0,true
+force_sign,wall_energy_1x,90.0,120.0,1500,1492,-0.003958004005173301,0.0,-0.003958004005173301,0.0,-1.0,true
+force_sign,wall_energy_2x,90.0,120.0,1500,1492,-0.003958004005173301,0.0,-0.003958004005173301,0.0,-1.0,true
+force_sign,wall_energy_1x,120.0,90.0,1500,1508,0.006322566937236813,0.005577263532829588,0.0118998304700664,0.005577263532829588,1.0,true
+force_sign,wall_energy_2x,120.0,90.0,1500,1508,0.006322566937236813,0.011154527065659175,0.017477094002895988,0.011154527065659175,1.0,true
diff --git a/validation/surface_tension_3d/contact_angle_force_sign_wetted_area.csv b/validation/surface_tension_3d/contact_angle_force_sign_wetted_area.csv
new file mode 100644
index 0000000000..b4863a4408
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_force_sign_wetted_area.csv
@@ -0,0 +1,5 @@
+kind,variant,target,initial_angle,requested_particles,particle_count,control_acceleration,wall_acceleration,acceleration,expected_direction,wall_zero_at_90,sign_pass,measure_eligible
+force_sign,wetted_area,60.0,90.0,1500,1508,0.006340809670341996,-0.014323580254964195,-0.007982770584622198,-1.0,,true,false
+force_sign,wetted_area,90.0,60.0,1500,1480,0.025617239827415973,0.0,0.025617239827415973,1.0,true,true,false
+force_sign,wetted_area,90.0,120.0,1500,1492,-0.003958004005173301,0.0,-0.003958004005173301,-1.0,true,true,false
+force_sign,wetted_area,120.0,90.0,1500,1508,0.006322566937236813,0.014260902451901761,0.020583469389138576,1.0,,true,false
diff --git a/validation/surface_tension_3d/contact_angle_normal_components.csv b/validation/surface_tension_3d/contact_angle_normal_components.csv
new file mode 100644
index 0000000000..afb021c82b
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_normal_components.csv
@@ -0,0 +1,39 @@
+kind,variant,target,initial_angle,requested_particles,particle_count,local_angle,angle_mean,angle_median,angle_p10,angle_p90,mean_error,median_error,wrong_sign_weight,valid_weight_fraction,line_particles,valid_particles,line_weight,discrete_line_length,analytic_line_length,line_length_error,cross_line_length,cross_error_1x,cross_error_2x,cross_error_4x,cross_line_length_raw,raw_cross_error_4x,corrected_cross_line_length,corrected_cross_error_4x
+normal,baseline_total,30.0,30.0,750,760,30.81164085139647,28.848186931026486,30.597115621019444,19.543459410062354,37.97815755999795,1.9634539203699823,0.2145252303770242,0.4774619102514957,1.0,418,418,4.183671464805477e7,0.055782255860191164,0.08318250925018945,0.3293992166981412,0.013716243785962267,0.8351066358829392,0.6702132717658784,0.34042654353175683,0.014582978404842574,0.2987478480130432,0.02054093519400042,0.012247388103232226
+normal,baseline_total,60.0,60.0,750,776,60.6325825549372,53.39776499917253,52.641012611678974,45.03509036641297,65.47515721478216,7.234817555764671,7.991569943258227,0.769553657826818,1.0,186,186,2.881970008292826e7,0.0384262187348343,0.06267210593303608,0.3868688763085133,0.01107393504025107,0.8233036073164136,0.6466072146328272,0.29321442926565433,0.011762161068144615,0.24928892093000654,0.015739521375786904,0.004563107715210624
+normal,baseline_total,90.0,90.0,750,736,89.45404799393238,72.45714625080306,72.58796627110885,57.04166338004779,79.2250840499702,16.996901743129314,16.866081722823523,0.07071511152953922,1.0,60,60,1.5108608777142314e7,0.0201447745884908,0.049108914031371555,0.5897939307796137,0.007475106770814142,0.8477851339567614,0.6955702679135227,0.39114053582704555,0.008523335797538565,0.3057605963680058,0.01076123781638738,0.12347987906937385
+normal,baseline_total,120.0,120.0,750,768,121.26298125731725,90.52503140332412,91.49674558162533,82.64456772352885,99.69993563608533,30.73794985399313,29.766235675691917,1.0,1.0,28,28,6.817624970372566e6,0.00909014668841054,0.035722597891021876,0.7455351171227342,0.004608474520120183,0.8709927387090056,0.7419854774180112,0.4839709548360226,0.005298040465514016,0.4067575396754034,0.006392966336872589,0.28415437685965983
+normal,baseline_total,150.0,150.0,750,728,152.7205054577875,88.30659308886409,81.04467080183761,79.88932629111092,101.36480230885381,64.41391236892342,71.6758346559499,1.0,1.0,20,20,4.751198130077558e6,0.006334916198475338,0.01957315597740063,0.6763467166056563,0.003032836102572421,0.8450512474291747,0.6901024948583494,0.3802049897166987,0.0036133119260711886,0.2615780653374129,0.004332648752246738,0.11457329472073718
+normal,baseline_total,30.0,30.0,1500,1504,30.19653104455346,29.529342006613064,30.686752012077953,19.528175324666595,39.60097996563345,0.6671890379403962,0.4902209675244933,0.46191906792266973,1.0,587,587,8.980002792000565e7,0.0598666527214867,0.08318250925018945,0.2802975858611725,0.014308506917275407,0.8279865918177647,0.6559731836355294,0.3119463672710586,0.015432895257369583,0.2578778689663328,0.021498074175881524,0.033778584929262
+normal,baseline_total,60.0,60.0,1500,1480,59.240154735427446,52.86475463455987,51.4181944087227,46.81835661801829,62.58061318109166,6.375400100867573,7.8219603267047475,0.13352049620133774,1.0,218,218,5.808346029625704e7,0.038722258451644134,0.06267210593303608,0.38214524827012986,0.011452226101951719,0.8172675717297868,0.6345351434595736,0.26907028691914725,0.012160043537362096,0.22389437174140192,0.016162704253551083,0.03157243644058272
+normal,baseline_total,90.0,60.0,1500,1480,59.240154735427446,52.86475463455987,51.4181944087227,46.81835661801829,62.58061318109166,6.375400100867573,7.8219603267047475,0.0,1.0,218,218,5.808346029625704e7,0.038722258451644134,0.06267210593303608,0.38214524827012986,0.011452226101951719,0.8172675717297868,0.6345351434595736,0.26907028691914725,0.012160043537362096,0.22389437174140192,0.016162704253551083,0.03157243644058272
+normal,baseline_total,60.0,90.0,1500,1508,87.78824383296441,73.73091363774299,74.8732906867833,57.17931051286257,79.63071727903181,14.057330195221425,12.914953146181105,0.14716329370911801,1.0,84,84,3.1585969869232558e7,0.02105727445061629,0.049108914031371555,0.5712127855817668,0.007856192705285321,0.840025118448625,0.6800502368972501,0.36010047379450005,0.008779790382782426,0.2848719580991951,0.011065626054714952,0.09868696769421093
+normal,baseline_total,90.0,90.0,1500,1508,87.78824383296441,73.73091363774299,74.8732906867833,57.17931051286257,79.63071727903181,14.057330195221425,12.914953146181105,0.0962658589676665,1.0,84,84,3.1585969869232558e7,0.02105727445061629,0.049108914031371555,0.5712127855817668,0.007856192705285321,0.840025118448625,0.6800502368972501,0.36010047379450005,0.008779790382782426,0.2848719580991951,0.011065626054714952,0.09868696769421093
+normal,baseline_total,120.0,90.0,1500,1508,87.78824383296441,73.73091363774299,74.8732906867833,57.17931051286257,79.63071727903181,14.057330195221425,12.914953146181105,0.0,1.0,84,84,3.1585969869232558e7,0.02105727445061629,0.049108914031371555,0.5712127855817668,0.007856192705285321,0.840025118448625,0.6800502368972501,0.36010047379450005,0.008779790382782426,0.2848719580991951,0.011065626054714952,0.09868696769421093
+normal,baseline_total,90.0,120.0,1500,1492,118.05922431859437,84.30119031782489,83.4354708266115,79.48845366386207,95.03996384417994,33.758034000769484,34.62375349198287,0.7380312874622319,1.0,36,36,1.5787769130597072e7,0.010525156334027893,0.035722597891021876,0.7053641964636292,0.004973180734873781,0.8607833408408495,0.721566681681699,0.4431333633633979,0.005865660205904878,0.3431989214447263,0.007051781795430263,0.2103842148386884
+normal,baseline_total,120.0,120.0,1500,1492,118.05922431859437,84.30119031782489,83.4354708266115,79.48845366386207,95.03996384417994,33.758034000769484,34.62375349198287,0.0,1.0,36,36,1.5787769130597072e7,0.010525156334027893,0.035722597891021876,0.7053641964636292,0.004973180734873781,0.8607833408408495,0.721566681681699,0.4431333633633979,0.005865660205904878,0.3431989214447263,0.007051781795430263,0.2103842148386884
+normal,baseline_total,150.0,150.0,1500,1504,149.99774220905832,95.88252771909471,98.32746293393029,91.11659196618974,100.24507756891849,54.115214489963606,51.670279275128024,0.0,1.0,24,24,7.494472954034056e6,0.004996303754940506,0.01957315597740063,0.74473693661312,0.0028703391231740464,0.8533532800490544,0.7067065600981088,0.4134131201962178,0.003219697520062909,0.34201770551863886,0.003711973827544385,0.24141536871616032
+normal,baseline_total,30.0,30.0,3000,3004,30.21799495784461,30.06943619729532,30.812422130639273,20.324432192095543,41.37516565400462,0.14855876054928885,0.5944271727946635,0.44202829600062565,1.0,720,720,1.8876647362955406e8,0.06292212365628115,0.08318250925018945,0.24356545356152692,0.01499880324829353,0.8196880163451024,0.6393760326902047,0.27875206538040953,0.016121920695471047,0.22474468054427788,0.02239279936860577,0.07680326407343951
+normal,baseline_total,60.0,60.0,3000,2980,60.400487988287246,57.2591718503646,55.3019262443085,48.99444633731251,66.29404765101455,3.1413161379226437,5.098561743978749,0.6037797675380167,1.0,232,232,1.0697606630909045e8,0.035658644187455166,0.06267210593303608,0.43102846702557396,0.011107462528036,0.8227686406468596,0.6455372812937192,0.29107456258743847,0.011969661274473397,0.2360453764063557,0.015670939577139786,0.0001859260248173733
+normal,baseline_total,90.0,90.0,3000,3004,90.51880954004696,77.82297093050684,77.40022447679266,69.7872197752411,82.47040949493092,12.695838609540118,13.1185850632543,0.9045602365278907,1.0,84,84,5.642203772173821e7,0.018807311256998513,0.049108914031371555,0.6170285654253298,0.0070972303341294745,0.8554797947762467,0.7109595895524934,0.4219191791049869,0.008490491408557774,0.308435824654244,0.010396607526034931,0.1531796024327955
+normal,baseline_total,120.0,120.0,3000,2992,118.99726426566806,92.32020419278072,91.62597453097868,83.56972780586418,99.68223478874914,26.677060072887343,27.371289734689384,0.0,1.0,44,44,2.509471634784128e7,0.00836488710134584,0.035722597891021876,0.7658376603273812,0.004450883689947426,0.8754042552133068,0.7508085104266137,0.5016170208532273,0.00523720393982561,0.4135696450966271,0.0061433924100623444,0.3121001525360666
+normal,baseline_total,150.0,150.0,3000,3004,150.9920265180778,96.41041920172432,96.46042041585167,92.06469695454386,99.79006235135897,54.581607316353484,54.531606102226135,1.0,1.0,28,28,1.347348813935138e7,0.004491152332903713,0.01957315597740063,0.770545315324251,0.002655093533727627,0.8643502592635942,0.7287005185271884,0.45740103705437685,0.003074291256289738,0.37173315129367046,0.003518369086103844,0.2809807288786357
+normal,fluid_only,30.0,30.0,750,760,30.81164085139647,88.98837280629786,89.31106872857261,26.419347857087345,155.41782934623993,58.17673195490139,58.49942787717614,0.12032868352222933,0.9999980189404204,418,396,4.183671464805477e7,0.055782255860191164,0.08318250925018945,0.3293992166981412,0.013716243785962267,0.8351066358829392,0.6702132717658784,0.34042654353175683,0.014582978404842574,0.2987478480130432,0.02054093519400042,0.012247388103232226
+normal,fluid_only,60.0,60.0,750,776,60.6325825549372,107.23961351380838,104.58328245707285,49.576068662801966,155.65982850625335,46.60703095887118,43.95069990213565,0.18446622043178612,1.0,186,186,2.881970008292826e7,0.0384262187348343,0.06267210593303608,0.3868688763085133,0.01107393504025107,0.8233036073164136,0.6466072146328272,0.29321442926565433,0.011762161068144615,0.24928892093000654,0.015739521375786904,0.004563107715210624
+normal,fluid_only,90.0,90.0,750,736,89.45404799393238,126.33288147976401,124.7810555231727,104.88448509289061,151.48228962647818,36.878833485831635,35.32700752924032,0.9825767954744531,1.0,60,60,1.5108608777142314e7,0.0201447745884908,0.049108914031371555,0.5897939307796137,0.007475106770814142,0.8477851339567614,0.6955702679135227,0.39114053582704555,0.008523335797538565,0.3057605963680058,0.01076123781638738,0.12347987906937385
+normal,fluid_only,120.0,120.0,750,768,121.26298125731725,139.053174331803,138.80150132166747,129.10353253379867,150.01252868615367,17.790193074485757,17.538520064350223,0.0,1.0,28,28,6.817624970372566e6,0.00909014668841054,0.035722597891021876,0.7455351171227342,0.004608474520120183,0.8709927387090056,0.7419854774180112,0.4839709548360226,0.005298040465514016,0.4067575396754034,0.006392966336872589,0.28415437685965983
+normal,fluid_only,150.0,150.0,750,728,152.7205054577875,135.44543610527418,129.12364572856512,124.39151731486551,153.25015217754614,17.275069352513327,23.596859729222388,0.7380083620207947,1.0,20,20,4.751198130077558e6,0.006334916198475338,0.01957315597740063,0.6763467166056563,0.003032836102572421,0.8450512474291747,0.6901024948583494,0.3802049897166987,0.0036133119260711886,0.2615780653374129,0.004332648752246738,0.11457329472073718
+normal,fluid_only,30.0,30.0,1500,1504,30.19653104455346,88.66224011932451,88.44173433515648,24.611173238256402,155.4674367763557,58.46570907477105,58.24520329060302,0.13731742121932577,1.0,587,587,8.980002792000565e7,0.0598666527214867,0.08318250925018945,0.2802975858611725,0.014308506917275407,0.8279865918177647,0.6559731836355294,0.3119463672710586,0.015432895257369583,0.2578778689663328,0.021498074175881524,0.033778584929262
+normal,fluid_only,60.0,60.0,1500,1480,59.240154735427446,111.00786089127831,105.38056174074107,55.02605644190101,156.59673283331037,51.76770615585087,46.14040700531363,0.8619970530649491,1.0,218,218,5.808346029625704e7,0.038722258451644134,0.06267210593303608,0.38214524827012986,0.011452226101951719,0.8172675717297868,0.6345351434595736,0.26907028691914725,0.012160043537362096,0.22389437174140192,0.016162704253551083,0.03157243644058272
+normal,fluid_only,90.0,60.0,1500,1480,59.240154735427446,111.00786089127831,105.38056174074107,55.02605644190101,156.59673283331037,51.76770615585087,46.14040700531363,0.8533610318374295,1.0,218,218,5.808346029625704e7,0.038722258451644134,0.06267210593303608,0.38214524827012986,0.011452226101951719,0.8172675717297868,0.6345351434595736,0.26907028691914725,0.012160043537362096,0.22389437174140192,0.016162704253551083,0.03157243644058272
+normal,fluid_only,60.0,90.0,1500,1508,87.78824383296441,127.43308933064242,125.05378203996095,106.64965585654313,153.33477136208123,39.644845497678006,37.26553820699654,0.0,1.0,84,84,3.1585969869232558e7,0.02105727445061629,0.049108914031371555,0.5712127855817668,0.007856192705285321,0.840025118448625,0.6800502368972501,0.36010047379450005,0.008779790382782426,0.2848719580991951,0.011065626054714952,0.09868696769421093
+normal,fluid_only,90.0,90.0,1500,1508,87.78824383296441,127.43308933064242,125.05378203996095,106.64965585654313,153.33477136208123,39.644845497678006,37.26553820699654,0.9787041515151271,1.0,84,84,3.1585969869232558e7,0.02105727445061629,0.049108914031371555,0.5712127855817668,0.007856192705285321,0.840025118448625,0.6800502368972501,0.36010047379450005,0.008779790382782426,0.2848719580991951,0.011065626054714952,0.09868696769421093
+normal,fluid_only,120.0,90.0,1500,1508,87.78824383296441,127.43308933064242,125.05378203996095,106.64965585654313,153.33477136208123,39.644845497678006,37.26553820699654,0.7165366631244514,1.0,84,84,3.1585969869232558e7,0.02105727445061629,0.049108914031371555,0.5712127855817668,0.007856192705285321,0.840025118448625,0.6800502368972501,0.36010047379450005,0.008779790382782426,0.2848719580991951,0.011065626054714952,0.09868696769421093
+normal,fluid_only,90.0,120.0,1500,1492,118.05922431859437,133.73470101993428,130.5928848677999,126.85875532541284,149.22792073425973,15.675476701339903,12.533660549205536,0.0,1.0,36,36,1.5787769130597072e7,0.010525156334027893,0.035722597891021876,0.7053641964636292,0.004973180734873781,0.8607833408408495,0.721566681681699,0.4431333633633979,0.005865660205904878,0.3431989214447263,0.007051781795430263,0.2103842148386884
+normal,fluid_only,120.0,120.0,1500,1492,118.05922431859437,133.73470101993428,130.5928848677999,126.85875532541284,149.22792073425973,15.675476701339903,12.533660549205536,1.0000000000000002,1.0,36,36,1.5787769130597072e7,0.010525156334027893,0.035722597891021876,0.7053641964636292,0.004973180734873781,0.8607833408408495,0.721566681681699,0.4431333633633979,0.005865660205904878,0.3431989214447263,0.007051781795430263,0.2103842148386884
+normal,fluid_only,150.0,150.0,1500,1504,149.99774220905832,143.6280821076896,146.9375652942442,135.7774664604741,150.85166359690274,6.369660101368709,3.0601769148141216,0.19797595631403347,1.0,24,24,7.494472954034056e6,0.004996303754940506,0.01957315597740063,0.74473693661312,0.0028703391231740464,0.8533532800490544,0.7067065600981088,0.4134131201962178,0.003219697520062909,0.34201770551863886,0.003711973827544385,0.24141536871616032
+normal,fluid_only,30.0,30.0,3000,3004,30.21799495784461,89.88796667833337,88.49562108861117,25.697015363623148,157.87352506725858,59.66997172048876,58.27762613076656,0.1326307432711447,1.0,720,720,1.8876647362955406e8,0.06292212365628115,0.08318250925018945,0.24356545356152692,0.01499880324829353,0.8196880163451024,0.6393760326902047,0.27875206538040953,0.016121920695471047,0.22474468054427788,0.02239279936860577,0.07680326407343951
+normal,fluid_only,60.0,60.0,3000,2980,60.400487988287246,113.44975753361263,106.34013715856784,60.44101861293812,150.98906028298197,53.04926954532538,45.939649170280596,0.08425636576071745,1.0,232,232,1.0697606630909045e8,0.035658644187455166,0.06267210593303608,0.43102846702557396,0.011107462528036,0.8227686406468596,0.6455372812937192,0.29107456258743847,0.011969661274473397,0.2360453764063557,0.015670939577139786,0.0001859260248173733
+normal,fluid_only,90.0,90.0,3000,3004,90.51880954004696,127.39623428591719,125.89091758665765,116.26826572228052,140.8097912071044,36.87742474587023,35.37210804661069,0.007067568573260266,1.0,84,84,5.642203772173821e7,0.018807311256998513,0.049108914031371555,0.6170285654253298,0.0070972303341294745,0.8554797947762467,0.7109595895524934,0.4219191791049869,0.008490491408557774,0.308435824654244,0.010396607526034931,0.1531796024327955
+normal,fluid_only,120.0,120.0,3000,2992,118.99726426566806,140.4944044732665,138.75894646279107,129.8477244441228,149.97304049015202,21.49714020759845,19.761682197123008,0.9999999999999999,1.0,44,44,2.509471634784128e7,0.00836488710134584,0.035722597891021876,0.7658376603273812,0.004450883689947426,0.8754042552133068,0.7508085104266137,0.5016170208532273,0.00523720393982561,0.4135696450966271,0.0061433924100623444,0.3121001525360666
+normal,fluid_only,150.0,150.0,3000,3004,150.9920265180778,144.0932204511864,142.5159610091279,137.4823338784478,149.8888219535023,6.89880606689141,8.476065508949915,1.0,1.0,28,28,1.347348813935138e7,0.004491152332903713,0.01957315597740063,0.770545315324251,0.002655093533727627,0.8643502592635942,0.7287005185271884,0.45740103705437685,0.003074291256289738,0.37173315129367046,0.003518369086103844,0.2809807288786357
diff --git a/validation/surface_tension_3d/contact_angle_perturbation.csv b/validation/surface_tension_3d/contact_angle_perturbation.csv
new file mode 100644
index 0000000000..dbe2010f82
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_perturbation.csv
@@ -0,0 +1,13 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,line_angle,shape_acceleration,boundary_shape_acceleration,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,control_error_reduction,contact_shape_acceleration,motion_toward_target,beats_control,acceleration_toward_target,response_pass
+perturbation,60.0,90.0,none,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.84925378056579,27.78824383296441,27.84925378056579,-0.002195530885942487,90.58346656631097,NaN,NaN,0.006340809670341996,0.0,0,998.6193761376162,1000.0049131497138,0.0018838103346938318,true,322,46,0.125,9.910466084363524e-6,1.2230126506308182e-5,1.2897212755610705,2.5735875360493035,1.009009956067085,31.872191197,42.71138139,253840,0,-0.002195530885942487,0.0,false,true,true,true
+perturbation,60.0,90.0,geometric,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.89290440110224,27.78824383296441,27.892904401102243,-0.0037663613709073562,90.57946002828503,60.000000000000014,NaN,0.007932551738815042,0.0,0,998.8020627830055,1000.005214694395,0.002130595833277761,true,322,43,0.1178082191780822,4.832592665302005e-6,1.2230126506308182e-5,1.302772098170332,2.574441999981093,1.0237146187157877,28.400702787,32.636141975,290088,36192,-0.002195530885942487,0.001591742068473046,false,false,false,false
+perturbation,60.0,90.0,contact_line_force,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.84125270274653,27.78824383296441,27.84125270274653,-0.0019076005702538534,90.57943874300376,76.46999014087012,79.14035494719728,0.003911756545216449,0.0,0,998.1744271472235,1000.0048383014753,0.0019774102931295065,true,322,45,0.1226158038147139,1.909104008259929e-6,1.2230126506308182e-5,1.3319635329991755,2.5702305949766613,1.02617826723261,33.243437581,37.538884658,314312,60320,-0.002195530885942487,-0.0024290531251255467,false,true,true,false
+perturbation,90.0,60.0,none,1500,1480,0.1,4000.0,0.01,1,59.240154735427446,59.37354077858404,-30.759845264572554,-30.626459221415963,0.0043363691204980626,65.69601879328322,NaN,NaN,0.025617239827415973,0.0,0,996.8675352729871,1000.0048070221002,0.002295818121752908,true,313,19,0.0572289156626506,9.807417560557384e-6,1.2230126506308182e-5,1.3302637799372479,2.6503430594295043,1.0170327247734718,23.478130113,24.039522917,249136,0,0.0043363691204980626,0.0,true,true,true,true
+perturbation,90.0,60.0,geometric,1500,1480,0.1,4000.0,0.01,1,59.240154735427446,59.228994364538316,-30.759845264572554,-30.771005635461684,-0.00036282272531407145,65.70809101096721,90.00000000000004,NaN,0.020729744901776415,0.0,0,996.7457923195412,1000.0051910079588,0.0017736373373906072,true,320,37,0.10364145658263306,1.912013442958299e-6,1.2230126506308182e-5,1.1324776667688268,2.634812736487866,1.0164358433918514,25.197048348,26.014961553,284712,35520,0.0043363691204980626,-0.004887494925639557,false,false,false,false
+perturbation,90.0,60.0,contact_line_force,1500,1480,0.1,4000.0,0.01,1,59.240154735427446,59.5261159158727,-30.759845264572554,-30.4738840841273,0.009296574088251686,65.70678046810686,52.83318912414703,58.16403588711674,0.040914849860637315,0.0,0,998.5959933247723,1000.0050545521696,0.002663654660032263,true,317,48,0.13150684931506848,1.026165704943835e-5,1.2230126506308182e-5,1.2362592424131171,2.631713646880181,1.0400507395300127,30.417658267,30.988310451,308488,59200,0.0043363691204980626,0.015297610033221342,true,true,true,true
+perturbation,90.0,120.0,none,1500,1492,0.1,4000.0,0.01,1,118.05922431859437,118.23063413442455,28.059224318594374,28.230634134424548,-0.006108857960003711,116.74771530867152,NaN,NaN,-0.003958004005173301,0.0,0,999.1479106129792,1000.0048424522311,0.0015339313660140404,true,326,38,0.1043956043956044,1.0256374648685154e-5,1.2230126506308182e-5,1.2967092822555601,2.542781856283903,1.023501397223866,25.646877001,25.99706263,251152,0,-0.006108857960003711,0.0,false,true,true,true
+perturbation,90.0,120.0,geometric,1500,1492,0.1,4000.0,0.01,1,118.05922431859437,118.17890956568237,28.059224318594374,28.178909565682375,-0.004265451023486966,116.74740839524539,90.0,NaN,-0.004292680633811273,0.0,0,999.121206056526,1000.0049447754614,0.0016066925461618346,true,326,37,0.10192837465564739,1.0232300163593569e-5,1.2230126506308182e-5,1.3214929647236437,2.5527510656688426,1.0288923938537606,25.305355928,25.662277067,287016,35808,-0.006108857960003711,-0.00033467662863797144,false,true,true,false
+perturbation,90.0,120.0,contact_line_force,1500,1492,0.1,4000.0,0.01,1,118.05922431859437,118.30648520900154,28.059224318594374,28.306485209001536,-0.008812107120270918,116.74796566003978,96.88820698622986,89.18511080253086,-0.003518965619245596,0.0,0,999.2325993216095,1000.004844265267,0.001532097508842073,true,327,35,0.09668508287292818,6.998584272523839e-6,1.2230126506308182e-5,1.288147236556328,2.557391969804226,1.0321026216498024,29.594590996,29.96509239,310984,59680,-0.006108857960003711,0.00043903838592770526,false,false,false,false
+perturbation,120.0,90.0,none,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.85482756777331,-32.21175616703559,-32.14517243222669,0.0020670631698448405,90.58348911431534,NaN,NaN,0.006322566937236813,0.0,0,998.6348038786263,1000.004952344707,0.0018842532409343899,true,322,42,0.11538461538461539,9.87003411610475e-6,1.2230126506308182e-5,1.2935004106918497,2.5738496457139224,1.0464080530369324,25.42012289,25.774739037,253840,0,0.0020670631698448405,0.0,true,true,true,true
+perturbation,120.0,90.0,geometric,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.90521125899403,-32.21175616703559,-32.09478874100597,0.003631203012436801,90.58854624726324,119.99999999999997,NaN,0.008732417820200731,0.0,0,999.3702414500565,1000.0060730798567,0.0015170252490006072,true,324,50,0.13368983957219252,8.129143144688485e-6,1.2230126506308182e-5,1.1263405465070386,2.583054776838676,1.0578647138806039,26.083247351,26.448409974,290088,36192,0.0020670631698448405,0.002409850882963918,true,true,true,true
+perturbation,120.0,90.0,contact_line_force,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,88.23772672671097,-32.21175616703559,-31.762273273289026,0.013954001496091939,90.59182878105032,76.51624795231697,79.86583395658411,0.01503906887541758,0.0,0,999.4245109450723,1000.0060541822496,0.001961931791936529,true,324,38,0.10497237569060773,1.0730841381630984e-5,1.2230126506308182e-5,1.248823564508646,2.570673469516981,0.9911841757630554,29.589813736,29.950619048,314312,60320,0.0020670631698448405,0.008716501938180767,true,true,true,true
diff --git a/validation/surface_tension_3d/contact_angle_perturbation_r4_wetted_area.csv b/validation/surface_tension_3d/contact_angle_perturbation_r4_wetted_area.csv
new file mode 100644
index 0000000000..0a10e87bd4
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_perturbation_r4_wetted_area.csv
@@ -0,0 +1,9 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,shape_acceleration,boundary_shape_acceleration,wetting_energy,explicit_reaction_residual,density_resultant_residual,total_momentum_residual,max_explicit_reaction_residual,max_density_resultant_residual,max_total_momentum_residual,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,control_error_reduction,contact_shape_acceleration,motion_toward_target,beats_control,acceleration_toward_target,reaction_pass,response_pass
+perturbation,60.0,90.0,none,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.84925381011382,27.78824383296441,27.849253810113822,-0.0021955319492712455,90.58346657385819,NaN,0.006340809670386159,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6193760856231,1000.0049163863989,0.0018838122721795763,true,322,49,0.1320754716981132,9.910466084271638e-6,1.2230126506308182e-5,1.2897212756242402,2.573908880343451,1.0092894789365192,32.660901142,42.054388804,253816,0,-0.0021955319492712455,0.0,false,true,true,true,true
+perturbation,60.0,90.0,r4_wetted_area,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.71972689463574,27.78824383296441,27.71972689463574,0.002465680765597389,90.56558238363814,2.1086777582784864,-0.010371017361523979,0.0,-7.173293435382179e-6,2.0275155016978082e-16,1.693453953003158e-16,1.7262632178505492e-16,1.3256365746678748e-15,2.4093906642005805e-16,1.0851829009180663e-15,0,999.5309253249076,1000.004379956467,0.0010025461995570395,true,319,27,0.07803468208092486,9.096015862929228e-6,1.2230126506308182e-5,1.4625793059752226,2.583660412663913,1.014168744820888,34.227027412,38.421150678,290056,288808,-0.0021955319492712455,-0.01671182703191014,true,true,true,true,true
+perturbation,90.0,60.0,none,1500,1480,0.1,4000.0,0.01,1,59.240154735427446,59.3735407785842,-30.759845264572554,-30.6264592214158,0.004336369120503392,65.69601879326311,NaN,0.025617239827415973,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,996.8675352730065,1000.004807022048,0.0022958181222382827,true,313,19,0.0572289156626506,9.807417560523462e-6,1.2230126506308182e-5,1.3302637799600283,2.650343000694687,1.0170239636400782,25.204447592,25.786150082,249112,0,0.004336369120503392,0.0,true,true,true,true,true
+perturbation,90.0,60.0,r4_wetted_area,1500,1480,0.1,4000.0,0.01,1,59.240154735427446,59.3735407785842,-30.759845264572554,-30.6264592214158,0.004336369120503392,65.69601879326311,0.0,0.025617239827415973,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,996.8675352730065,1000.004807022048,0.0022958181222382827,true,313,19,0.0572289156626506,9.807417560523462e-6,1.2230126506308182e-5,1.3302637799600283,2.650343000694687,1.0170239636400782,25.282159346,25.842964721,284680,287240,0.004336369120503392,0.0,true,false,false,true,false
+perturbation,90.0,120.0,none,1500,1492,0.1,4000.0,0.01,1,118.05922431859437,118.23063409804432,28.059224318594374,28.23063409804432,-0.006108856663452178,116.74771535675464,NaN,-0.003958004005173301,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.1479105612143,1000.0048396236862,0.0015339312990104831,true,326,39,0.10684931506849316,1.0256374649071308e-5,1.2230126506308182e-5,1.2967092824392825,2.5427816883602588,1.0202534894334687,28.896840855,29.261307179,251128,0,-0.006108856663452178,0.0,false,true,true,true,true
+perturbation,90.0,120.0,r4_wetted_area,1500,1492,0.1,4000.0,0.01,1,118.05922431859437,118.23063409804432,28.059224318594374,28.23063409804432,-0.006108856663452178,116.74771535675464,126.03477445555572,-0.003958004005173301,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.1479105612143,1000.0048396236862,0.0015339312990104831,true,326,39,0.10684931506849316,1.0256374649071308e-5,1.2230126506308182e-5,1.2967092824392825,2.5427816883602588,1.0202534894334687,28.64008555,29.001185651,286984,208880,-0.006108856663452178,0.0,false,false,false,true,false
+perturbation,120.0,90.0,none,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.85482764022284,-32.21175616703559,-32.14517235977716,0.0020670654190089177,90.58348914564176,NaN,0.006322566937281028,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348037418,1000.0049605366512,0.0018842586472753206,true,323,44,0.11989100817438691,9.870034116213684e-6,1.2230126506308182e-5,1.2942784041081006,2.5750478998283373,1.0499302911338562,28.858590344,29.218288304,253816,0,0.0020670654190089177,0.0,true,true,true,true,true
+perturbation,120.0,90.0,r4_wetted_area,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.90581179986938,-32.21175616703559,-32.09418820013062,0.003649846543458124,90.63503349654061,34.871047227546974,0.02209847804898474,0.0,6.424121233963377e-6,7.50773883273499e-16,6.996783813673347e-17,6.131494403504238e-16,1.6858713941020476e-15,2.215992624257086e-16,1.3737688509865223e-15,0,997.0655677295546,1000.0099996378543,0.0050888073998904566,false,329,46,0.12266666666666666,8.115354089407412e-6,1.2230126506308182e-5,1.0074046150067233,2.5594893703979356,1.0753984604884899,35.088285769,35.464813341,290056,209776,0.0020670654190089177,0.015775911111703712,true,true,true,true,true
diff --git a/validation/surface_tension_3d/contact_angle_perturbation_r4_wetted_area_classified.csv b/validation/surface_tension_3d/contact_angle_perturbation_r4_wetted_area_classified.csv
new file mode 100644
index 0000000000..b30d6fbdb1
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_perturbation_r4_wetted_area_classified.csv
@@ -0,0 +1,9 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,shape_acceleration,boundary_shape_acceleration,wetting_energy,explicit_reaction_residual,density_resultant_residual,total_momentum_residual,max_explicit_reaction_residual,max_density_resultant_residual,max_total_momentum_residual,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,control_error_reduction,contact_shape_acceleration,motion_toward_target,beats_control,acceleration_toward_target,reaction_pass,response_pass,control_equivalent,control_comparison_pass,effective_acceleration_toward_target,formulation_response_pass
+perturbation,60.0,90.0,none,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.84925381011382,27.78824383296441,27.849253810113822,-0.0021955319492712455,90.58346657385819,NaN,0.006340809670386159,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6193760856231,1000.0049163863989,0.0018838122721795763,true,322,49,0.1320754716981132,9.910466084271638e-6,1.2230126506308182e-5,1.2897212756242402,2.573908880343451,1.0092894789365192,32.660901142,42.054388804,253816,0,-0.0021955319492712455,0.0,false,true,true,true,true,true,true,true,true
+perturbation,60.0,90.0,r4_wetted_area,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.71972689463574,27.78824383296441,27.71972689463574,0.002465680765597389,90.56558238363814,2.1086777582784864,-0.010371017361523979,0.0,-7.173293435382179e-6,2.0275155016978082e-16,1.693453953003158e-16,1.7262632178505492e-16,1.3256365746678748e-15,2.4093906642005805e-16,1.0851829009180663e-15,0,999.5309253249076,1000.004379956467,0.0010025461995570395,true,319,27,0.07803468208092486,9.096015862929228e-6,1.2230126506308182e-5,1.4625793059752226,2.583660412663913,1.014168744820888,34.227027412,38.421150678,290056,288808,-0.0021955319492712455,-0.01671182703191014,true,true,true,true,true,false,true,true,true
+perturbation,90.0,60.0,none,1500,1480,0.1,4000.0,0.01,1,59.240154735427446,59.3735407785842,-30.759845264572554,-30.6264592214158,0.004336369120503392,65.69601879326311,NaN,0.025617239827415973,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,996.8675352730065,1000.004807022048,0.0022958181222382827,true,313,19,0.0572289156626506,9.807417560523462e-6,1.2230126506308182e-5,1.3302637799600283,2.650343000694687,1.0170239636400782,25.204447592,25.786150082,249112,0,0.004336369120503392,0.0,true,true,true,true,true,true,true,true,true
+perturbation,90.0,60.0,r4_wetted_area,1500,1480,0.1,4000.0,0.01,1,59.240154735427446,59.3735407785842,-30.759845264572554,-30.6264592214158,0.004336369120503392,65.69601879326311,0.0,0.025617239827415973,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,996.8675352730065,1000.004807022048,0.0022958181222382827,true,313,19,0.0572289156626506,9.807417560523462e-6,1.2230126506308182e-5,1.3302637799600283,2.650343000694687,1.0170239636400782,25.282159346,25.842964721,284680,287240,0.004336369120503392,0.0,true,false,false,true,false,true,true,true,true
+perturbation,90.0,120.0,none,1500,1492,0.1,4000.0,0.01,1,118.05922431859437,118.23063409804432,28.059224318594374,28.23063409804432,-0.006108856663452178,116.74771535675464,NaN,-0.003958004005173301,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.1479105612143,1000.0048396236862,0.0015339312990104831,true,326,39,0.10684931506849316,1.0256374649071308e-5,1.2230126506308182e-5,1.2967092824392825,2.5427816883602588,1.0202534894334687,28.896840855,29.261307179,251128,0,-0.006108856663452178,0.0,false,true,true,true,true,true,true,true,true
+perturbation,90.0,120.0,r4_wetted_area,1500,1492,0.1,4000.0,0.01,1,118.05922431859437,118.23063409804432,28.059224318594374,28.23063409804432,-0.006108856663452178,116.74771535675464,126.03477445555572,-0.003958004005173301,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.1479105612143,1000.0048396236862,0.0015339312990104831,true,326,39,0.10684931506849316,1.0256374649071308e-5,1.2230126506308182e-5,1.2967092824392825,2.5427816883602588,1.0202534894334687,28.64008555,29.001185651,286984,208880,-0.006108856663452178,0.0,false,false,false,true,false,true,true,true,false
+perturbation,120.0,90.0,none,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.85482764022284,-32.21175616703559,-32.14517235977716,0.0020670654190089177,90.58348914564176,NaN,0.006322566937281028,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348037418,1000.0049605366512,0.0018842586472753206,true,323,44,0.11989100817438691,9.870034116213684e-6,1.2230126506308182e-5,1.2942784041081006,2.5750478998283373,1.0499302911338562,28.858590344,29.218288304,253816,0,0.0020670654190089177,0.0,true,true,true,true,true,true,true,true,true
+perturbation,120.0,90.0,r4_wetted_area,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.90581179986938,-32.21175616703559,-32.09418820013062,0.003649846543458124,90.63503349654061,34.871047227546974,0.02209847804898474,0.0,6.424121233963377e-6,7.50773883273499e-16,6.996783813673347e-17,6.131494403504238e-16,1.6858713941020476e-15,2.215992624257086e-16,1.3737688509865223e-15,0,997.0655677295546,1000.0099996378543,0.0050888073998904566,false,329,46,0.12266666666666666,8.115354089407412e-6,1.2230126506308182e-5,1.0074046150067233,2.5594893703979356,1.0753984604884899,35.088285769,35.464813341,290056,209776,0.0020670654190089177,0.015775911111703712,true,true,true,true,true,false,true,true,true
diff --git a/validation/surface_tension_3d/contact_angle_perturbation_r4_wetted_area_extended.csv b/validation/surface_tension_3d/contact_angle_perturbation_r4_wetted_area_extended.csv
new file mode 100644
index 0000000000..669c920362
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_perturbation_r4_wetted_area_extended.csv
@@ -0,0 +1,9 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,shape_acceleration,boundary_shape_acceleration,wetting_energy,explicit_reaction_residual,density_resultant_residual,total_momentum_residual,max_explicit_reaction_residual,max_density_resultant_residual,max_total_momentum_residual,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,control_error_reduction,contact_shape_acceleration,motion_toward_target,beats_control,acceleration_toward_target,reaction_pass,response_pass,control_equivalent,control_comparison_pass,effective_acceleration_toward_target,formulation_response_pass
+perturbation,60.0,90.0,none,1500,1508,0.1,4000.0,0.02,1,87.78824383296441,87.87389143722737,27.78824383296441,27.87389143722737,-0.003082152466265642,90.70260511135218,NaN,0.006340809670386159,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.1043111355403,1000.0060895344566,0.0018521905772590215,true,635,96,0.13132694938440492,9.910466084271638e-6,1.2230126506308182e-5,1.4578258347210338,2.579763226462909,1.015577901188074,61.473586614,70.740535489,253816,0,-0.003082152466265642,0.0,false,true,true,true,true,true,true,true,true
+perturbation,60.0,90.0,r4_wetted_area,1500,1508,0.1,4000.0,0.02,1,87.78824383296441,87.6440930134896,27.78824383296441,27.644093013489595,0.005187474974716277,90.66746662335404,5.47810156381381,-0.010371017361523979,0.0,-7.173293435382179e-6,2.0275155016978082e-16,1.693453953003158e-16,1.7262632178505492e-16,1.5068682798021932e-15,2.4093906642005805e-16,1.2368808498439228e-15,0,999.5544767788048,1000.0045182589826,0.0009595743352215127,true,631,79,0.11126760563380282,9.096015862929228e-6,1.2230126506308182e-5,1.7068829932779725,2.5969110896929797,1.024867318062658,68.803701464,72.899580974,290056,288808,-0.003082152466265642,-0.01671182703191014,true,true,true,true,true,false,true,true,true
+perturbation,90.0,60.0,none,1500,1480,0.1,4000.0,0.02,1,59.240154735427446,59.537280950455674,-30.759845264572554,-30.462719049544326,0.00965954842986294,65.86743658607124,NaN,0.025617239827415973,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,997.9541266031109,1000.0049971986302,0.00229718999101434,true,619,74,0.10678210678210678,9.807417560523462e-6,1.2230126506308182e-5,1.4590403906658052,2.6503206536022206,1.0030020586168604,52.217219523,52.802058103,249112,0,0.00965954842986294,0.0,true,true,true,true,true,true,true,true,true
+perturbation,90.0,60.0,r4_wetted_area,1500,1480,0.1,4000.0,0.02,1,59.240154735427446,59.537280950455674,-30.759845264572554,-30.462719049544326,0.00965954842986294,65.86743658607124,0.0,0.025617239827415973,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,997.9541266031109,1000.0049971986302,0.00229718999101434,true,619,74,0.10678210678210678,9.807417560523462e-6,1.2230126506308182e-5,1.4590403906658052,2.6503206536022206,1.0030020586168604,53.166977544,53.736102349,284680,287240,0.00965954842986294,0.0,true,false,false,true,false,true,true,true,true
+perturbation,90.0,120.0,none,1500,1492,0.1,4000.0,0.02,1,118.05922431859437,117.57280698891807,28.059224318594374,27.572806988918074,0.017335380484982288,116.82078355881362,NaN,-0.003958004005173301,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.3396226490969,1000.0047960675629,0.0015109912500881103,true,643,152,0.19119496855345913,1.0256374649071308e-5,1.2230126506308182e-5,1.538661334566212,2.5176816584476653,0.9859451004738031,62.210702923,62.569972583,251128,0,0.017335380484982288,0.0,true,true,true,true,true,true,true,true,true
+perturbation,90.0,120.0,r4_wetted_area,1500,1492,0.1,4000.0,0.02,1,118.05922431859437,117.57280698891807,28.059224318594374,27.572806988918074,0.017335380484982288,116.82078355881362,162.28433270345238,-0.003958004005173301,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.3396226490969,1000.0047960675629,0.0015109912500881103,true,643,152,0.19119496855345913,1.0256374649071308e-5,1.2230126506308182e-5,1.538661334566212,2.5176816584476653,0.9859451004738031,63.508003524,63.885618705,286984,208880,0.017335380484982288,0.0,true,false,false,true,false,true,true,true,true
+perturbation,120.0,90.0,none,1500,1508,0.1,4000.0,0.02,1,87.78824383296441,87.87284434486581,-32.21175616703559,-32.12715565513419,0.0026263862008236716,90.70254626914286,NaN,0.006322566937281028,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.1060025039586,1000.0061052288613,0.001853128340923646,true,636,82,0.11420612813370473,5.245235463899323e-6,1.2230126506308182e-5,1.4553097344156112,2.5797180968903626,1.0044564650482684,56.598732178,56.969822945,253816,0,0.0026263862008236716,0.0,true,true,true,true,true,true,true,true,true
+perturbation,120.0,90.0,r4_wetted_area,1500,1508,0.1,4000.0,0.02,1,87.78824383296441,88.3572780082805,-32.21175616703559,-31.642721991719498,0.017665419183149678,90.84105938130212,80.44274865451993,0.02209847804898474,0.0,6.424121233963377e-6,7.50773883273499e-16,6.996783813673347e-17,6.131494403504238e-16,1.6858713941020476e-15,2.6312282136811324e-16,1.3737688509865223e-15,0,999.378361295837,1000.0097232180361,0.004728219055095291,true,642,106,0.14171122994652408,8.115354089407412e-6,1.2230126506308182e-5,1.0611573651472068,2.5693117312413323,1.0225299319672485,70.133796328,70.509381116,290056,209776,0.0026263862008236716,0.015775911111703712,true,true,true,true,true,false,true,true,true
diff --git a/validation/surface_tension_3d/contact_angle_perturbation_r4_wetted_area_extended_production.csv b/validation/surface_tension_3d/contact_angle_perturbation_r4_wetted_area_extended_production.csv
new file mode 100644
index 0000000000..4c1f009400
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_perturbation_r4_wetted_area_extended_production.csv
@@ -0,0 +1,9 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,shape_acceleration,boundary_shape_acceleration,wetting_energy,explicit_reaction_residual,density_resultant_residual,total_momentum_residual,max_explicit_reaction_residual,max_density_resultant_residual,max_total_momentum_residual,force_consistency_residual,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,control_error_reduction,contact_shape_acceleration,motion_toward_target,beats_control,acceleration_toward_target,reaction_pass,response_pass,control_equivalent,control_comparison_pass,effective_acceleration_toward_target,formulation_response_pass
+perturbation,60.0,90.0,none,1500,1508,0.1,4000.0,0.02,1,87.78824383296441,87.87389052282384,27.78824383296441,27.873890522823842,-0.003082119560136709,90.70260508250446,NaN,0.006340809670386161,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.1043171100883,1000.0060914956294,0.001852187521187558,true,635,93,0.12774725274725274,9.910466084271641e-6,1.2230126506308182e-5,1.4578258347210353,2.577970671716059,1.0116298105550605,61.736026731,70.888271877,253816,0,-0.003082119560136709,0.0,false,true,true,true,true,true,true,true,true
+perturbation,60.0,90.0,wetted_area_production,1500,1508,0.1,4000.0,0.02,1,87.78824383296441,87.6440921239775,27.78824383296441,27.6440921239775,0.005187506985090851,90.66746684727092,NaN,-0.010370993758861134,0.0,-7.173300590626698e-6,1.497201908153429e-15,2.5668917460110715e-16,1.355307969834118e-15,1.497201908153429e-15,2.5668917460110715e-16,1.355307969834118e-15,4.979444989760144e-18,0,999.554474811373,1000.0045040626536,0.0009595715348416999,true,631,89,0.12361111111111112,9.096019536030538e-6,1.2230126506308182e-5,1.7068630971340515,2.5932048866007267,1.0146196710402007,63.417884123,69.062722277,266008,317072,-0.003082119560136709,-0.016711803429247295,true,true,true,true,true,false,true,true,true
+perturbation,90.0,60.0,none,1500,1480,0.1,4000.0,0.02,1,59.240154735427446,59.53728094610998,-30.759845264572554,-30.46271905389002,0.00965954828858484,65.86743658675732,NaN,0.025617239827415966,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,997.9541267732801,1000.0049971261573,0.002297189972622963,true,619,75,0.10806916426512968,9.807417560523447e-6,1.2230126506308182e-5,1.459040390665805,2.650596364356776,1.0049683856107094,54.706519727,55.467717924,249112,0,0.00965954828858484,0.0,true,true,true,true,true,true,true,true,true
+perturbation,90.0,60.0,wetted_area_production,1500,1480,0.1,4000.0,0.02,1,59.240154735427446,59.53728094610998,-30.759845264572554,-30.46271905389002,0.00965954828858484,65.86743658675732,NaN,0.025617239827415966,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,997.9541267732801,1000.0049971261573,0.002297189972622963,true,619,75,0.10806916426512968,9.807417560523447e-6,1.2230126506308182e-5,1.459040390665805,2.650596364356776,1.0049683856107094,56.347433885,57.018120392,261080,316848,0.00965954828858484,0.0,true,false,false,true,false,true,true,true,true
+perturbation,90.0,120.0,none,1500,1492,0.1,4000.0,0.02,1,118.05922431859437,117.5728069912649,28.059224318594374,27.572806991264898,0.01733538040134408,116.8207835594829,NaN,-0.003958004005173301,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.3396226180992,1000.0047960233273,0.0015109912511698637,true,643,152,0.19119496855345913,1.0256374649071301e-5,1.2230126506308182e-5,1.5386613345662106,2.5174061659102716,0.9859451111837068,63.110456411,63.486938998,251128,0,0.01733538040134408,0.0,true,true,true,true,true,true,true,true,true
+perturbation,90.0,120.0,wetted_area_production,1500,1492,0.1,4000.0,0.02,1,118.05922431859437,117.5728069912649,28.059224318594374,27.572806991264898,0.01733538040134408,116.8207835594829,NaN,-0.003958004005173301,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.3396226180992,1000.0047960233273,0.0015109912511698637,true,643,152,0.19119496855345913,1.0256374649071301e-5,1.2230126506308182e-5,1.5386613345662106,2.5174061659102716,0.9859451111837068,64.01391613,64.479236102,263192,198864,0.01733538040134408,0.0,true,false,false,true,false,true,true,true,true
+perturbation,120.0,90.0,none,1500,1508,0.1,4000.0,0.02,1,87.78824383296441,87.87284708080529,-32.21175616703559,-32.127152919194714,0.0026264711368780835,90.7025462967903,NaN,0.006322566937281027,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.105998001131,1000.006109953255,0.0018531346656836588,true,636,90,0.12396694214876033,9.22597340819939e-6,1.2230126506308182e-5,1.4553097344156112,2.581201702441338,1.0180808957598102,57.259312751,57.625631844,253816,0,0.0026264711368780835,0.0,true,true,true,true,true,true,true,true,true
+perturbation,120.0,90.0,wetted_area_production,1500,1508,0.1,4000.0,0.02,1,87.78824383296441,88.35727623575492,-32.21175616703559,-31.642723764245076,0.01766536415586184,90.84105685200346,NaN,0.022098455726507323,0.0,6.424128006174769e-6,6.595627072155773e-16,1.1866080936244986e-16,1.5317037359913808e-15,1.0274198036927102e-15,1.1896284532708835e-16,1.5317037359913808e-15,3.356325930898541e-18,0,999.3783680084106,1000.0097261598077,0.004728210451596065,true,643,97,0.13108108108108107,8.115362662739728e-6,1.2230126506308182e-5,1.0617051836947446,2.5912397605258612,1.0299642273012526,59.91203983,60.371161771,266008,198992,0.0026264711368780835,0.015775888789226297,true,true,true,true,true,false,true,true,true
diff --git a/validation/surface_tension_3d/contact_angle_perturbation_r4_wetted_area_production.csv b/validation/surface_tension_3d/contact_angle_perturbation_r4_wetted_area_production.csv
new file mode 100644
index 0000000000..756fb43c2e
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_perturbation_r4_wetted_area_production.csv
@@ -0,0 +1,9 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,shape_acceleration,boundary_shape_acceleration,wetting_energy,explicit_reaction_residual,density_resultant_residual,total_momentum_residual,max_explicit_reaction_residual,max_density_resultant_residual,max_total_momentum_residual,force_consistency_residual,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,control_error_reduction,contact_shape_acceleration,motion_toward_target,beats_control,acceleration_toward_target,reaction_pass,response_pass,control_equivalent,control_comparison_pass,effective_acceleration_toward_target,formulation_response_pass
+perturbation,60.0,90.0,none,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.84925381038731,27.78824383296441,27.84925381038731,-0.0021955319591129285,90.58346657395815,NaN,0.006340809670386161,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.619376085094,1000.0049164157444,0.0018838122900157257,true,322,49,0.1320754716981132,9.910466084271641e-6,1.2230126506308182e-5,1.289721275624241,2.5738880850476336,1.0093021304557666,33.597101919,42.351637951,253816,0,-0.0021955319591129285,0.0,false,true,true,true,true,true,true,true,true
+perturbation,60.0,90.0,wetted_area_production,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.71972654810989,27.78824383296441,27.71972654810989,0.002465693235829569,90.56558258209398,NaN,-0.010370993758861134,0.0,-7.173300590626698e-6,1.4969409502413716e-15,2.253596334492519e-16,1.35505315181755e-15,1.4969409502413716e-15,2.253596334492519e-16,1.35505315181755e-15,2.8373172833782133e-18,0,999.5309267263258,1000.0043694088156,0.0010025423573258865,true,319,25,0.07267441860465117,9.096019536030538e-6,1.2230126506308182e-5,1.4626035636270474,2.5822322949541765,1.0124276163969572,30.205176491,35.554552756,266008,317072,-0.0021955319591129285,-0.016711803429247295,true,true,true,true,true,false,true,true,true
+perturbation,90.0,60.0,none,1500,1480,0.1,4000.0,0.01,1,59.240154735427446,59.373540778584136,-30.759845264572554,-30.626459221415864,0.004336369120501282,65.69601879326895,NaN,0.025617239827415966,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,996.8675352730007,1000.0048070220627,0.0022958181220983013,true,313,19,0.0572289156626506,9.807417560523447e-6,1.2230126506308182e-5,1.330263779960027,2.6503430149974263,1.0170264902156605,25.286069959,25.859934757,249112,0,0.004336369120501282,0.0,true,true,true,true,true,true,true,true,true
+perturbation,90.0,60.0,wetted_area_production,1500,1480,0.1,4000.0,0.01,1,59.240154735427446,59.373540778584136,-30.759845264572554,-30.626459221415864,0.004336369120501282,65.69601879326895,NaN,0.025617239827415966,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,996.8675352730007,1000.0048070220627,0.0022958181220983013,true,313,19,0.0572289156626506,9.807417560523447e-6,1.2230126506308182e-5,1.330263779960027,2.6503430149974263,1.0170264902156605,25.676379152,26.344777494,261080,316848,0.004336369120501282,0.0,true,false,false,true,false,true,true,true,true
+perturbation,90.0,120.0,none,1500,1492,0.1,4000.0,0.01,1,118.05922431859437,118.23063409797012,28.059224318594374,28.230634097970125,-0.006108856660808071,116.74771535719243,NaN,-0.003958004005173301,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.1479105607743,1000.0048396489527,0.0015339312991750804,true,326,39,0.10684931506849316,1.0256374649071301e-5,1.2230126506308182e-5,1.2967092824392834,2.5427816464823323,1.0197740114450313,28.691407353,29.04951344,251128,0,-0.006108856660808071,0.0,false,true,true,true,true,true,true,true,true
+perturbation,90.0,120.0,wetted_area_production,1500,1492,0.1,4000.0,0.01,1,118.05922431859437,118.23063409797012,28.059224318594374,28.230634097970125,-0.006108856660808071,116.74771535719243,NaN,-0.003958004005173301,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.1479105607743,1000.0048396489527,0.0015339312991750804,true,326,39,0.10684931506849316,1.0256374649071301e-5,1.2230126506308182e-5,1.2967092824392834,2.5427816464823323,1.0197740114450313,29.428411731,29.88850077,263192,198864,-0.006108856660808071,0.0,false,false,false,true,false,true,true,true,false
+perturbation,120.0,90.0,none,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.8548276025082,-32.21175616703559,-32.145172397491805,0.0020670642481741552,90.58348913131027,NaN,0.006322566937281027,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348038162945,1000.0049560481513,0.0018842555157376594,true,323,45,0.12228260869565218,9.870034116213688e-6,1.2230126506308182e-5,1.2942784041080995,2.5682567242380516,1.0455498620055843,29.023824675,29.378905996,253816,0,0.0020670642481741552,0.0,true,true,true,true,true,true,true,true,true
+perturbation,120.0,90.0,wetted_area_production,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.90580555824066,-32.21175616703559,-32.09419444175934,0.0036496527748013374,90.6350326332222,NaN,0.022098455726507323,0.0,6.424128006174769e-6,6.597056626287263e-16,8.481227207207922e-17,1.531387795946587e-15,6.597056626287263e-16,1.2963502423649755e-16,1.531387795946587e-15,1.725625138221086e-18,0,997.0655999151976,1000.0099979427348,0.005088801167397451,false,329,44,0.11796246648793565,8.115362662739728e-6,1.2230126506308182e-5,1.0074052407600997,2.5823425644556823,1.0944497295159235,30.356091203,30.816429172,266008,198992,0.0020670642481741552,0.015775888789226297,true,true,true,true,true,false,true,true,true
diff --git a/validation/surface_tension_3d/contact_angle_recovery_comparison.csv b/validation/surface_tension_3d/contact_angle_recovery_comparison.csv
new file mode 100644
index 0000000000..19b2e8db1c
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_recovery_comparison.csv
@@ -0,0 +1,8 @@
+method,role,measure,middle_passes,endpoint_passes,measure_gate,sign_cases,sign_passes,zero_at_90,eligible,max_middle_error
+R6-D production discrete,attribution,contact_line,0,2,false,0,0,,false,0.3431989214447275
+R6-D analytic wall,attribution,contact_line,3,2,false,0,0,,false,0.2615636471832792
+R6-D analytic interface,attribution,contact_line,4,1,false,0,0,,false,0.29995781887908257
+R6-D analytic both,attribution,contact_line,5,1,false,0,0,,false,0.1605553922807449
+R6-C compatible indicator,candidate,contact_line,5,0,false,0,0,,false,0.08848688865990983
+support-moment diagnostic,attribution,contact_line,3,1,false,0,0,,false,0.24141536871616154
+R6-W wetted-area energy,candidate,wetted_area,4,4,false,4,4,true,false,0.5062361787397287
diff --git a/validation/surface_tension_3d/contact_angle_recovery_extended.csv b/validation/surface_tension_3d/contact_angle_recovery_extended.csv
new file mode 100644
index 0000000000..4ec0e8efd8
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_recovery_extended.csv
@@ -0,0 +1,61 @@
+variant,target,requested_particles,particle_count_min,particle_count_max,phase_count,coarea_normalization,line_length_phases,line_length,analytic_line_length,signed_error,line_length_error,phase_standard_error,angle_phases,angle,angle_error,angle_standard_error,valid_particles,ghost_fraction_min,ghost_fraction_max,middle_pass,endpoint_pass,angle_middle_pass,angle_endpoint_pass,protocol_valid,measure_eligible,static_eligible
+analytic_both_control,30.0,750,743,758,8,3.999999999999993,0.064805994409237691;0.065091016265079807;0.06421451450084667;0.065091016265079876;0.065776556645096643;0.065455601456679988;0.064214514500846684;0.065455601456679904,0.0650131019374434,0.08318250925018945,-0.21842821858255823,0.21842821858255823,0.0024389831896923517,24.142383808581489;24.120212239708632;24.068921468924458;24.120212239708636;24.12341243370275;24.156378363700956;24.068921468924433;24.156378363700966,24.119817463932783,5.880182536067217,0.01223016565138383,749,NaN,NaN,true,true,true,true,true,true,true
+analytic_both_control,60.0,750,742,768,8,3.999999999999993,0.054798122840678039;0.054883783063536015;0.054258984502427665;0.054883783063536043;0.058420277055939142;0.055680584186634241;0.054258984502427637;0.055680584186634172,0.055358137925226626,0.06267210593303608,-0.11670212607223207,0.11670212607223207,0.007617888005368574,53.608715604694837;53.63841933970852;53.570671990604296;53.638419339708463;53.740146566486551;53.682634977193551;53.570671990604318;53.682634977193615,53.64268096284917,6.35731903715083,0.020806213328434665,476,NaN,NaN,true,true,true,true,true,true,true
+analytic_both_control,90.0,750,749,756,8,3.999999999999993,0.042304776900952842;0.045393159968814288;0.046390848239538464;0.045393159968814281;0.045244656678456625;0.045758558093939597;0.046390848239538443;0.045758558093939597,0.04532932077299926,0.049108914031371555,-0.07696348683169474,0.07696348683169474,0.009341508012397047,84.451951746259823;84.361927315595054;84.400436230817405;84.361927315595054;84.382823721257395;84.497249234787162;84.400436230817419;84.497249234787148,84.41904020656469,5.580959793435312,0.019750106402333722,301,NaN,NaN,true,true,true,true,true,true,true
+analytic_both_control,120.0,750,747,758,8,3.999999999999993,0.035485334696227513;0.032795928062704496;0.033904480148102996;0.032795928062704489;0.029659409321183047;0.034843873530704096;0.033904480148103024;0.034843873530704082,0.033529163437554216,0.035722597891021876,-0.06140187396670094,0.06140187396670094,0.018178653984927382,115.78220443099875;115.77407064125826;115.707058842789;115.77407064125822;115.70511649151005;115.68544004615509;115.70705884278887;115.68544004615514,115.72755525473902,4.272444745260984,0.014762996865178702,240,NaN,NaN,true,true,true,true,true,true,true
+analytic_both_control,150.0,750,740,757,8,3.999999999999993,0.017686929572317857;0.017831631314553741;0.018074784738045403;0.017831631314553762;0.017362974983958099;0.018701656000033481;0.018074784738045406;0.018701656000033481,0.018033256082692652,0.01957315597740063,-0.07867407261690262,0.07867407261690262,0.008492959690489795,144.31443919899868;144.63643066549628;144.43914787465641;144.63643066549616;144.50702877419917;144.36410177959695;144.43914787465647;144.36410177959692,144.46134073500687,5.538659264993129,0.0432731239943652,180,NaN,NaN,true,true,true,true,true,true,true
+analytic_both_control,30.0,1500,1496,1512,8,3.999999999999993,0.07127428300891625;0.067456481018583703;0.068656213173252587;0.06745648101858373;0.069069142308051196;0.070123777621592068;0.068656213173252489;0.070123777621592193,0.06910204611797803,0.08318250925018945,-0.16927192097393184,0.16927192097393184,0.005707141029543194,25.433017665638047;25.29851132511957;25.346106303862609;25.298511325119573;25.355503251757437;25.405529499644608;25.346106303862605;25.40552949964458,25.361945853832616,4.638054146167384,0.017645184169264975,1266,NaN,NaN,true,true,true,true,true,true,true
+analytic_both_control,60.0,1500,1486,1507,8,3.999999999999993,0.057613884219493441;0.056888547705053201;0.055840769745301416;0.056888547705053284;0.05692453962385409;0.057901150758304321;0.055840769745301347;0.057901150758304258,0.05697492003258317,0.06267210593303608,-0.09090465073154308,0.09090465073154308,0.004629484888605034,55.038814592103094;55.058254739826552;54.984274277384834;55.058254739826374;55.096574786304103;55.083491037931793;54.984274277384905;55.083491037931779,55.048867929917506,4.951132070082494,0.015407496887556422,623,NaN,NaN,true,true,true,true,true,true,true
+analytic_both_control,90.0,1500,1487,1516,8,3.999999999999993,0.040493454053258431;0.046593649496690079;0.046565223399209918;0.046593649496690093;0.049217721295182391;0.044903512956041308;0.046565223399209904;0.044903512956041294,0.04572949338154043,0.049108914031371555,-0.06881481125142164,0.06881481125142164,0.01801667885994166,85.549149571109467;85.675684373466311;85.675827298651825;85.675684373466297;85.612236587439227;85.635371224328125;85.675827298651868;85.635371224328139,85.64328274619807,4.356717253801932,0.015897383324520203,398,NaN,NaN,true,true,true,true,true,true,true
+analytic_both_control,120.0,1500,1496,1506,8,3.999999999999993,0.033195118732931059;0.03364039706593798;0.034221506823622856;0.03364039706593798;0.03685317746775308;0.033841874743087939;0.034221506823622835;0.033841874743087932,0.0341819816832477,0.035722597891021876,-0.04312721634843297,0.04312721634843297,0.011178809765346789,116.6821735281848;116.72405322015921;116.7403790184764;116.72405322015916;116.5935265395768;116.67520293738505;116.74037901847647;116.67520293738504,116.69337365111866,3.3066263488813377,0.017448268784483176,324,NaN,NaN,true,true,true,true,true,true,true
+analytic_both_control,150.0,1500,1492,1507,8,3.999999999999993,0.0192137835143718;0.017792776556798194;0.018695523709728542;0.017792776556798163;0.017690750841383458;0.018993415500783029;0.018695523709728545;0.018993415500783019,0.018483495736296842,0.01957315597740063,-0.055671157086875644,0.055671157086875644,0.011271974259776983,145.88769069119701;146.45822652513601;145.99580261028581;146.45822652513618;146.51676273239147;146.07030512373029;145.99580261028589;146.07030512373035,146.1745070492153,3.8254929507847066,0.08920037975015659,251,NaN,NaN,true,true,true,true,true,true,true
+analytic_both_control,30.0,3000,2999,3007,8,3.999999999999993,0.073857286154257476;0.072579386789040856;0.072757310113941903;0.07257938678904069;0.071253816740517173;0.073114353200932874;0.072757310113941973;0.073114353200932902,0.07275165038782572,0.08318250925018945,-0.1253972614722485,0.1253972614722485,0.0031268360526720343,26.404302546889198;26.367196183430686;26.379284498601265;26.367196183430757;26.342176138756439;26.389203628063857;26.379284498601269;26.389203628063839,26.3773934579007,3.6226065420993017,0.006625476254308281,1903,NaN,NaN,true,true,true,true,true,true,true
+analytic_both_control,60.0,3000,2983,3003,8,3.999999999999993,0.05606169368450558;0.059091387093059347;0.058494335365918339;0.059091387093059319;0.05646622569256167;0.058585774176446469;0.058494335365918346;0.058585774176446441,0.058108864080989434,0.06267210593303608,-0.0728113693342679,0.0728113693342679,0.006596772797180268,56.054335157161994;56.151462897714424;56.101086492928331;56.15146289771446;56.09438677346219;56.10317035258236;56.10108649292831;56.10317035258246,56.107966952233944,3.8920330477660556,0.011167302844230943,802,NaN,NaN,true,true,true,true,true,true,true
+analytic_both_control,90.0,3000,2987,3005,8,3.999999999999993,0.047597704910925222;0.046654539362890712;0.045492412883151277;0.046654539362890671;0.043426033643711628;0.046484716831095306;0.045492412883151333;0.046484716831095278,0.04603588458861392,0.049108914031371555,-0.06257579715150152,0.06257579715150152,0.00902886828454569,86.592909387183184;86.595055830651887;86.561959542523397;86.595055830651901;86.516988885800956;86.547285064612169;86.56195954252324;86.547285064612169,86.56533775113792,3.4346622488620824,0.009943188585183496,521,NaN,NaN,true,true,true,true,true,true,true
+analytic_both_control,120.0,3000,2988,3013,8,3.999999999999993,0.032964220308225892;0.032942038867305463;0.034676085983817551;0.032942038867305505;0.032614057203740401;0.03516823299972302;0.034676085983817544;0.035168232999723048,0.033893874151707304,0.035722597891021876,-0.051192350144673604,0.051192350144673604,0.011088047930664492,117.56885338167784;117.43895415311192;117.40113013268311;117.43895415311179;117.37027382714162;117.4157882950868;117.40113013268316;117.41578829508676,117.43080186932319,2.56919813067681,0.0211558580958378,425,NaN,NaN,true,true,true,true,true,true,true
+analytic_both_control,150.0,3000,2995,3014,8,3.999999999999993,0.0198272021882249;0.018672851565196217;0.01845682301060253;0.01867285156519622;0.018515744418396294;0.019142347221270294;0.018456823010602495;0.019142347221270291,0.018860873775094905,0.01957315597740063,-0.0363907692314992,0.0363907692314992,0.008658969732644241,146.9752061853394;147.3678726153012;147.3135285766742;147.36787261530102;147.68942722844031;147.22940641766826;147.31352857667457;147.22940641766823,147.30730525739966,2.692694742600338,0.07017339128345806,351,NaN,NaN,true,true,true,true,true,true,true
+compatible_geometry_wall,30.0,750,743,758,8,3.999999999999993,0.080511218146709321;0.080513416090367163;0.080617079582384149;0.080513416090367079;0.080527496210493779;0.080622027253756987;0.080617079582384149;0.080622027253756917,0.08056797002627744,0.08318250925018945,-0.031431357955892136,0.031431357955892136,0.00023544059335276951,43.749313990537615;44.128255848391134;44.636115017163867;44.128255848391149;44.559733859392509;43.951528409853978;44.636115017163895;43.951528409854028,44.217659817817825,14.217659817817825,0.12278101443614886,749,NaN,NaN,true,false,false,false,true,false,false
+compatible_geometry_wall,60.0,750,742,768,8,3.999999999999993,0.063909313742209303;0.063877817323556865;0.063561869465251594;0.063877817323556824;0.064014250174534562;0.064330515967978313;0.063561869465251664;0.064330515967978286,0.06393299617878967,0.06267210593303608,0.020118842776734258,0.020118842776734258,0.0016581566971115135,73.985272937562726;74.581386435310478;75.88262581670368;74.581386435310378;76.282347123545989;74.321842875733864;75.882625816703623;74.321842875733893,74.97792226931064,14.977922269310639,0.3133683302101636,600,NaN,NaN,true,false,false,false,true,false,false
+compatible_geometry_wall,90.0,750,749,756,8,3.999999999999993,0.047272102403126903;0.047396745345136951;0.047795235107939593;0.047396745345137006;0.047387608508289825;0.04805547207987465;0.047795235107939621;0.048055472079874692,0.04764432699716491,0.049108914031371555,-0.029823242136265637,0.029823242136265637,0.002293936894880484,94.58128150481015;95.442757859409781;94.678498005191585;95.442757859409795;96.058452124202773;93.393780601388457;94.678498005191585;93.393780601388386,94.70412685369064,4.704126853690639,0.3374166171326724,448,NaN,NaN,true,true,false,false,true,true,false
+compatible_geometry_wall,120.0,750,747,758,8,3.999999999999993,0.033799296975025324;0.032705891394725503;0.03289109055151869;0.032705891394725496;0.030873340045257148;0.033265490017922034;0.032891090551518724;0.033265490017922034,0.03279969761857687,0.035722597891021876,-0.08182216426033262,0.08182216426033262,0.008512474072825913,107.36738755908435;109.09887070109853;109.39007975591765;109.09887070109855;113.06955185971448;108.60965015589935;109.39007975591744;108.60965015589937,109.29198727241386,10.708012727586137,0.582297786310843,308,NaN,NaN,true,true,false,false,true,true,false
+compatible_geometry_wall,150.0,750,740,757,8,3.999999999999993,0.018440786974269058;0.018892243205372611;0.018864172138073173;0.018892243205372618;0.018522776686539788;0.01916394505267939;0.018864172138073173;0.019163945052679376,0.018850535556632398,0.01957315597740063,-0.036918952753586476,0.036918952753586476,0.004708428634710033,123.36127367752465;121.47657888526966;122.10803080371434;121.47657888526966;122.93844098787099;121.62039649351024;122.10803080371427;121.62039649351021,122.08112975486806,27.91887024513194,0.2510780945564913,188,NaN,NaN,true,false,false,false,true,false,false
+compatible_geometry_wall,30.0,1500,1496,1512,8,3.999999999999993,0.085755003804472762;0.084792906065765339;0.085160745183513617;0.084792906065765367;0.085274065177112987;0.085366578119604089;0.085160745183513575;0.085366578119603992,0.08520869096491898,0.08318250925018945,0.024358266334997714,0.024358266334997714,0.0013468929820966413,45.74167183426966;45.834671619409235;45.606253983805523;45.834671619409143;45.562804752166038;44.927506159753626;45.606253983805765;44.927506159753626,45.504678807616436,15.504678807616436,0.13121849858889162,1396,NaN,NaN,true,false,false,false,true,false,false
+compatible_geometry_wall,60.0,1500,1486,1507,8,3.999999999999993,0.065225201591822449;0.065661736201045234;0.065030886340425267;0.065661736201045276;0.066132921464564648;0.0655231061184601;0.06503088634042524;0.065523106118460059,0.06547369754703104,0.06267210593303608,0.04470236913673209,0.04470236913673209,0.0020935838443370573,78.0845436877282;75.59898746760544;76.860041833888815;75.598987467605397;73.011706826285675;76.081701780634404;76.860041833888843;76.081701780634404,76.01573750404194,16.015737504041937,0.5190235875778679,1015,NaN,NaN,true,false,false,false,true,false,false
+compatible_geometry_wall,90.0,1500,1487,1516,8,3.999999999999993,0.046991461575337991;0.048300702284420481;0.048363724152753675;0.048300702284420516;0.047976665340111267;0.047952799112141377;0.048363724152753654;0.047952799112141335,0.04802532225176004,0.049108914031371555,-0.02206507313355166,0.02206507313355166,0.0032906891240826095,98.833824740729185;95.719612351420608;95.597408498237442;95.719612351420537;97.235188796296057;96.377073312483375;95.597408498237527;96.37707331248339,96.42311469908365,6.423114699083655,0.3970423309215833,727,NaN,NaN,true,true,false,false,true,true,false
+compatible_geometry_wall,120.0,1500,1496,1506,8,3.999999999999993,0.032068554822872582;0.03267613015240374;0.032909951075414945;0.032676130152403754;0.033315240028912306;0.032442674160876955;0.032909951075414959;0.032442674160876935,0.03268016320364702,0.035722597891021876,-0.08516834908413828,0.08516834908413828,0.0037329733274699592,111.97312356047682;110.30786113155209;109.71550998753203;110.30786113155212;109.36642641409834;111.2107952194918;109.71550998753195;111.21079521949191,110.46712110113472,9.532878898865278,0.3208696887704843,479,NaN,NaN,true,true,false,false,true,true,false
+compatible_geometry_wall,150.0,1500,1492,1507,8,3.999999999999993,0.018521586645918603;0.018131650118549864;0.018286266264342318;0.01813165011854987;0.018212014507006189;0.018531821665237116;0.018286266264342339;0.018531821665237109,0.018329134656147928,0.01957315597740063,-0.06355752351276733,0.06355752351276733,0.003162674906016133,123.54368704051625;123.00143734849503;123.82026944751564;123.00143734849499;122.7639011809501;122.87240665675544;123.8202694475155;122.87240665675536,123.21204282131329,26.78795717868671,0.15641707180641362,268,NaN,NaN,true,false,false,false,true,false,false
+compatible_geometry_wall,30.0,3000,2999,3007,8,3.999999999999993,0.088818697362424731;0.088559697366496506;0.088577648678767093;0.088559697366496368;0.088224699011186916;0.088826628922958062;0.088577648678767384;0.088826628922958159,0.0886214182887569,0.08318250925018945,0.06538524850469152,0.06538524850469152,0.0008680593665088224,46.440232256464427;46.805984394229569;46.281163619236722;46.805984394229711;46.003945145035949;46.389092053118354;46.281163619236672;46.389092053118361,46.424752678422585,16.424752678422585,0.09559705272935771,2476,NaN,NaN,true,false,false,false,true,false,false
+compatible_geometry_wall,60.0,3000,2983,3003,8,3.999999999999993,0.065848975581835736;0.066847563883842609;0.066197881643092232;0.066847563883842609;0.066652818038115763;0.066061925786946363;0.066197881643092274;0.066061925786946543,0.06633956703096426,0.06267210593303608,0.05851823619660701,0.05851823619660701,0.002184089751148396,78.815183329455579;76.326906343153183;77.998064001247343;76.326906343153055;75.908388438267892;78.424333809328544;77.998064001247343;78.424333809328061,77.52213852148243,17.522138521482432,0.40562964704737997,1688,NaN,NaN,true,false,false,false,true,false,false
+compatible_geometry_wall,90.0,3000,2987,3005,8,3.999999999999993,0.048477287068190114;0.048461990436296787;0.04800172923124027;0.048461990436296822;0.047444557912411499;0.048053940751140355;0.04800172923124027;0.04805394075114032,0.04811964572724456,0.049108914031371555,-0.020144373453158337,0.020144373453158337,0.0025153594566342708,97.399451659924864;97.58856598400196;98.667039325267069;97.588565984001846;99.942867235427215;98.724252722429696;98.667039325266856;98.724252722429895,98.40740163061551,8.40740163061551,0.30029702196631775,1170,NaN,NaN,true,true,false,false,true,true,false
+compatible_geometry_wall,120.0,3000,2988,3013,8,3.999999999999993,0.032483731908667955;0.031883859593076526;0.032458059635034342;0.031883859593076498;0.031602151173710402;0.032685266596296288;0.032458059635034335;0.032685266596296302,0.03226753184139908,0.035722597891021876,-0.09671933883876782,0.09671933883876782,0.004107187988880445,111.04801652334555;112.98385066817976;111.74042467441839;112.98385066817998;113.85427893028687;111.09251070827884;111.74042467441838;111.09251070827865,112.05516171245375,7.944838287546247,0.3785896110082569,739,NaN,NaN,true,true,false,false,true,true,false
+compatible_geometry_wall,150.0,3000,2995,3014,8,3.999999999999993,0.017957827521336338;0.017713148633126292;0.017654782966433899;0.017713148633126261;0.017903030934135148;0.017963515421564016;0.017654782966433913;0.017963515421564019,0.017815469062214984,0.01957315597740063,-0.0898008945116,0.0898008945116,0.0025938787981034917,125.25806821261126;124.62146502303723;124.90034480231819;124.62146502303759;122.49780968497404;124.08012820197273;124.90034480231839;124.08012820197266,124.36754694263522,25.632453057364785,0.30328009128932226,384,NaN,NaN,true,false,false,false,true,false,false
+compatible_indicator,30.0,750,743,758,8,3.999999999999993,0.081126642939297541;0.081236180337519004;0.08129676459031171;0.081236180337518879;0.081239698064465488;0.081275529303432043;0.081296764590311668;0.081275529303431945,0.08124791118328605,0.08318250925018945,-0.023257269879713327,0.023257269879713327,0.00023471858447951332,44.296389395463201;44.690965241972108;45.214478787764811;44.690965241972073;45.133075262722691;44.50548774959492;45.214478787764797;44.505487749594955,44.7815469907488,14.7815469907488,0.12695874459693374,640,NaN,NaN,true,false,false,false,true,false,false
+compatible_indicator,60.0,750,742,768,8,3.999999999999993,0.063811818997921488;0.063883475624212607;0.063490662885249141;0.063883475624212593;0.063920718031005255;0.064241424896330848;0.063490662885249127;0.064241424896330834,0.06387045798006398,0.06267210593303608,0.019120979408419947,0.019120979408419947,0.0016098250121777803,75.007226603597289;75.531499969012543;76.910516238263668;75.531499969012543;77.384494356382135;75.275853848944593;76.910516238263682;75.275853848944664,75.97625383864103,15.976253838641028,0.3285395693660941,444,NaN,NaN,true,false,false,false,true,false,false
+compatible_indicator,90.0,750,749,756,8,3.999999999999993,0.0469807707138057;0.047002016863095603;0.047400918203775955;0.047002016863095589;0.047015653809183523;0.047712903101382251;0.047400918203775975;0.047712903101382313,0.04727851260743711,0.049108914031371555,-0.03727228467656918,0.03727228467656918,0.002305728951761095,95.223249721332849;96.337634061199665;95.582118919735521;96.337634061199807;96.775014422529978;94.078112063042681;95.582118919735478;94.078112063042511,95.49414347461014,5.494143474610141,0.3576342722669824,309,NaN,NaN,true,true,false,false,true,true,false
+compatible_indicator,120.0,750,747,758,8,3.999999999999993,0.033305370336107899;0.032190020941282586;0.032339133650510124;0.032190020941282559;0.030201834152559712;0.032727640164599114;0.032339133650510131;0.032727640164599114,0.03225259925018141,0.035722597891021876,-0.09713735410359325,0.09713735410359325,0.008999761280190501,107.6379373610564;109.3228551045718;109.70310053017963;109.32285510457193;113.27955688476185;108.97499896605517;109.70310053017964;108.97499896605522,109.5755767349805,10.424423265019499,0.5723257240833828,198,NaN,NaN,true,true,false,false,true,true,false
+compatible_indicator,150.0,750,740,757,8,3.999999999999993,0.017821744130598883;0.018334759683288487;0.018253557896274423;0.018334759683288487;0.017895771549723601;0.018554091811571964;0.01825355789627443;0.018554091811571968,0.018250291807824028,0.01957315597740063,-0.06758563468783452,0.06758563468783452,0.004867096700352964,122.85464206799114;121.13180485308506;121.73191849650784;121.13180485308506;122.51104869674782;121.2503631981362;121.7319184965078;121.25036319813626,121.6913472752547,28.3086527247453,0.23311053191869374,105,NaN,NaN,true,false,false,false,true,false,false
+compatible_indicator,30.0,1500,1496,1512,8,3.999999999999993,0.086255052952128583;0.085280717712318335;0.085623963341480216;0.085280717712318418;0.085620631461566912;0.085772460493341265;0.085623963341480258;0.085772460493341363,0.08565374593849692,0.08318250925018945,0.029708609545243236,0.029708609545243236,0.0013158659177300827,46.400548099116669;46.496544331456903;46.261069044317985;46.496544331456931;46.217623264456257;45.563276206491047;46.261069044317921;45.563276206490976,46.15712015201189,16.157120152011892,0.13497660047999327,1106,NaN,NaN,true,false,false,false,true,false,false
+compatible_indicator,60.0,1500,1486,1507,8,3.999999999999993,0.064969058153102444;0.065575476107413821;0.064884931104438243;0.065575476107413821;0.066234739368312923;0.065469655273990776;0.064884931104438229;0.065469655273990693,0.06538299031163762,0.06267210593303608,0.043255038876435226,0.043255038876435226,0.0025957650864597355,79.122569730429305;76.492668176328124;77.865624199291332;76.492668176328095;73.794606597445977;77.034783450456615;77.865624199291332;77.034783450456786,76.95400631360388,16.954006313603884,0.5474722560487105,728,NaN,NaN,true,false,false,false,true,false,false
+compatible_indicator,90.0,1500,1487,1516,8,3.999999999999993,0.046523063954188523;0.047956535352500392;0.048015848660978425;0.047956535352500392;0.047553212274145919;0.0475930381734673;0.048015848660978411;0.047593038173467259,0.04765089007527833,0.049108914031371555,-0.029689598820324514,0.029689598820324514,0.0035908863529318467,99.488602812637936;96.385561400848289;96.285080458685314;96.385561400848289;98.09419334600608;97.113720963058498;96.285080458685172;97.113720963058569,97.1339084606982,7.133908460698194,0.4015106992362748,494,NaN,NaN,true,true,false,false,true,true,false
+compatible_indicator,120.0,1500,1496,1506,8,3.999999999999993,0.031468345152313337;0.032195804301843343;0.03245969597097282;0.032195804301843323;0.032840339246001805;0.031881474341522414;0.032459695970972847;0.031881474341522414,0.03217282920337404,0.035722597891021876,-0.09937039569398165,0.09937039569398165,0.004227723826072903,112.05029300953174;110.49429537129315;109.87333956462186;110.49429537129328;109.65200059616393;111.40705614777735;109.87333956462186;111.40705614777737,110.64656543389434,9.353434566105662,0.3093093768136103,306,NaN,NaN,true,true,false,false,true,true,false
+compatible_indicator,150.0,1500,1492,1507,8,3.999999999999993,0.017967048726361443;0.017618857658824039;0.017722373261176223;0.017618857658824046;0.017685701885999393;0.017979286879301257;0.017722373261176226;0.017979286879301226,0.01778672327637048,0.01957315597740063,-0.09126952766803598,0.09126952766803598,0.0029087857851328373,123.03239601314083;122.4704394966187;123.26002046749279;122.47043949661862;122.20527156418304;122.34453320621964;123.26002046749281;122.34453320621981,122.67330222605925,27.32669777394075,0.1543975710999419,150,NaN,NaN,true,false,false,false,true,false,false
+compatible_indicator,30.0,3000,2999,3007,8,3.999999999999993,0.089099334654994741;0.088912039975364093;0.088891837317147629;0.088912039975364301;0.088494175453120563;0.089029042159604832;0.088891837317147657;0.089029042159604999,0.08890741862654358,0.08318250925018945,0.06882347536710176,0.06882347536710176,0.0007832525595234071,47.127139619596058;47.524165853694285;46.961305724892746;47.52416585369425;46.65711145694867;47.0856382472777;46.961305724892561;47.085638247277714,47.11608016014539,17.116080160145387,0.10295678111445339,1870,NaN,NaN,true,false,false,false,true,false,false
+compatible_indicator,60.0,3000,2983,3003,8,3.999999999999993,0.065592111008647327;0.066699111734519159;0.066009155788260235;0.066699111734519312;0.066620824913770346;0.065896628430440862;0.066009155788260193;0.065896628430440835,0.0661778409786073,0.06267210593303608,0.05593772529866836,0.05593772529866836,0.0024290742753393262,79.790942816616649;77.201290816528413;79.013394028838206;77.201290816528299;76.752135042317079;79.450842242883908;79.013394028838391;79.450842242883894,78.47748107660388,18.47748107660388,0.4315301845276225,1189,NaN,NaN,true,false,false,false,true,false,false
+compatible_indicator,90.0,3000,2987,3005,8,3.999999999999993,0.048110162267165059;0.048061327537627409;0.047586590214699291;0.048061327537627367;0.046928357136540158;0.047613471541753334;0.047586590214699326;0.047613471541753362,0.04769516224898316,0.049108914031371555,-0.02878808888922424,0.02878808888922424,0.002808535497429185,98.129241438822248;98.254128453696993;99.369718935005793;98.254128453697192;100.65195840709831;99.545367154617381;99.369718935005665;99.545367154617367,99.13381120995251,9.133811209952512,0.3079707345779123,790,NaN,NaN,true,true,false,false,true,true,false
+compatible_indicator,120.0,3000,2988,3013,8,3.999999999999993,0.031990730225839321;0.031343790682203117;0.031930507832027437;0.031343790682203103;0.031009026792651384;0.032205814279642898;0.031930507832027423;0.032205814279642905,0.0317449978257797,0.035722597891021876,-0.11134688684671123,0.11134688684671123,0.0044537418908155366,111.15532388066036;113.08502683131569;111.87936040295854;113.08502683131586;114.01901016207245;111.23779786926224;111.87936040295857;111.23779786926221,112.1842990927552,7.815700907244803,0.3785291044408283,473,NaN,NaN,true,true,false,false,true,true,false
+compatible_indicator,150.0,3000,2995,3014,8,3.999999999999993,0.017388484680744144;0.017203587216502673;0.017118697357120457;0.017203587216502673;0.017480323582547561;0.017447829543287718;0.01711869735712046;0.017447829543287705,0.017301129562139174,0.01957315597740063,-0.11607869563215878,0.11607869563215878,0.002801458655490342,124.51732998266914;123.95473424608922;124.15827998828246;123.95473424608932;121.94672067199512;123.40733901448398;124.15827998828243;123.40733901448411,123.6841530607563,26.3158469392437,0.2822740523423813,218,NaN,NaN,true,false,false,false,true,false,false
+young_color_boundary,30.0,750,743,758,8,3.999999999999993,0.072382248312367684;0.072406439219402471;0.072510922151391793;0.072406439219402485;0.072387627436774626;0.072481898190817615;0.072510922151391682;0.072481898190817629,0.07244604935904575,0.08318250925018945,-0.1290711230993462,0.1290711230993462,0.0002361510411331167,32.838744828843282;32.938684741971691;33.265357781220622;32.938684741971699;33.148596696012504;32.941996152498156;33.265357781220672;32.941996152498184,33.03499074224061,3.034990742240609,0.05876745382586512,749,0.0,1.0,true,true,true,false,true,true,false
+young_color_boundary,60.0,750,742,768,8,3.999999999999993,0.06145767602323695;0.061490774530003137;0.06114897043668166;0.061490774530003109;0.061606767987218168;0.061886083204707787;0.061148970436681632;0.061886083204707794,0.06151451254415502,0.06267210593303608,-0.018470631737154797,0.018470631737154797,0.0015902405157798786,65.341101141040497;65.902510504153184;67.154528836472664;65.902510504153241;67.494247755272411;65.715663707127845;67.154528836472736;65.715663707127817,66.29581546563826,6.295815465638256,0.29292249999157405,600,0.0,1.0,true,true,false,false,true,true,false
+young_color_boundary,90.0,750,749,756,8,3.999999999999993,0.046957532263694354;0.04710181815913736;0.047472498237890147;0.047101818159137422;0.047055660176513602;0.047714394802018958;0.047472498237890168;0.047714394802018972,0.04732382685478763,0.049108914031371555,-0.03634955510202453,0.03634955510202453,0.0022004889723418286,92.728414353880822;93.615220627134079;92.815835329662804;93.615220627134079;94.224155375214409;91.539090039155326;92.815835329662733;91.539090039155326,92.85712293615693,2.8571229361569266,0.3410183716467725,448,0.0,0.9287988358669833,true,true,true,false,true,true,false
+young_color_boundary,120.0,750,747,758,8,3.999999999999993,0.034680574498867148;0.033565742925018148;0.033748363888048492;0.033565742925018176;0.03172358428363773;0.03413737791983551;0.033748363888048506;0.034137377919835524,0.03366339103103865,0.035722597891021876,-0.05764437587280746,0.05764437587280746,0.008595958952870755,111.54511159655215;113.18212263399471;113.45395040286002;113.18212263399458;116.95388363444977;112.69863368307517;113.45395040285992;112.6986336830751,113.3611698427028,6.638830157297207,0.5534819478787948,308,0.0,0.9287063621356851,true,true,false,true,true,true,false
+young_color_boundary,150.0,750,740,757,8,3.999999999999993,0.019515097483065062;0.019954412295374108;0.019943784289272088;0.019954412295374108;0.01955007596298693;0.020313487773189903;0.019943784289272092;0.020313487773189889,0.01993606777021552,0.01957315597740063,0.018541301833690627,0.018541301833690627,0.005340496473837341,133.35238786262011;131.83901895052256;132.34500448592701;131.8390189505225;133.15099245634286;132.00343725842455;132.34500448592701;132.00343725842458,132.3534450889354,17.646554911064612,0.20736736687994212,188,0.0,0.9286948042127006,true,true,false,false,true,true,false
+young_color_boundary,30.0,1500,1496,1512,8,3.999999999999993,0.077291368460393695;0.076334414745138773;0.07663321009908089;0.076334414745138926;0.076717940929982895;0.076748043812109673;0.076633210099080973;0.076748043812109798,0.07668008083787944,0.08318250925018945,-0.07817062109478501,0.07817062109478501,0.0012727781138162804,34.029535934169616;34.141655613973221;33.950490274662229;34.141655613973079;33.983517224591765;33.581936994593839;33.950490274662151;33.581936994593811,33.9199360697972,3.9199360697972025,0.07845919097690274,1396,0.0,1.0,true,true,true,false,true,true,false
+young_color_boundary,60.0,1500,1486,1507,8,3.999999999999993,0.062676541437575686;0.06310207743596459;0.062511215009160001;0.06310207743596466;0.063622168717714186;0.06303105083885166;0.062511215009160057;0.063031050838851715,0.06294842459040531,0.06267210593303608,0.0044089575937413805,0.0044089575937413805,0.0021011047945014385,69.351698032438335;66.975451652893028;68.119763024656365;66.975451652892914;64.430011082124565;67.345120184834201;68.119763024656322;67.345120184834101,67.32624326687032,7.326243266870321,0.4999366679120701,1015,0.0,1.0,true,true,false,false,true,true,false
+young_color_boundary,90.0,1500,1487,1516,8,3.999999999999993,0.04664106218033795;0.047963929104391817;0.04803424143745199;0.047963929104391775;0.047678586331725267;0.047642460104815376;0.048034241437451955;0.047642460104815314,0.047700113725672684,0.049108914031371555,-0.02868726245501796,0.02868726245501796,0.003322851040912541,97.061863437459706;93.846802076378879;93.720369361423749;93.846802076378879;95.407116177385504;94.528339231319521;93.720369361423792;94.528339231319706,94.57306203703646,4.573062037036465,0.40975042530986494,727,0.0,0.9287003256293382,true,true,true,false,true,true,false
+young_color_boundary,120.0,1500,1496,1506,8,3.999999999999993,0.033008578871675749;0.033633362861368803;0.033853925561789053;0.033633362861368796;0.034300342933558638;0.033386203995205602;0.033853925561789053;0.033386203995205588,0.03363198833024516,0.035722597891021876,-0.05852344689920064,0.05852344689920064,0.003841686288650527,115.97165631113891;114.32748252644332;113.76817381091298;114.3274825264433;113.4181080171649;115.20170617117446;113.76817381091286;115.20170617117454,114.48947268569741,5.510527314302593,0.31201188418437587,479,0.0,0.9286715277894354,true,true,false,true,true,true,false
+young_color_boundary,150.0,1500,1492,1507,8,3.999999999999993,0.019855507705757211;0.019432942825118139;0.019591800525173876;0.019432942825118146;0.019512789804481526;0.019894676539690213;0.01959180052517389;0.019894676539690209,0.0196508921612754,0.01957315597740063,0.003971571266510265,0.003971571266510265,0.003624157544209278,133.57904698865553;133.19506138503152;133.81749002044427;133.19506138503141;133.118988501742;133.08615736155855;133.81749002044413;133.08615736155855,133.36169255758725,16.638307442412753,0.1140975137703862,268,0.0,0.9287607758267137,true,true,false,false,true,true,false
+young_color_boundary,30.0,3000,2999,3007,8,3.999999999999993,0.079901673016566754;0.079773820352058669;0.079697245194335298;0.079773820352058669;0.07932510050989959;0.079895941984068256;0.079697245194335201;0.079895941984068131,0.07974509857342382,0.08318250925018945,-0.041323719466394904,0.041323719466394904,0.000807485305343837,34.477007377530938;34.84200220144298;34.387303246327697;34.842002201443009;34.262161288088706;34.559887933803395;34.38730324632774;34.559887933803459,34.53992138738146,4.539921387381462,0.07452180765073659,2476,0.0,1.0,true,true,true,false,true,true,false
+young_color_boundary,60.0,3000,2983,3003,8,3.999999999999993,0.063224619904978727;0.064200490143458563;0.06359111495701629;0.064200490143458633;0.06409631143355829;0.06352904471927584;0.063591114957016373;0.063529044719275854,0.06374527887225483,0.06267210593303608,0.017123613818967653,0.017123613818967653,0.002078001589235858,70.019504208447515;67.683080340069466;69.225197702019301;67.68308034006931;67.284182833544278;69.672426410042348;69.225197702019244;69.672426410042391,68.80285755744572,8.80285755744572,0.3818007945959263,1688,0.0,1.0,true,true,false,false,true,true,false
+young_color_boundary,90.0,3000,2987,3005,8,3.999999999999993,0.048187862590632333;0.048115846886207222;0.047674470362300166;0.048115846886207299;0.047095763812946542;0.047763143365779824;0.047674470362300152;0.04776314336577981,0.04779881845401916,0.049108914031371555,-0.02667734775229369,0.02667734775229369,0.002552806919562967,95.564082978302807;95.737200452186528;96.839989924211864;95.737200452186372;98.175635392935007;96.934337733636127;96.839989924211849;96.934337733635985,96.58974684545517,6.589746845455167,0.310045435554459,1170,0.0,0.9286844240342336,true,true,true,false,true,true,false
+young_color_boundary,120.0,3000,2988,3013,8,3.999999999999993,0.033481250903995695;0.032876855562044054;0.033463459407155116;0.032876855562044095;0.032615145152219777;0.033696985069311229;0.033463459407155137;0.033696985069311201,0.03327137451665454,0.035722597891021876,-0.06861828419772908,0.06861828419772908,0.004135316469844155,115.06609795708027;116.88080107536975;115.69982165358489;116.88080107536967;117.68421295760599;115.06930869160243;115.69982165358488;115.06930869160257,115.99536233039579,4.004637669604207,0.35788663926802333,739,0.0,0.9287055544539785,true,true,false,true,true,true,false
+young_color_boundary,150.0,3000,2995,3014,8,3.999999999999993,0.019461699378866684;0.019176770444556193;0.019131048226399865;0.019176770444556158;0.019372932670143918;0.019452355030977819;0.019131048226399879;0.019452355030977815,0.01929437243160979,0.01957315597740063,-0.014243157624285407,0.014243157624285407,0.002774053358826736,135.04041447633372;134.5347287780109;134.76633919336439;134.53472877801119;132.83469650836261;134.10488906272784;134.76633919336442;134.10488906272769,134.33419104861466,15.665808951385344,0.2429839147167452,384,0.0,0.9287034952680039,true,true,false,false,true,true,false
diff --git a/validation/surface_tension_3d/contact_angle_recovery_extended_comparison.csv b/validation/surface_tension_3d/contact_angle_recovery_extended_comparison.csv
new file mode 100644
index 0000000000..3358d72656
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_recovery_extended_comparison.csv
@@ -0,0 +1,5 @@
+method,role,measure,middle_passes,endpoint_passes,measure_gate,angle_middle_passes,angle_endpoint_passes,angle_gate,sign_cases,sign_passes,zero_at_90,eligible_for_dynamics,max_middle_error
+exact-profile protocol control,control,contact_line,5,5,true,5,5,true,0,0,,false,0.16927192097393184
+R7-CG compatible + geometry wall,candidate,contact_line,5,2,false,0,0,false,4,4,true,false,0.08516834908413828
+R7-Y Young color boundary,candidate,contact_line,5,5,true,2,1,false,4,3,,false,0.07817062109478501
+R7-W corrected wetted-area energy,candidate,wetted_area,5,5,true,0,0,true,4,4,true,true,0.05677850354866176
diff --git a/validation/surface_tension_3d/contact_angle_scorecard.csv b/validation/surface_tension_3d/contact_angle_scorecard.csv
new file mode 100644
index 0000000000..6d02d91c13
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_scorecard.csv
@@ -0,0 +1,3 @@
+mechanism,eligible,static_cells,static_eligible,monotone_targets,mae_750,max_750,mae_1500,max_1500,mae_3000,max_3000,endpoint_regressions,sensitivity_span,threshold_pass,timestep_pass,response_cases,response_passes,acceleration_passes,motion_passes,mean_error_reduction,median_runtime,runtime_mad,runtime_overhead,contact_cache_bytes,worst_rms_speed,density_min,density_max,max_rejected_fraction,minimum_dt,mean_runtime,total_runtime
+geometric,false,15,true,true,1.2460669709300825,2.8620776802886496,1.2288200781318892,2.3066502366821595,0.7478574598808393,1.3139217822942157,1,0.1537995189474941,true,true,4,1,2,1,-0.001190858026817898,26.144955996,0.04447921499999907,1.023526635156901,36192,0.003312793167729143,985.1533023160614,1000.0069241777505,0.16932907348242812,1.6516015193587114e-6,31.756506335599997,476.34759503399994
+contact_line_force,false,15,true,true,1.3271228224661251,3.1747382339068793,0.9480397114929481,1.8985870504559728,0.8379970590668563,1.7759106941769858,1,0.13451497318226302,true,true,4,2,3,2,0.0031327169734547133,29.955174682,0.17199666199999797,1.1726896443235708,60320,0.0032614856967259805,987.5003492054914,1000.0069981924015,0.21428571428571427,2.2557370645426983e-6,37.749703603933334,566.245554059
diff --git a/validation/surface_tension_3d/contact_angle_selected_matrix_r4_wetted_area.csv b/validation/surface_tension_3d/contact_angle_selected_matrix_r4_wetted_area.csv
new file mode 100644
index 0000000000..0249086a1d
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_selected_matrix_r4_wetted_area.csv
@@ -0,0 +1,16 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,shape_acceleration,boundary_shape_acceleration,wetting_energy,explicit_reaction_residual,density_resultant_residual,total_momentum_residual,max_explicit_reaction_residual,max_density_resultant_residual,max_total_momentum_residual,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,pass
+selected,30.0,30.0,r4_wetted_area,750,760,0.1,4000.0,0.01,1,30.81164085139647,30.77850367223989,0.8116408513964686,0.7785036722398893,0.04082739194258789,26.926554122447673,0.0,-0.023429625212478003,0.0,-3.225043401302112e-5,1.9724411973381792e-16,5.3851221382083066e-17,1.726170786440661e-16,1.521924579594432e-15,2.0183438133888564e-16,1.3325928190860728e-15,0,998.5209649086248,1000.0062704890845,0.0016381242856747912,true,257,6,0.022813688212927757,8.106698611459526e-6,1.5408993828174923e-5,0.6592596865415601,2.7297570980799706,1.771796515154266,15.318516736,25.47005601,146440,265032,true
+selected,60.0,60.0,r4_wetted_area,750,776,0.1,4000.0,0.01,1,60.6325825549372,60.68545660773726,0.6325825549372013,0.6854566077372581,-0.08358443081837108,55.59450994643729,0.0,0.0006532418750816662,0.0,-1.0976354530742135e-5,4.1598060335533584e-16,2.8584863495169794e-17,3.4708756424162327e-16,1.2355889416820077e-15,1.8420870397323065e-16,1.0334729766867103e-15,0,999.8801657518245,1000.0035960538238,0.0007285992196104599,true,252,45,0.15151515151515152,1.165517956529879e-5,1.5408993828174923e-5,1.2740220744225543,2.6112783980962035,1.0167922237745508,13.341252838,13.693375792,149512,168784,true
+selected,90.0,90.0,r4_wetted_area,750,736,0.1,4000.0,0.01,1,89.45404799393238,89.55060003019153,-0.5459520060676226,-0.44939996980846786,0.1768507765995747,89.11797828283034,0.6672226137947009,0.007883226317137601,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.1486951604467,1000.0041223412647,0.0015376263771680393,true,260,45,0.14754098360655737,1.2424466293240116e-5,1.5408993828174923e-5,1.2073653446956882,2.569135292585517,1.0281266331819505,11.318896615,11.537478059,141832,117200,true
+selected,120.0,120.0,r4_wetted_area,750,768,0.1,4000.0,0.01,1,121.26298125731725,121.67641476265695,1.26298125731725,1.6764147626569468,-0.327347300638402,119.80036474946107,176.83956577912213,0.006426045957084546,0.0,3.34803495542116e-6,9.99026462831012e-17,1.7231356447978234e-17,8.100497094503484e-17,1.047463210684791e-15,2.668384767142955e-16,8.45054065146601e-16,0,999.4446648204328,1000.008068291347,0.002652496780585433,true,268,29,0.09764309764309764,1.1306461761782753e-5,1.5408993828174923e-5,1.0251344636663795,2.5059240704140935,1.1200225205165102,13.160666877,13.33486191,147976,98944,true
+selected,150.0,150.0,r4_wetted_area,750,728,0.1,4000.0,0.01,1,152.7205054577875,154.325630423968,2.7205054577875103,4.3256304239679935,-0.5900098312928488,166.32350948213355,179.99999999999997,0.001930901930260658,0.0,2.0295697806460465e-6,2.2314116846152984e-16,5.871139235200235e-17,1.8068670583425673e-16,7.941657376835957e-16,2.4941010261691177e-16,6.410847474923502e-16,0,999.2579713894244,1000.0107412585543,0.0029441564838964033,true,264,13,0.04693140794223827,1.1465408381358206e-5,1.5408993828174923e-5,1.0530839001319936,2.5544368036447453,1.1968297644237604,11.503384367,11.665004831,140296,92176,true
+selected,30.0,30.0,r4_wetted_area,1500,1504,0.1,4000.0,0.01,1,30.19653104455346,30.201451531616563,0.19653104455345982,0.20145153161656282,-0.025036691146087753,29.38670866061075,0.0,-0.021635908811310094,0.0,-3.2474907706229246e-5,1.0724768836613807e-15,1.189747019257752e-16,9.275092445037729e-16,1.953310503701338e-15,2.1977110804895669e-16,1.712877883253034e-15,0,998.8657410587434,1000.0086119281783,0.0019027455180109154,true,320,32,0.09090909090909091,6.398252276648624e-6,1.2230126506308182e-5,0.6810013315666379,2.7235450065565807,1.1878235718328833,32.872048246,33.839434632,289288,443264,true
+selected,60.0,60.0,r4_wetted_area,1500,1480,0.1,4000.0,0.01,1,59.240154735427446,59.310700670496175,-0.7598452645725544,-0.6892993295038252,0.09284250143799255,65.66748571576534,0.0,-6.016613999131602e-5,0.0,-1.0862359312261775e-5,1.216756091193397e-15,5.0158107346353796e-17,1.0167431393093095e-15,1.905932734181908e-15,2.362995557684334e-16,1.593490915171286e-15,0,999.875611776967,1000.004819421663,0.0009227050563320157,true,316,49,0.13424657534246576,9.414030698844364e-6,1.2230126506308182e-5,1.373297669230533,2.606743907413477,1.0395055894400071,32.700070344,33.285390995,284680,287240,true
+selected,90.0,90.0,r4_wetted_area,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.85482764022284,-2.2117561670355883,-2.1451723597771633,0.03010449716420016,90.58348914564176,5.448616776298459,0.006322566937281028,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348037418,1000.0049605366512,0.0018842586472753206,true,323,44,0.11989100817438691,9.870034116213684e-6,1.2230126506308182e-5,1.2942784041081006,2.5750478998283373,1.0499302911338562,28.830802177,29.202483973,290056,209776,true
+selected,120.0,120.0,r4_wetted_area,1500,1492,0.1,4000.0,0.01,1,118.05922431859437,117.95275190216836,-1.9407756814056256,-2.047248097831641,-0.05486075358740172,116.77910661177684,175.77609588469304,0.005428040822784659,0.0,3.6415706340133573e-6,7.409661965623413e-16,1.0489349990484759e-16,6.00834640972785e-16,1.1398583126369347e-15,2.6934045948314654e-16,9.24251245967206e-16,0,998.9624588867765,1000.0094832219286,0.003695892544815706,true,334,42,0.11170212765957446,8.925679448626058e-6,1.2230126506308182e-5,1.0558984263792481,2.5375608451449434,1.0774282141818203,33.69596025,33.991413391,286984,170720,true
+selected,150.0,150.0,r4_wetted_area,1500,1504,0.1,4000.0,0.01,1,149.99774220905832,150.5697171419865,-0.0022577909416838793,0.5697171419865015,-251.33387709563652,144.6524753184541,180.0,0.002245230154743737,0.0,1.9009549829342702e-6,3.288258148896174e-16,2.6821525371287295e-17,2.652228360013871e-16,8.802865395444085e-16,2.360487355884715e-16,7.094616866163528e-16,0,999.339580604262,1000.0138787313256,0.003477522997337026,true,330,21,0.05982905982905983,9.464188441049915e-6,1.2230126506308182e-5,1.1144480260642244,2.525283670326788,1.026683052489188,31.661896519,31.939095002,289288,165704,true
+selected,30.0,30.0,r4_wetted_area,3000,3004,0.1,4000.0,0.01,1,30.21799495784461,30.212332108031102,0.21799495784460987,0.21233210803110225,0.02597697611677874,29.0974225360716,0.0,-0.018414427558849714,0.0,-3.2819918772100445e-5,4.032580930308914e-15,1.7309993525757667e-16,3.4481197979048692e-15,4.032580930308914e-15,2.504345745434644e-16,3.4481197979048692e-15,0,997.0686857302419,1000.0112948349013,0.002124648159359982,true,385,50,0.11494252873563218,4.6631484413156385e-6,9.707057840908885e-6,0.7412022922826005,2.8335419541383287,1.0674389334353664,77.16647853,78.720167787,577288,725312,true
+selected,60.0,60.0,r4_wetted_area,3000,2980,0.1,4000.0,0.01,1,60.400487988287246,60.533029269332886,0.40048798828724586,0.5330292693328857,-0.3309494539710789,57.39547231903918,0.0,-0.0008066083680369104,0.0,-1.0714964162669248e-5,6.713363206362742e-16,1.6387167979573758e-17,5.56512559969073e-16,2.385575467395159e-15,2.401856838035688e-16,1.9766680824259407e-15,0,999.679822942329,1000.0057502583638,0.0011344986036445283,true,395,49,0.11036036036036036,7.22980090924278e-6,9.707057840908885e-6,1.3977666939687243,2.6558124270664787,1.0316956529022212,84.57535244,85.581606957,572680,491512,true
+selected,90.0,90.0,r4_wetted_area,3000,3004,0.1,4000.0,0.01,1,90.51880954004696,91.08262422493794,0.5188095400469592,1.0826242249379447,-1.086746949256085,87.46885583661317,34.73506113949568,0.004926387344861666,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6135682733536,1000.0059352358529,0.0021986250413797734,true,398,4,0.009950248756218905,7.747573755252771e-6,9.707057840908885e-6,1.291728026343552,2.6186687490976612,1.0059349553040413,61.787905512,62.396274121,577288,363808,true
+selected,120.0,120.0,r4_wetted_area,3000,2992,0.1,4000.0,0.01,1,118.99726426566804,119.76997616384881,-1.0027357343319636,-0.23002383615119015,0.7706037310972714,118.31193204981955,180.00000000000006,0.00408833475140092,0.0,3.4183382732127033e-6,3.502426343703146e-16,7.088040779081783e-17,2.819091867654413e-16,1.5148929623212578e-15,2.7631415784669834e-16,1.2245289056806596e-15,0,999.2995429628929,1000.0131343300843,0.004318845390412592,true,411,15,0.035211267605633804,2.8706989726977328e-6,9.707057840908885e-6,1.060840973246404,2.5696498293505465,1.0339071816541228,76.990752793,77.478827561,574984,307144,true
+selected,150.0,150.0,r4_wetted_area,3000,3004,0.1,4000.0,0.01,1,150.9920265180778,153.7069747626013,0.9920265180778074,3.7069747626013054,-2.7367698292824842,153.6879477363243,180.0,0.0014277416611603515,0.0,1.8959031809317202e-6,1.0293998327741905e-16,6.7632979123946e-17,8.204164717768632e-17,9.286142053118777e-16,2.731655496121384e-16,7.488761084552094e-16,0,999.3722343444966,1000.0192196538891,0.00408344475661501,true,414,14,0.03271028037383177,4.459634798191503e-6,9.707057840908885e-6,1.1236505951687525,2.5233637151038817,1.0513646277520394,77.647215553,78.101360089,577288,293552,true
diff --git a/validation/surface_tension_3d/contact_angle_selected_matrix_r4_wetted_area_production.csv b/validation/surface_tension_3d/contact_angle_selected_matrix_r4_wetted_area_production.csv
new file mode 100644
index 0000000000..e1066173ae
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_selected_matrix_r4_wetted_area_production.csv
@@ -0,0 +1,16 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,shape_acceleration,boundary_shape_acceleration,wetting_energy,explicit_reaction_residual,density_resultant_residual,total_momentum_residual,max_explicit_reaction_residual,max_density_resultant_residual,max_total_momentum_residual,force_consistency_residual,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,pass
+selected,30.0,30.0,wetted_area_production,750,760,0.1,4000.0,0.01,1,30.81164085139647,30.77850314444182,0.8116408513964686,0.7785031444418209,0.040828042227830985,26.926554042858633,0.0,-0.023429588899002006,0.0,-3.225044320114583e-5,1.8280174106819235e-15,1.5972949513181597e-16,1.0395487751332803e-15,1.8280174106819235e-15,1.5972949513181597e-16,1.0395487751332803e-15,1.8546270376964776e-18,0,998.5210041028666,1000.0062704927399,0.001638120342590714,true,257,6,0.022813688212927757,8.106708230443494e-6,1.5408993828174923e-5,0.6592598178973976,2.729795679701764,1.7717802123236244,14.266893191,24.915771323,152648,338160,true
+selected,60.0,60.0,wetted_area_production,750,776,0.1,4000.0,0.01,1,60.6325825549372,60.68545645263228,0.6325825549372013,0.6854564526322804,-0.08358418562511272,55.594510250538235,0.0,0.0006532666078900175,0.0,-1.0976362106609452e-5,1.317757027671803e-15,3.952986174848456e-17,9.860403640391227e-16,1.317757027671803e-15,1.0135720769556874e-16,9.860403640391227e-16,5.3195310162148306e-18,0,999.8801672828672,1000.0035949851151,0.0007285942217759005,true,252,52,0.17105263157894737,2.4089365927466855e-6,1.5408993828174923e-5,1.2740245056332302,2.611982496772032,1.0264431100677764,11.960377598,12.388918391,155848,193136,true
+selected,90.0,90.0,wetted_area_production,750,736,0.1,4000.0,0.01,1,89.45404799393238,89.55060008482445,-0.5459520060676226,-0.44939991517554745,0.1768508766686646,89.11797830578122,0.6672226482077523,0.007883226317137601,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.1486950334717,1000.0041250956256,0.0015376244540644725,true,260,44,0.14473684210526316,1.2424466293240109e-5,1.5408993828174923e-5,1.207365344693068,2.556031553670992,1.0284145843535704,11.598391954,11.866272156,147848,119088,true
+selected,120.0,120.0,wetted_area_production,750,768,0.1,4000.0,0.01,1,121.26298125731725,121.67641538346147,1.26298125731725,1.6764153834614746,-0.32734779217738885,119.80036451383906,176.83951434779146,0.006426038894142357,0.0,3.348040187983978e-6,3.9523471968963086e-16,8.03866799697347e-17,8.91380075388918e-16,3.9523471968963086e-16,8.03866799697347e-17,8.91380075388918e-16,4.547029542966642e-18,0,999.4446663627434,1000.0080683135271,0.0026524908851306406,true,268,31,0.10367892976588629,1.1306472824619672e-5,1.5408993828174923e-5,1.025133417615367,2.504369648067531,1.1068105517268074,11.525695146,11.744435377,154248,89392,true
+selected,150.0,150.0,wetted_area_production,750,728,0.1,4000.0,0.01,1,152.7205054577875,154.32563203954402,2.7205054577875103,4.325632039544018,-0.5900104251442673,166.32350933976744,180.00000000000006,0.0019309038642627509,0.0,2.0295745896066395e-6,1.746808057254539e-16,1.2106910381938636e-16,4.588193600620067e-16,2.8804795561727226e-16,1.5874911678786068e-16,4.588193600620067e-16,8.00975108709128e-18,0,999.2579736419242,1000.0107413742704,0.002944152705118291,true,264,10,0.0364963503649635,1.1445152302698783e-5,1.5408993828174923e-5,1.053091179362635,2.5489350160420363,1.182797438207002,9.83400287,10.039390025,146248,82304,true
+selected,30.0,30.0,wetted_area_production,1500,1504,0.1,4000.0,0.01,1,30.19653104455346,30.20145116406,0.19653104455345982,0.20145116405999985,-0.025034820924699552,29.386708455564488,0.0,-0.021635871996912814,0.0,-3.247491644468638e-5,1.727262207020054e-15,7.000023995698484e-17,1.2360394385154407e-15,1.727262207020054e-15,7.000023995698484e-17,1.2360394385154407e-15,4.081679035195169e-18,0,998.8657682684461,1000.0086143654223,0.001902811980984588,true,320,30,0.08571428571428572,6.3982571568885505e-6,1.2230126506308182e-5,0.6810019867434559,2.7189083482456264,1.1889959300487716,29.667259592,30.725614356,301448,548160,true
+selected,60.0,60.0,wetted_area_production,1500,1480,0.1,4000.0,0.01,1,59.240154735427446,59.31070040812313,-0.7598452645725544,-0.6892995918768676,0.0928421561400028,65.66748586166771,0.0,-6.0138113942466864e-5,0.0,-1.0862366326086675e-5,1.0247492582557004e-15,1.335579435011094e-16,1.1859543178325947e-15,1.0247492582557004e-15,1.335579435011094e-16,1.1859543178325947e-15,1.2461124944102086e-18,0,999.8756129247647,1000.0048195854953,0.0009226999890674609,true,315,55,0.14864864864864866,9.414055702017114e-6,1.2230126506308182e-5,1.3724904429934914,2.610699719275579,1.015882284414469,29.040480562,29.686046802,296648,316848,true
+selected,90.0,90.0,wetted_area_production,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.8548276025082,-2.2117561670355883,-2.1451723974918053,0.03010448011230149,90.58348913131027,5.44861670180217,0.006322566937281027,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348038162945,1000.0049560481513,0.0018842555157376594,true,323,45,0.12228260869565218,9.870034116213688e-6,1.2230126506308182e-5,1.2942784041080995,2.5682567242380516,1.0455498620055843,29.613910503,30.059639339,302248,198992,true
+selected,120.0,120.0,wetted_area_production,1500,1492,0.1,4000.0,0.01,1,118.05922431859437,117.95275299704642,-1.9407756814056256,-2.0472470029535828,-0.05486018944283355,116.77910640539336,175.77608910585573,0.005428028928631595,0.0,3.6415757278575266e-6,3.365174612532466e-16,1.2877651908594786e-16,1.671344178026969e-16,4.3090377145413666e-16,1.2877651908594786e-16,3.338412614354583e-16,3.3651641724453797e-18,0,998.9624579525808,1000.009480586395,0.003695883466334544,true,335,40,0.10666666666666667,2.9481894611230186e-6,1.2230126506308182e-5,1.0561513181894597,2.5161496577845326,1.0573591995045761,29.238084555,29.612898438,299048,141840,true
+selected,150.0,150.0,wetted_area_production,1500,1504,0.1,4000.0,0.01,1,149.99774220905832,150.56971969187893,-0.0022577909416838793,0.5697196918789302,-251.33500647054086,144.65247518899955,180.0,0.0022452292064080418,0.0,1.9009589083225509e-6,4.867567223051944e-16,1.4117598780006027e-16,1.4006815778417268e-16,4.867567223051944e-16,1.4117598780006027e-16,1.4006815778417268e-16,8.57427883829283e-18,0,999.339582807631,1000.0138787578936,0.00347752025920046,true,330,32,0.08839779005524862,9.464199013763122e-6,1.2230126506308182e-5,1.114449515488719,2.5206614372720457,1.0473789469908306,28.017884292,28.383955885,301448,133440,true
+selected,30.0,30.0,wetted_area_production,3000,3004,0.1,4000.0,0.01,1,30.21799495784461,30.21233127138849,0.21799495784460987,0.21233127138848928,0.025980814015697296,29.097422761310686,0.0,-0.018414386185701372,0.0,-3.281992718088949e-5,1.5823286587619446e-15,1.8815846730178187e-17,4.3953534368363677e-16,1.5823286587619446e-15,1.0806713959638782e-16,1.0735617773209046e-15,1.9039285780539785e-18,0,997.0687291576515,1000.0112940858411,0.0021246499281855438,true,384,52,0.11926605504587157,4.663154582717139e-6,9.707057840908885e-6,0.7409284141585771,2.852848320675875,1.065529376970489,68.447011331,70.140074901,601448,856080,true
+selected,60.0,60.0,wetted_area_production,3000,2980,0.1,4000.0,0.01,1,60.400487988287246,60.53302914739081,0.40048798828724586,0.5330291473908133,-0.3309491494873591,57.395471875153,0.0,-0.0008065794575702227,0.0,-1.0714970704529093e-5,1.8210785588291015e-15,3.479359646945972e-17,3.1585899933802464e-15,1.8210785588291015e-15,3.479359646945972e-17,3.1585899933802464e-15,2.820137155515867e-18,0,999.6798256777222,1000.0057511977693,0.0011344928750279097,true,395,55,0.12222222222222222,7.229820032017105e-6,9.707057840908885e-6,1.3977705861585104,2.634184400742327,1.0196557884559196,78.774283227,79.912453371,596648,508560,true
+selected,90.0,90.0,wetted_area_production,3000,3004,0.1,4000.0,0.01,1,90.51880954004696,91.08262422493875,0.5188095400469592,1.0826242249387548,-1.086746949257646,87.46885583661383,34.73506113949593,0.004926387344861667,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6135682733499,1000.0059352359254,0.0021986250413796194,true,398,4,0.009950248756218905,7.747573755252771e-6,9.707057840908885e-6,1.291728026343552,2.618668742995176,1.005934947799149,68.702038099,69.507040148,601448,315936,true
+selected,120.0,120.0,wetted_area_production,3000,2992,0.1,4000.0,0.01,1,118.99726426566804,119.76997933590135,-1.0027357343319636,-0.23002066409864597,0.7706068944955982,118.31193129971173,180.00000000000003,0.004088322095860415,0.0,3.4183427055203075e-6,2.2348837079224153e-16,1.5569429279735376e-16,2.682534252031781e-16,2.2348837079224153e-16,1.5569429279735376e-16,6.074710067514442e-16,4.422144209216317e-18,0,999.2995387079106,1000.0131341514012,0.004318831585230121,true,410,9,0.021479713603818614,7.164593234579546e-6,9.707057840908885e-6,1.0603583152565494,2.5709287182810554,1.0361219328038564,67.833073496,68.505668034,599048,232176,true
+selected,150.0,150.0,wetted_area_production,3000,3004,0.1,4000.0,0.01,1,150.9920265180778,153.70698112947943,0.9920265180778074,3.7069811294794306,-2.736776247334834,153.68794746091328,179.99999999999991,0.001427737794663487,0.0,1.8959066400235988e-6,5.875771993050781e-16,4.8766448400290035e-17,2.720021750898885e-16,5.875771993050781e-16,4.8766448400290035e-17,2.725985695856001e-16,1.0209458369151007e-17,0,999.3722274185658,1000.019219848114,0.004083454881153175,true,414,20,0.04608294930875576,3.2883634109477644e-6,9.707057840908885e-6,1.123656353487892,2.518548235416512,1.0471035469540801,68.432504089,69.003292211,601448,210960,true
diff --git a/validation/surface_tension_3d/contact_angle_sensitivity_r4_wetted_area.csv b/validation/surface_tension_3d/contact_angle_sensitivity_r4_wetted_area.csv
new file mode 100644
index 0000000000..33382598a3
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_sensitivity_r4_wetted_area.csv
@@ -0,0 +1,5 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,shape_acceleration,boundary_shape_acceleration,wetting_energy,explicit_reaction_residual,density_resultant_residual,total_momentum_residual,max_explicit_reaction_residual,max_density_resultant_residual,max_total_momentum_residual,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,pass,angle_span
+sensitivity,90.0,90.0,r4_wetted_area,1500,1508,0.0,2000.0,0.01,1,87.78824383296441,87.81652672348122,-2.2117561670355883,-2.1834732765187823,0.01278752646351311,90.61631770811125,6.53434802931794,0.0066148461457588344,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,997.0896819643111,1000.0062848254298,0.003150477646563007,true,326,46,0.12365591397849462,9.914275886884275e-6,1.2230126506308182e-5,1.2991200776138558,2.5294861117543412,1.0383550924195335,33.128580952,43.014457426,290056,209776,true,0.106514883971073
+sensitivity,90.0,90.0,r4_wetted_area,1500,1508,0.0,8000.0,0.01,1,87.78824383296441,87.88579267867608,-2.2117561670355883,-2.114207321323917,0.04410470157857216,90.55129833736292,0.0,0.0066148461457588344,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.4765742996243,1000.0058421389436,0.0011359585916195917,true,313,1,0.0031847133757961785,1.0325836517236453e-5,1.2230126506308182e-5,1.377872194056592,2.6194954847089513,0.9893221123447278,25.0824057,25.467252332,290056,209776,true,0.106514883971073
+sensitivity,90.0,90.0,r4_wetted_area,1500,1508,0.1,2000.0,0.01,1,87.78824383296441,87.77927779470501,-2.2117561670355883,-2.22072220529499,-0.004053809544213527,90.61767600869311,6.845623548754194,0.006322566937281028,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,996.9012269017295,1000.006398661818,0.0031200976613639645,true,326,44,0.11891891891891893,2.768569510820662e-6,1.2230126506308182e-5,1.2989490208644745,2.5395560296757704,1.0113972890246343,29.668160543,30.036051835,290056,209776,true,0.106514883971073
+sensitivity,90.0,90.0,r4_wetted_area,1500,1508,0.1,8000.0,0.01,1,87.78824383296441,87.85544596663576,-2.2117561670355883,-2.144554033364244,0.030384060717423056,90.55171859120924,4.263841154133551,0.006322566937281028,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.399696383569,1000.005771895003,0.0011070788832980934,true,314,2,0.006329113924050633,8.336978511123971e-7,1.2230126506308182e-5,1.3190428348611907,2.637188746494285,1.0334410101401887,25.060569167,25.430842375,290056,209776,true,0.106514883971073
diff --git a/validation/surface_tension_3d/contact_angle_sensitivity_r4_wetted_area_production.csv b/validation/surface_tension_3d/contact_angle_sensitivity_r4_wetted_area_production.csv
new file mode 100644
index 0000000000..8a79e95de8
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_sensitivity_r4_wetted_area_production.csv
@@ -0,0 +1,5 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,shape_acceleration,boundary_shape_acceleration,wetting_energy,explicit_reaction_residual,density_resultant_residual,total_momentum_residual,max_explicit_reaction_residual,max_density_resultant_residual,max_total_momentum_residual,force_consistency_residual,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,pass,angle_span
+sensitivity,90.0,90.0,wetted_area_production,1500,1508,0.0,2000.0,0.01,1,87.78824383296441,87.81652671764151,-2.2117561670355883,-2.1834732823584915,0.012787523823208913,90.61631770489988,6.534348040964391,0.0066148461457588344,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,997.0896819844645,1000.0062842449026,0.0031504767998462757,true,326,55,0.14435695538057744,6.205295478964659e-6,1.2230126506308182e-5,1.2991200776138558,2.5213075015685655,1.0298010506749993,34.256699505,45.03906574,302248,198992,true,0.10651487875826149
+sensitivity,90.0,90.0,wetted_area_production,1500,1508,0.0,8000.0,0.01,1,87.78824383296441,87.88579267867583,-2.2117561670355883,-2.114207321324173,0.04410470157845647,90.5512983373634,0.0,0.0066148461457588344,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.4765742996245,1000.0058421389435,0.0011359585916194456,true,313,1,0.0031847133757961785,1.0325836517236453e-5,1.2230126506308182e-5,1.3778721940565906,2.6194954827471597,0.9893221078777009,25.339369931,25.815514583,302248,198992,true,0.10651487875826149
+sensitivity,90.0,90.0,wetted_area_production,1500,1508,0.1,2000.0,0.01,1,87.78824383296441,87.77927779991757,-2.2117561670355883,-2.2207222000824345,-0.004053807187463843,90.61767600800117,6.845623553998976,0.006322566937281027,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,996.9012268656568,1000.0063991749213,0.003120098284685172,true,325,44,0.11924119241192412,9.923644793039049e-6,1.2230126506308182e-5,1.2984818071326962,2.547959285738967,1.0148694335281303,29.70476793,30.17480733,302248,198992,true,0.10651487875826149
+sensitivity,90.0,90.0,wetted_area_production,1500,1508,0.1,8000.0,0.01,1,87.78824383296441,87.85544596663561,-2.2117561670355883,-2.144554033364386,0.030384060717358774,90.5517185912091,4.26384115413395,0.006322566937281027,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,999.3996963835679,1000.005771895003,0.001107078883297943,true,314,2,0.006329113924050633,8.336976847714977e-7,1.2230126506308182e-5,1.3190428348611896,2.6371887514850743,1.0334410169534958,25.281594434,25.854864463,302248,198992,true,0.10651487875826149
diff --git a/validation/surface_tension_3d/contact_angle_static_r4_wetted_area.csv b/validation/surface_tension_3d/contact_angle_static_r4_wetted_area.csv
new file mode 100644
index 0000000000..14ad3d1cdb
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_static_r4_wetted_area.csv
@@ -0,0 +1,10 @@
+kind,target,initial_angle,requested_particles,particle_count,energy,raw_area,corrected_area,area_derivative,finite_difference,analytic_derivative,explicit_derivative,density_derivative,gradient_relative_error,gradient_pass,shape_acceleration,control_acceleration,contact_shape_acceleration,expected_direction,total_sign_pass,contact_sign_pass,zero_at_90,explicit_reaction_residual,density_resultant_residual,total_momentum_residual,explicit_force_scale,density_force_scale,wall_force_scale,reaction_pass,finite_pass,stage_pass
+energy_gradient,30.0,30.0,1500,1504,-3.2474907706229246e-5,0.000471844593689209,0.0005208165751694364,1.0506133306029564,0.006331177243450358,0.006331177246544735,0.005426825876342798,0.0009043513702019367,4.88752122580247e-10,true,-0.021635908811310094,NaN,NaN,0.0,true,true,true,1.0724768836613807e-15,1.189747019257752e-16,9.275092445037729e-16,0.033805551706260005,0.010836681535021424,0.0348713248197307,true,true,true
+energy_gradient,60.0,60.0,1500,1480,-1.0862359312261775e-5,0.0002887204285014363,0.00030173220311838266,1.0222852040045376,0.002762036357668041,0.0027620363579440176,0.0024316061613017235,0.0003304301966422939,9.991784623635823e-11,true,-6.016613999131602e-5,NaN,NaN,0.0,true,true,true,1.216756091193397e-15,5.0158107346353796e-17,1.0167431393093095e-15,0.012083033705754281,0.004860363185471679,0.012533560480125196,true,true,true
+energy_gradient,90.0,90.0,1500,1508,0.0,0.0001887079484924029,0.00018870794849240287,0.9999999999999999,0.0,0.0,0.0,0.0,0.0,true,0.006322566937281028,NaN,NaN,0.0,true,true,true,0.0,0.0,0.0,0.0,0.0,0.0,true,true,true
+energy_gradient,120.0,120.0,1500,1492,3.6415706340133573e-6,0.00010891503120949087,0.00010115473983370438,0.9637163089148524,-0.0010175961809363965,-0.0010175961811311536,-0.000939088927047358,-7.850725408379566e-5,1.9138946413005476e-10,true,0.005428040822784659,NaN,NaN,0.0,true,true,true,7.409661965623413e-16,1.0489349990484759e-16,6.00834640972785e-16,0.0052082931512441475,0.002487307377432547,0.005452281087356261,true,true,true
+energy_gradient,150.0,150.0,1500,1504,1.9009549829342702e-6,4.383614898332927e-5,3.0486579753179484e-5,0.8339464321022523,-0.0005628923580024262,-0.0005628923579003199,-0.0005278628145207829,-3.502954337953706e-5,1.8139570741236422e-10,true,0.002245230154743737,NaN,NaN,0.0,true,true,true,3.288258148896174e-16,2.6821525371287295e-17,2.652228360013871e-16,0.0039097215511687145,0.0019434991565215282,0.004148923648144211,true,true,true
+force_sign,60.0,90.0,1500,1508,-7.173293435382179e-6,0.00018871115161465607,0.00019925815098283833,1.027564910608314,NaN,NaN,NaN,NaN,NaN,true,-0.010371017361523979,0.006340809670386159,-0.01671182703191014,-1.0,true,true,true,2.0275155016978082e-16,1.693453953003158e-16,1.7262632178505492e-16,0.008544287827499213,0.0038744765057880607,0.008906751411290808,true,true,true
+force_sign,90.0,60.0,1500,1480,0.0,0.0002887204285014363,0.00028872042850143633,1.0,NaN,NaN,NaN,NaN,NaN,true,0.025617239827415973,0.025617239827415973,0.0,1.0,true,true,true,0.0,0.0,0.0,0.0,0.0,0.0,true,true,true
+force_sign,90.0,120.0,1500,1492,0.0,0.00010891690901978293,0.00010891690901978292,0.9999999999999999,NaN,NaN,NaN,NaN,NaN,true,-0.003958004005173301,-0.003958004005173301,0.0,-1.0,true,true,true,0.0,0.0,0.0,0.0,0.0,0.0,true,true,true
+force_sign,120.0,90.0,1500,1508,6.424121233963377e-6,0.0001887079484924029,0.00017844781205453826,0.9724348554497539,NaN,NaN,NaN,NaN,NaN,true,0.02209847804898474,0.006322566937281028,0.015775911111703712,1.0,true,true,true,7.50773883273499e-16,6.996783813673347e-17,6.131494403504238e-16,0.008087798439597272,0.0036672288819493922,0.008428257176716261,true,true,true
diff --git a/validation/surface_tension_3d/contact_angle_static_r4_wetted_area_production.csv b/validation/surface_tension_3d/contact_angle_static_r4_wetted_area_production.csv
new file mode 100644
index 0000000000..ef078bf762
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_static_r4_wetted_area_production.csv
@@ -0,0 +1,10 @@
+kind,target,initial_angle,requested_particles,particle_count,energy,raw_area,corrected_area,area_derivative,finite_difference,analytic_derivative,explicit_derivative,density_derivative,gradient_relative_error,gradient_pass,shape_acceleration,control_acceleration,contact_shape_acceleration,expected_direction,total_sign_pass,contact_sign_pass,zero_at_90,explicit_reaction_residual,density_resultant_residual,total_momentum_residual,explicit_force_scale,density_force_scale,wall_force_scale,reaction_pass,finite_pass,stage_pass,mechanism
+energy_gradient,30.0,30.0,1500,1504,-3.247491644468638e-5,0.0004718447270808841,0.000520816715312508,1.0506133234486987,0.006331174114322776,0.006331174116593396,0.005426825906539668,0.000904348210053728,3.5864113267630977e-10,true,-0.021635871996912814,NaN,NaN,0.0,true,true,true,1.727262207020054e-15,7.024014363252007e-17,1.2348614863239303e-15,0.033805426549199526,0.010836630320529806,0.03487119792323833,true,true,true,wetted_area_production
+energy_gradient,60.0,60.0,1500,1480,-1.0862366326086675e-5,0.0002887206190827627,0.0003017323979468521,1.0222851966494266,0.002762033343633246,0.0027620333432716348,0.0024316057471578563,0.0003304275961137785,1.3092215173195698e-10,true,-6.0138113942466864e-5,NaN,NaN,0.0,true,true,true,1.0247138508623385e-15,1.2957087838599248e-16,1.1880784828369559e-15,0.012082937661063517,0.004860318616333446,0.01253346235915512,true,true,true,wetted_area_production
+energy_gradient,90.0,90.0,1500,1508,0.0,0.00018870814194184656,0.0001887081419418465,0.9999999999999999,0.0,0.0,0.0,0.0,0.0,true,0.006322566937281027,NaN,NaN,0.0,true,true,true,0.0,0.0,0.0,0.0,0.0,0.0,true,true,true,wetted_area_production
+energy_gradient,120.0,120.0,1500,1492,3.6415757278575266e-6,0.00010891517803243889,0.00010115488132937575,0.9637163333709492,-0.0010175948912087523,-0.0010175948913415454,-0.0009390889017877902,-7.850598955375511e-5,1.3049700152381557e-10,true,0.005428028928631595,NaN,NaN,0.0,true,true,true,2.585608039704071e-16,1.6835854229056969e-16,1.66166032478379e-16,0.0052082425510982635,0.002487282287010832,0.005452228628384886,true,true,true,wetted_area_production
+energy_gradient,150.0,150.0,1500,1504,1.9009589083225509e-6,4.383622447191713e-5,3.0486642706623388e-5,0.8339465750793779,-0.0005628922708829067,-0.0005628922708228603,-0.0005278632154359083,-3.502905538695194e-5,1.0667470850964232e-10,true,0.0022452292064080418,NaN,NaN,0.0,true,true,true,4.332878665905483e-16,1.1742503118043945e-16,1.1748360538437305e-16,0.003909698602008243,0.001943486719875262,0.004148899171385761,true,true,true,wetted_area_production
+force_sign,60.0,90.0,1500,1508,-7.173300590626698e-6,0.00018871134503970546,0.00019925834973963052,1.0275648964815915,NaN,NaN,NaN,NaN,NaN,true,-0.010370993758861134,0.006340809670386161,-0.016711803429247295,-1.0,true,true,true,1.4969773209879534e-15,1.9353251458639052e-16,1.4451023284828238e-15,0.008544199646364811,0.003874433974132114,0.0089066608492214,true,true,true,wetted_area_production
+force_sign,90.0,60.0,1500,1480,0.0,0.0002887206190827627,0.00028872061908276277,1.0,NaN,NaN,NaN,NaN,NaN,true,0.025617239827415966,0.025617239827415966,0.0,1.0,true,true,true,0.0,0.0,0.0,0.0,0.0,0.0,true,true,true,wetted_area_production
+force_sign,90.0,120.0,1500,1492,0.0,0.00010891705583345892,0.0001089170558334589,0.9999999999999999,NaN,NaN,NaN,NaN,NaN,true,-0.003958004005173301,-0.003958004005173301,0.0,-1.0,true,true,true,0.0,0.0,0.0,0.0,0.0,0.0,true,true,true,wetted_area_production
+force_sign,120.0,90.0,1500,1508,6.424128006174769e-6,0.00018870814194184656,0.00017844800017152138,0.9724348695786177,NaN,NaN,NaN,NaN,NaN,true,0.022098455726507323,0.006322566937281027,0.015775888789226297,1.0,true,true,true,6.594837146700146e-16,6.515041068541296e-17,1.4369794309643279e-15,0.008087715192056196,0.003667188739325922,0.008428171700382856,true,true,true,wetted_area_production
diff --git a/validation/surface_tension_3d/contact_angle_threshold_r4_wetted_area.csv b/validation/surface_tension_3d/contact_angle_threshold_r4_wetted_area.csv
new file mode 100644
index 0000000000..c922527fe4
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_threshold_r4_wetted_area.csv
@@ -0,0 +1,6 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,shape_acceleration,boundary_shape_acceleration,wetting_energy,explicit_reaction_residual,density_resultant_residual,total_momentum_residual,max_explicit_reaction_residual,max_density_resultant_residual,max_total_momentum_residual,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,reaction_pass,pass
+threshold,30.0,30.0,r4_wetted_area,1500,1504,0.1,4000.0,0.01,1,30.19653104455346,30.201451531616563,0.19653104455345982,0.20145153161656282,-0.025036691146087753,29.38670866061075,0.0,-0.021635908811310094,0.0,-3.2474907706229246e-5,1.0724768836613807e-15,1.189747019257752e-16,9.275092445037729e-16,1.953310503701338e-15,2.1977110804895669e-16,1.712877883253034e-15,0,998.8657410587434,1000.0086119281783,0.0019027455180109154,true,320,32,0.09090909090909091,6.398252276648624e-6,1.2230126506308182e-5,0.6810013315666379,2.7235450065565807,1.1878235718328833,36.37496913,46.7846428,289288,443264,true,true
+threshold,60.0,60.0,r4_wetted_area,1500,1480,0.1,4000.0,0.01,1,59.240154735427446,59.310700670496175,-0.7598452645725544,-0.6892993295038252,0.09284250143799255,65.66748571576534,0.0,-6.016613999131602e-5,0.0,-1.0862359312261775e-5,1.216756091193397e-15,5.0158107346353796e-17,1.0167431393093095e-15,1.905932734181908e-15,2.362995557684334e-16,1.593490915171286e-15,0,999.875611776967,1000.004819421663,0.0009227050563320157,true,316,49,0.13424657534246576,9.414030698844364e-6,1.2230126506308182e-5,1.373297669230533,2.606743907413477,1.0395055894400071,32.695125932,33.30614857,284680,287240,true,true
+threshold,90.0,90.0,r4_wetted_area,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.85482764022284,-2.2117561670355883,-2.1451723597771633,0.03010449716420016,90.58348914564176,5.448616776298459,0.006322566937281028,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348037418,1000.0049605366512,0.0018842586472753206,true,323,44,0.11989100817438691,9.870034116213684e-6,1.2230126506308182e-5,1.2942784041081006,2.5750478998283373,1.0499302911338562,28.956388235,29.325957907,290056,209776,true,true
+threshold,120.0,120.0,r4_wetted_area,1500,1492,0.1,4000.0,0.01,1,118.05922431859437,117.95275190216836,-1.9407756814056256,-2.047248097831641,-0.05486075358740172,116.77910661177684,175.77609588469304,0.005428040822784659,0.0,3.6415706340133573e-6,7.409661965623413e-16,1.0489349990484759e-16,6.00834640972785e-16,1.1398583126369347e-15,2.6934045948314654e-16,9.24251245967206e-16,0,998.9624588867765,1000.0094832219286,0.003695892544815706,true,334,42,0.11170212765957446,8.925679448626058e-6,1.2230126506308182e-5,1.0558984263792481,2.5375608451449434,1.0774282141818203,34.123262836,34.422263754,286984,170720,true,true
+threshold,150.0,150.0,r4_wetted_area,1500,1504,0.1,4000.0,0.01,1,149.99774220905832,150.5697171419865,-0.0022577909416838793,0.5697171419865015,-251.33387709563652,144.6524753184541,180.0,0.002245230154743737,0.0,1.9009549829342702e-6,3.288258148896174e-16,2.6821525371287295e-17,2.652228360013871e-16,8.802865395444085e-16,2.360487355884715e-16,7.094616866163528e-16,0,999.339580604262,1000.0138787313256,0.003477522997337026,true,330,21,0.05982905982905983,9.464188441049915e-6,1.2230126506308182e-5,1.1144480260642244,2.525283670326788,1.026683052489188,31.770913413,32.057283671,289288,165704,true,true
diff --git a/validation/surface_tension_3d/contact_angle_threshold_r4_wetted_area_production.csv b/validation/surface_tension_3d/contact_angle_threshold_r4_wetted_area_production.csv
new file mode 100644
index 0000000000..e94a4ff869
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_threshold_r4_wetted_area_production.csv
@@ -0,0 +1,6 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,shape_acceleration,boundary_shape_acceleration,wetting_energy,explicit_reaction_residual,density_resultant_residual,total_momentum_residual,max_explicit_reaction_residual,max_density_resultant_residual,max_total_momentum_residual,force_consistency_residual,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,reaction_pass,pass
+threshold,30.0,30.0,wetted_area_production,1500,1504,0.1,4000.0,0.01,1,30.19653104455346,30.20145116406,0.19653104455345982,0.20145116405999985,-0.025034820924699552,29.386708455564488,NaN,-0.021635871996912814,0.0,-3.247491644468638e-5,1.727262207020054e-15,7.000023995698484e-17,1.2360394385154407e-15,1.727262207020054e-15,7.000023995698484e-17,1.2360394385154407e-15,4.081679035195169e-18,0,998.8657682684461,1000.0086143654223,0.001902811980984588,true,320,30,0.08571428571428572,6.3982571568885505e-6,1.2230126506308182e-5,0.6810019867434559,2.7189083482456264,1.1889959300487716,32.739859142,43.721092317,265304,548160,true,true
+threshold,60.0,60.0,wetted_area_production,1500,1480,0.1,4000.0,0.01,1,59.240154735427446,59.31070040812313,-0.7598452645725544,-0.6892995918768676,0.0928421561400028,65.66748586166771,NaN,-6.0138113942466864e-5,0.0,-1.0862366326086675e-5,1.0247492582557004e-15,1.335579435011094e-16,1.1859543178325947e-15,1.0247492582557004e-15,1.335579435011094e-16,1.1859543178325947e-15,1.2461124944102086e-18,0,999.8756129247647,1000.0048195854953,0.0009226999890674609,true,315,55,0.14864864864864866,9.414055702017114e-6,1.2230126506308182e-5,1.3724904429934914,2.610699719275579,1.015882284414469,28.979715513,29.660754167,261080,316848,true,true
+threshold,90.0,90.0,wetted_area_production,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.8548276025082,-2.2117561670355883,-2.1451723974918053,0.03010448011230149,90.58348913131027,NaN,0.006322566937281027,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348038162945,1000.0049560481513,0.0018842555157376594,true,323,45,0.12228260869565218,9.870034116213688e-6,1.2230126506308182e-5,1.2942784041080995,2.5682567242380516,1.0455498620055843,29.495612609,29.952225443,266008,198992,true,true
+threshold,120.0,120.0,wetted_area_production,1500,1492,0.1,4000.0,0.01,1,118.05922431859437,117.95275299704642,-1.9407756814056256,-2.0472470029535828,-0.05486018944283355,116.77910640539336,NaN,0.005428028928631595,0.0,3.6415757278575266e-6,3.365174612532466e-16,1.2877651908594786e-16,1.671344178026969e-16,4.3090377145413666e-16,1.2877651908594786e-16,3.338412614354583e-16,3.3651641724453797e-18,0,998.9624579525808,1000.009480586395,0.003695883466334544,true,335,40,0.10666666666666667,2.9481894611230186e-6,1.2230126506308182e-5,1.0561513181894597,2.5161496577845326,1.0573591995045761,29.596094487,29.965766913,263192,141840,true,true
+threshold,150.0,150.0,wetted_area_production,1500,1504,0.1,4000.0,0.01,1,149.99774220905832,150.56971969187893,-0.0022577909416838793,0.5697196918789302,-251.33500647054086,144.65247518899955,NaN,0.0022452292064080418,0.0,1.9009589083225509e-6,4.867567223051944e-16,1.4117598780006027e-16,1.4006815778417268e-16,4.867567223051944e-16,1.4117598780006027e-16,1.4006815778417268e-16,8.57427883829283e-18,0,999.339582807631,1000.0138787578936,0.00347752025920046,true,330,32,0.08839779005524862,9.464199013763122e-6,1.2230126506308182e-5,1.114449515488719,2.5206614372720457,1.0473789469908306,27.990504668,28.343833042,265304,133440,true,true
diff --git a/validation/surface_tension_3d/contact_angle_threshold_replay.csv b/validation/surface_tension_3d/contact_angle_threshold_replay.csv
new file mode 100644
index 0000000000..aa0ef60f86
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_threshold_replay.csv
@@ -0,0 +1,11 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,line_angle,shape_acceleration,boundary_shape_acceleration,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,pass
+threshold,30.0,30.0,geometric,1500,1504,0.1,4000.0,0.01,1,30.19653104455346,29.906041155108717,0.19653104455345982,-0.09395884489128292,0.5219134712036576,29.42062731180421,29.999999999999996,NaN,0.05048472435464338,0.0,0,991.5579377214747,1000.0050755055745,0.0024687265605006195,true,314,6,0.01875,4.345688571716355e-6,1.2230126506308182e-5,1.1539188558422244,2.646950980595081,1.038458429720055,28.83866302,40.173596637,289320,36096,true
+threshold,60.0,60.0,geometric,1500,1480,0.1,4000.0,0.01,1,59.240154735427446,59.2965510028388,-0.7598452645725544,-0.7034489971612032,0.0742207262988952,65.70066998497636,59.99999999999999,NaN,0.023579931115017837,0.0,0,996.0896806367282,1000.0048197818022,0.0022490922320444707,true,315,35,0.1,9.43971568925901e-6,1.2230126506308182e-5,1.2714873423353155,2.643155287904101,1.0385739756263952,25.020314148,25.625949782,284712,35520,true
+threshold,90.0,90.0,geometric,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.69810501918657,-2.2117561670355883,-2.3018949808134295,-0.04075440824865151,90.586582889824,90.00000000000001,NaN,0.005791039899580965,0.0,0,998.5343719232799,1000.0049882754446,0.0016925417536809712,true,323,50,0.13404825737265416,9.540941069913421e-6,1.2230126506308182e-5,1.238323400065117,2.5710108122629625,1.0528795980054548,25.90949597,26.279295021,290088,36192,true
+threshold,120.0,120.0,geometric,1500,1492,0.1,4000.0,0.01,1,118.05922431859437,118.09675778448738,-1.9407756814056256,-1.9032422155126199,0.01933941477761192,116.74914905028588,119.99999999999997,NaN,-0.0018963607143911098,0.0,0,999.1036171591738,1000.0053994945666,0.0013336171416070404,true,328,42,0.11351351351351352,3.305797414773423e-6,1.2230126506308182e-5,1.2815554187563594,2.553001485384407,1.0581503710695217,25.891795492,26.161834904,287016,35808,true
+threshold,150.0,150.0,geometric,1500,1504,0.1,4000.0,0.01,1,149.99774220905832,148.92167262572332,-0.0022577909416838793,-1.0783273742766823,-476.6028437214195,144.62706155788507,150.0,NaN,-0.0006604749104781291,0.0,0,999.6030401109109,1000.0048499783557,0.0008394868641019128,true,326,14,0.041176470588235294,1.4585860343609841e-6,1.2230126506308182e-5,1.264897581069487,2.5595505055321315,0.9974922169019886,23.890061199,24.151818278,289320,36096,true
+threshold,30.0,30.0,contact_line_force,1500,1504,0.1,4000.0,0.01,1,30.19653104455346,29.902683753081494,0.19653104455345982,-0.09731624691850627,0.5048301547492433,29.414705008475153,25.13342532179152,29.997961528987187,0.05255586314603495,0.0,0,992.9055722435127,1000.0045452207295,0.0024208599670719182,true,315,30,0.08695652173913043,1.1848337035380574e-6,1.2230126506308182e-5,1.1736920167655653,2.646959546047255,0.9944125069663887,32.274353925,37.250668219,313480,60160,true
+threshold,60.0,60.0,contact_line_force,1500,1480,0.1,4000.0,0.01,1,59.240154735427446,59.40642682630804,-0.7598452645725544,-0.5935731736919578,0.21882361927218397,65.69710995903569,52.66206821628579,57.559921928182334,0.028238269602213845,0.0,0,997.2830576026915,1000.0048065741016,0.0023288299435341516,true,314,7,0.021806853582554516,8.726963241509691e-6,1.2230126506308182e-5,1.3129632320144562,2.64444962260227,1.012743447851568,26.913658811,27.55598152,308488,59200,true
+threshold,90.0,90.0,contact_line_force,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,88.04368409678906,-2.2117561670355883,-1.9563159032109354,0.11549205451838707,90.58601651233053,76.36025992336582,79.60596287957348,0.009461805342587993,0.0,0,999.0580748748145,1000.0056780948962,0.001897729306743933,true,322,42,0.11538461538461539,1.051545892930318e-5,1.2230126506308182e-5,1.278689194190031,2.5814606624729017,1.0466719469935308,30.201617635,30.57788849,314312,60320,true
+threshold,120.0,120.0,contact_line_force,1500,1492,0.1,4000.0,0.01,1,118.05922431859437,118.4725421543578,-1.9407756814056256,-1.5274578456422034,0.21296527966801027,116.7510130941565,96.7065175275384,89.46814469699957,-0.0013537006206955056,0.0,0,999.5826999653357,1000.0048937112084,0.0014460192434750459,true,326,31,0.08683473389355742,1.0909145545939176e-5,1.2230126506308182e-5,1.3627289170766825,2.5422928621198615,1.0617609133916421,28.984557388,29.273237765,310984,59680,true
+threshold,150.0,150.0,contact_line_force,1500,1504,0.1,4000.0,0.01,1,149.99774220905832,149.21759142029185,-0.0022577909416838793,-0.7824085797081466,-345.5372126635515,144.62575136067653,116.40175288830366,100.39589233264421,-0.002936985619286333,0.0,0,999.6336461095731,1000.0045309453945,0.0009808729583806613,true,323,6,0.0182370820668693,5.183880518828607e-6,1.2230126506308182e-5,1.3719054508913564,2.5831030136292465,1.0112206547928504,26.67658711,26.949165607,313480,60160,true
diff --git a/validation/surface_tension_3d/contact_angle_timestep.csv b/validation/surface_tension_3d/contact_angle_timestep.csv
new file mode 100644
index 0000000000..5c1dbe3047
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_timestep.csv
@@ -0,0 +1,5 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,line_angle,shape_acceleration,boundary_shape_acceleration,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,pass
+timestep,90.0,90.0,geometric,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.69810501918657,-2.2117561670355883,-2.3018949808134295,-0.04075440824865151,90.586582889824,90.00000000000001,NaN,0.005791039899580965,0.0,0,998.5343719232799,1000.0049882754446,0.0016925417536809712,true,323,50,0.13404825737265416,9.540941069913421e-6,1.2230126506308182e-5,1.238323400065117,2.5710108122629625,1.0528795980054548,31.073125318,41.749094946,290088,36192,true
+timestep,30.0,30.0,geometric,3000,3004,0.1,4000.0,0.01,1,30.21799495784461,30.24094631669423,0.21799495784460987,0.24094631669423094,-0.10528389774033742,29.104608544558197,29.999999999999996,NaN,0.05535959583088661,0.0,0,983.6822042734843,1000.005496414489,0.003171963103822103,true,397,74,0.15711252653927812,7.502776488206746e-6,9.707057840908885e-6,1.2657133161815883,2.6172747570264177,0.9956895721038854,66.180331552,67.681864521,577320,72096,true
+timestep,90.0,90.0,contact_line_force,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,88.04368409678906,-2.2117561670355883,-1.9563159032109354,0.11549205451838707,90.58601651233053,76.36025992336582,79.60596287957348,0.009461805342587993,0.0,0,999.0580748748145,1000.0056780948962,0.001897729306743933,true,322,42,0.11538461538461539,1.051545892930318e-5,1.2230126506308182e-5,1.278689194190031,2.5814606624729017,1.0466719469935308,31.634343672,35.735534087,314312,60320,true
+timestep,30.0,30.0,contact_line_force,3000,3004,0.1,4000.0,0.01,1,30.21799495784461,30.247540315234616,0.21799495784460987,0.24754031523461606,-0.1355322970867361,29.104935598573032,27.403516177567393,31.328896085902475,0.05611691236319723,0.0,0,985.4961183217595,1000.0056767357609,0.0030790681020973452,true,397,68,0.14623655913978495,7.520482805200395e-6,9.707057840908885e-6,1.2669861683929287,2.6130338074545607,1.0047012806947846,76.168830231,77.985835244,625480,120160,true
diff --git a/validation/surface_tension_3d/contact_angle_timestep_r4_wetted_area.csv b/validation/surface_tension_3d/contact_angle_timestep_r4_wetted_area.csv
new file mode 100644
index 0000000000..ad25c3c033
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_timestep_r4_wetted_area.csv
@@ -0,0 +1,3 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,shape_acceleration,boundary_shape_acceleration,wetting_energy,explicit_reaction_residual,density_resultant_residual,total_momentum_residual,max_explicit_reaction_residual,max_density_resultant_residual,max_total_momentum_residual,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,reaction_pass,pass
+timestep,90.0,90.0,r4_wetted_area,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.85482764022284,-2.2117561670355883,-2.1451723597771633,0.03010449716420016,90.58348914564176,5.448616776298459,0.006322566937281028,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348037418,1000.0049605366512,0.0018842586472753206,true,323,44,0.11989100817438691,9.870034116213684e-6,1.2230126506308182e-5,1.2942784041081006,2.5750478998283373,1.0499302911338562,32.332569628,42.180101486,290056,209776,true,true
+timestep,30.0,30.0,r4_wetted_area,3000,3004,0.1,4000.0,0.01,1,30.21799495784461,30.212332108031102,0.21799495784460987,0.21233210803110225,0.02597697611677874,29.0974225360716,0.0,-0.018414427558849714,0.0,-3.2819918772100445e-5,4.032580930308914e-15,1.7309993525757667e-16,3.4481197979048692e-15,4.032580930308914e-15,2.504345745434644e-16,3.4481197979048692e-15,0,997.0686857302419,1000.0112948349013,0.002124648159359982,true,385,50,0.11494252873563218,4.6631484413156385e-6,9.707057840908885e-6,0.7412022922826005,2.8335419541383287,1.0674389334353664,77.047003028,78.584187385,577288,725312,true,true
diff --git a/validation/surface_tension_3d/contact_angle_timestep_r4_wetted_area_production.csv b/validation/surface_tension_3d/contact_angle_timestep_r4_wetted_area_production.csv
new file mode 100644
index 0000000000..6cbd01e6ec
--- /dev/null
+++ b/validation/surface_tension_3d/contact_angle_timestep_r4_wetted_area_production.csv
@@ -0,0 +1,3 @@
+kind,target,initial_angle,mechanism,requested_particles,particle_count,threshold,damping,final_time,repeat,initial_circle,final_circle,initial_error,final_error,error_reduction,cap_angle,normal_angle,shape_acceleration,boundary_shape_acceleration,wetting_energy,explicit_reaction_residual,density_resultant_residual,total_momentum_residual,max_explicit_reaction_residual,max_density_resultant_residual,max_total_momentum_residual,force_consistency_residual,below_wall,density_min,density_max,rms_speed,settled,accepted_steps,rejected_steps,rejected_fraction,minimum_dt,dt_reference,eta_p01,eta_median,eta_tail_head,solver_runtime,total_runtime,cache_bytes,contact_cache_bytes,reaction_pass,pass
+timestep,90.0,90.0,wetted_area_production,1500,1508,0.1,4000.0,0.01,1,87.78824383296441,87.8548276025082,-2.2117561670355883,-2.1451723974918053,0.03010448011230149,90.58348913131027,NaN,0.006322566937281027,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0,998.6348038162945,1000.0049560481513,0.0018842555157376594,true,323,45,0.12228260869565218,9.870034116213688e-6,1.2230126506308182e-5,1.2942784041080995,2.5682567242380516,1.0455498620055843,32.934288425,43.044530153,266008,198992,true,true
+timestep,30.0,30.0,wetted_area_production,3000,3004,0.1,4000.0,0.01,1,30.21799495784461,30.21233127138849,0.21799495784460987,0.21233127138848928,0.025980814015697296,29.097422761310686,NaN,-0.018414386185701372,0.0,-3.281992718088949e-5,1.5823286587619446e-15,1.8815846730178187e-17,4.3953534368363677e-16,1.5823286587619446e-15,1.0806713959638782e-16,1.0735617773209046e-15,1.9039285780539785e-18,0,997.0687291576515,1000.0112940858411,0.0021246499281855438,true,384,52,0.11926605504587157,4.663154582717139e-6,9.707057840908885e-6,0.7409284141585771,2.852848320675875,1.065529376970489,68.537532248,70.319042047,529304,856080,true,true
diff --git a/validation/surface_tension_3d/contact_line_cap_transfer.csv b/validation/surface_tension_3d/contact_line_cap_transfer.csv
new file mode 100644
index 0000000000..2e7cce32f5
--- /dev/null
+++ b/validation/surface_tension_3d/contact_line_cap_transfer.csv
@@ -0,0 +1,91 @@
+variant,target,requested_particles,particle_count,measure,coarea_normalization,line_length,analytic_line_length,line_length_error,middle_pass,endpoint_decreasing
+analytic_both,30.0,750,760,0.016678136196102616,3.999999999999993,0.06671254478441034,0.08318250925018945,0.1979979278605568,true,true
+analytic_both,60.0,750,776,0.015457419323452836,3.999999999999993,0.06182967729381123,0.06267210593303608,0.013441843491344807,true,false
+analytic_both,90.0,750,736,0.010645772913460015,3.999999999999993,0.042583091653839986,0.049108914031371555,0.13288468104513107,true,false
+analytic_both,120.0,750,768,0.0075698227205582575,3.999999999999993,0.030279290882232975,0.035722597891021876,0.15237713184787616,true,false
+analytic_both,150.0,750,728,0.00501377442587955,3.999999999999993,0.020055097703518165,0.01957315597740063,0.024622586499284393,true,false
+analytic_both,30.0,1500,1504,0.01745677721165718,3.999999999999993,0.0698271088466286,0.08318250925018945,0.1605553922807449,true,true
+analytic_both,60.0,1500,1480,0.014570974846513133,3.999999999999993,0.058283899386052426,0.06267210593303608,0.07001849517666392,true,false
+analytic_both,90.0,1500,1508,0.012647341707687786,3.999999999999993,0.050589366830751055,0.049108914031371555,0.03014631515642474,true,false
+analytic_both,120.0,1500,1492,0.009277040413785576,3.999999999999993,0.03710816165514224,0.035722597891021876,0.03878675812848975,true,false
+analytic_both,150.0,1500,1504,0.004667699106764702,3.999999999999993,0.018670796427058774,0.01957315597740063,0.04610189339847548,true,false
+analytic_both,30.0,3000,3004,0.018259290624350156,3.999999999999993,0.0730371624974005,0.08318250925018945,0.1219649039712738,true,true
+analytic_both,60.0,3000,2980,0.013281064378137019,3.999999999999993,0.05312425751254798,0.06267210593303608,0.1523460601545733,true,false
+analytic_both,90.0,3000,3004,0.01053300289231669,3.999999999999993,0.042132011569266686,0.049108914031371555,0.14206998056702924,true,false
+analytic_both,120.0,3000,2992,0.007389581284900793,3.999999999999993,0.02955832513960312,0.035722597891021876,0.17255947538373229,true,false
+analytic_both,150.0,3000,3004,0.004646302633356904,3.999999999999993,0.01858521053342758,0.01957315597740063,0.050474509328681694,true,false
+analytic_interface,30.0,750,760,0.013973721731022534,3.999999999999993,0.05589488692409004,0.08318250925018945,0.3280451932993025,false,true
+analytic_interface,60.0,750,776,0.013037649625210148,3.999999999999993,0.0521505985008405,0.06267210593303608,0.16788182358891224,true,false
+analytic_interface,90.0,750,736,0.009178755908714887,3.999999999999993,0.036715023634859484,0.049108914031371555,0.2523755745972036,false,false
+analytic_interface,120.0,750,768,0.006471773622372496,3.999999999999993,0.025887094489489938,0.035722597891021876,0.27533001467409757,false,false
+analytic_interface,150.0,750,728,0.004542232116378975,3.999999999999993,0.01816892846551587,0.01957315597740063,0.07174251886134753,true,false
+analytic_interface,30.0,1500,1504,0.014557816301653405,3.999999999999993,0.058231265206613515,0.08318250925018945,0.29995781887908257,false,true
+analytic_interface,60.0,1500,1480,0.01253642688553281,3.999999999999993,0.05014570754213115,0.06267210593303608,0.19987198777537718,true,false
+analytic_interface,90.0,1500,1508,0.010896413997081276,3.999999999999993,0.04358565598832503,0.049108914031371555,0.11246956182981738,true,false
+analytic_interface,120.0,1500,1492,0.008133276602359652,3.999999999999993,0.03253310640943855,0.035722597891021876,0.08928498121310868,true,false
+analytic_interface,150.0,1500,1504,0.004179796223922251,3.999999999999993,0.016719184895688972,0.01957315597740063,0.145810470473279,true,false
+analytic_interface,30.0,3000,3004,0.015554982867363709,3.999999999999993,0.062219931469454724,0.08318250925018945,0.252007038134486,false,true
+analytic_interface,60.0,3000,2980,0.011220019726549894,3.999999999999993,0.0448800789061995,0.06267210593303608,0.28389068409233,false,false
+analytic_interface,90.0,3000,3004,0.00875085745417981,3.999999999999993,0.03500342981671918,0.049108914031371555,0.2872285916491977,false,false
+analytic_interface,120.0,3000,2992,0.006346386752977707,3.999999999999993,0.025385547011910783,0.035722597891021876,0.2893700763490411,false,false
+analytic_interface,150.0,3000,3004,0.0041665283311494465,3.999999999999993,0.016666113324597755,0.01957315597740063,0.1485219172707446,true,false
+analytic_wall,30.0,750,760,0.016510944989214276,3.999999999999993,0.066043779956857,0.08318250925018945,0.206037656808164,false,true
+analytic_wall,60.0,750,776,0.013352575428189,3.999999999999993,0.0534103017127559,0.06267210593303608,0.14778192119754585,true,true
+analytic_wall,90.0,750,736,0.009593859869461107,3.999999999999993,0.03837543947784436,0.049108914031371555,0.2185646896339528,false,false
+analytic_wall,120.0,750,768,0.0060502960391862984,3.999999999999993,0.024201184156745152,0.035722597891021876,0.3225245198970367,false,false
+analytic_wall,150.0,750,728,0.0040766490963045745,3.999999999999993,0.01630659638521827,0.01957315597740063,0.1668897747483321,true,false
+analytic_wall,30.0,1500,1504,0.017519774810753178,3.999999999999993,0.07007909924301259,0.08318250925018945,0.1575260247050917,true,true
+analytic_wall,60.0,1500,1480,0.013719606480357357,3.999999999999993,0.05487842592142933,0.06267210593303608,0.12435644048620531,true,true
+analytic_wall,90.0,1500,1508,0.009878470314465293,3.999999999999993,0.0395138812578611,0.049108914031371555,0.19538271132163487,true,false
+analytic_wall,120.0,1500,1492,0.006594716224946132,3.999999999999993,0.026378864899784478,0.035722597891021876,0.2615636471832792,false,false
+analytic_wall,150.0,1500,1504,0.003640689293613163,3.999999999999993,0.014562757174452627,0.01957315597740063,0.25598318476249116,false,false
+analytic_wall,30.0,3000,3004,0.018218377011791246,3.999999999999993,0.07287350804716486,0.08318250925018945,0.1239323181754236,true,true
+analytic_wall,60.0,3000,2980,0.013550693538949206,3.999999999999993,0.054202774155796725,0.06267210593303608,0.13513718186347012,true,true
+analytic_wall,90.0,3000,3004,0.009478049057701778,3.999999999999993,0.03791219623080704,0.049108914031371555,0.22799766644018782,false,false
+analytic_wall,120.0,3000,2992,0.005936920312740892,3.999999999999993,0.023747681250963527,0.035722597891021876,0.33521964658309444,false,false
+analytic_wall,150.0,3000,3004,0.0034836883991880997,3.999999999999993,0.013934753596752374,0.01957315597740063,0.28806812693662764,false,false
+compatible_indicator,30.0,750,760,0.020327667069755896,3.999999999999993,0.08131066827902345,0.08318250925018945,0.022502819258986784,true,false
+compatible_indicator,60.0,750,776,0.01621413729309244,3.999999999999993,0.06485654917236965,0.06267210593303608,0.03485511148560416,true,false
+compatible_indicator,90.0,750,736,0.011740923150420522,3.999999999999993,0.046963692601682004,0.049108914031371555,0.043682933577377625,true,false
+compatible_indicator,120.0,750,768,0.007428667250684153,3.999999999999993,0.02971466900273656,0.035722597891021876,0.16818286583225472,true,false
+compatible_indicator,150.0,750,728,0.005008986233983837,3.999999999999993,0.020035944935935312,0.01957315597740063,0.023644064302610346,true,false
+compatible_indicator,30.0,1500,1504,0.021424586125732115,3.999999999999993,0.0856983445029283,0.08318250925018945,0.03024476269610865,true,false
+compatible_indicator,60.0,1500,1480,0.016651298190248263,3.999999999999993,0.06660519276099293,0.06267210593303608,0.06275657678009527,true,false
+compatible_indicator,90.0,1500,1508,0.012059055314184835,3.999999999999993,0.04823622125673926,0.049108914031371555,0.017770557379354956,true,false
+compatible_indicator,120.0,1500,1492,0.008140404087199087,3.999999999999993,0.03256161634879629,0.035722597891021876,0.08848688865990983,true,false
+compatible_indicator,150.0,1500,1504,0.0045348910199434975,3.999999999999993,0.01813956407977396,0.01957315597740063,0.0732427565223468,true,false
+compatible_indicator,30.0,3000,3004,0.022154290501886095,3.999999999999993,0.08861716200754423,0.08318250925018945,0.06533408052177014,true,false
+compatible_indicator,60.0,3000,2980,0.016443511893345076,3.999999999999993,0.06577404757338019,0.06267210593303608,0.04949477274081815,true,false
+compatible_indicator,90.0,3000,3004,0.011636334797395861,3.999999999999993,0.04654533918958336,0.049108914031371555,0.05220182307738552,true,false
+compatible_indicator,120.0,3000,2992,0.007384748323218496,3.999999999999993,0.029538993292873933,0.035722597891021876,0.17310064114071788,true,false
+compatible_indicator,150.0,3000,3004,0.004361268663709543,3.999999999999993,0.01744507465483814,0.01957315597740063,0.1087244859755675,true,false
+production_discrete,30.0,750,760,0.014582978404842574,3.999999999999993,0.05833191361937019,0.08318250925018945,0.29874784801304444,false,true
+production_discrete,60.0,750,776,0.011762161068144617,3.999999999999993,0.047048644272578384,0.06267210593303608,0.24928892093000776,false,true
+production_discrete,90.0,750,736,0.008523335797538565,3.999999999999993,0.034093343190154196,0.049108914031371555,0.3057605963680071,false,false
+production_discrete,120.0,750,768,0.005298040465514016,3.999999999999993,0.021192161862056025,0.035722597891021876,0.4067575396754045,false,false
+production_discrete,150.0,750,728,0.0036133119260711886,3.999999999999993,0.014453247704284728,0.01957315597740063,0.26157806533741423,false,false
+production_discrete,30.0,1500,1504,0.015432895257369583,3.999999999999993,0.06173158102947822,0.08318250925018945,0.25787786896633413,false,true
+production_discrete,60.0,1500,1480,0.012160043537362096,3.999999999999993,0.0486401741494483,0.06267210593303608,0.22389437174140325,false,true
+production_discrete,90.0,1500,1508,0.008779790382782426,3.999999999999993,0.03511916153112964,0.049108914031371555,0.2848719580991963,false,false
+production_discrete,120.0,1500,1492,0.005865660205904878,3.999999999999993,0.02346264082361947,0.035722597891021876,0.3431989214447275,false,false
+production_discrete,150.0,1500,1504,0.0032196975200629095,3.999999999999993,0.012878790080251615,0.01957315597740063,0.34201770551863986,false,false
+production_discrete,30.0,3000,3004,0.016121920695471047,3.999999999999993,0.06448768278188408,0.08318250925018945,0.2247446805442792,false,true
+production_discrete,60.0,3000,2980,0.011969661274473397,3.999999999999993,0.0478786450978935,0.06267210593303608,0.23604537640635703,false,true
+production_discrete,90.0,3000,3004,0.008490491408557774,3.999999999999993,0.03396196563423103,0.049108914031371555,0.30843582465424524,false,false
+production_discrete,120.0,3000,2992,0.00523720393982561,3.999999999999993,0.0209488157593024,0.035722597891021876,0.4135696450966282,false,false
+production_discrete,150.0,3000,3004,0.003074291256289738,3.999999999999993,0.01229716502515893,0.01957315597740063,0.37173315129367157,false,false
+support_moment,30.0,750,760,0.02054093519400042,3.999999999999993,0.08216374077600153,0.08318250925018945,0.012247388103234114,true,false
+support_moment,60.0,750,776,0.015739521375786904,3.999999999999993,0.0629580855031475,0.06267210593303608,0.004563107715208847,true,true
+support_moment,90.0,750,736,0.01076123781638738,3.999999999999993,0.04304495126554944,0.049108914031371555,0.12347987906937541,true,false
+support_moment,120.0,750,768,0.006392966336872589,3.999999999999993,0.02557186534749031,0.035722597891021876,0.28415437685966116,false,false
+support_moment,150.0,750,728,0.004332648752246738,3.999999999999993,0.01733059500898692,0.01957315597740063,0.11457329472073874,true,false
+support_moment,30.0,1500,1504,0.021498074175881527,3.999999999999993,0.08599229670352596,0.08318250925018945,0.033778584929260225,true,false
+support_moment,60.0,1500,1480,0.016162704253551086,3.999999999999993,0.06465081701420423,0.06267210593303608,0.031572436440581164,true,true
+support_moment,90.0,1500,1508,0.01106562605471495,3.999999999999993,0.04426250421885972,0.049108914031371555,0.0986869676942127,true,false
+support_moment,120.0,1500,1492,0.0070517817954302625,3.999999999999993,0.028207127181721,0.035722597891021876,0.21038421483868985,false,false
+support_moment,150.0,1500,1504,0.003711973827544386,3.999999999999993,0.014847895310177517,0.01957315597740063,0.24141536871616154,false,false
+support_moment,30.0,3000,3004,0.02239279936860577,3.999999999999993,0.08957119747442292,0.08318250925018945,0.07680326407343752,true,false
+support_moment,60.0,3000,2980,0.015670939577139786,3.999999999999993,0.06268375830855903,0.06267210593303608,0.00018592602481559695,true,true
+support_moment,90.0,3000,3004,0.010396607526034931,3.999999999999993,0.04158643010413965,0.049108914031371555,0.15317960243279705,true,false
+support_moment,120.0,3000,2992,0.0061433924100623444,3.999999999999993,0.024573569640249333,0.035722597891021876,0.3121001525360678,false,false
+support_moment,150.0,3000,3004,0.003518369086103844,3.999999999999993,0.014073476344415352,0.01957315597740063,0.280980728878637,false,false
diff --git a/validation/surface_tension_3d/contact_line_normalization.csv b/validation/surface_tension_3d/contact_line_normalization.csv
new file mode 100644
index 0000000000..69f46aa906
--- /dev/null
+++ b/validation/surface_tension_3d/contact_line_normalization.csv
@@ -0,0 +1,151 @@
+kernel,angle,cells_per_h,lattice_spacing,phase_count,support,kernel_mass,cutoff_value,kernel_half_profile_integral,half_profile_integral,half_profile_mass_error,coarea_normalization,divergence_normalization,continuous_coarea,profile_coarea_measure,normalized_profile_coarea,profile_coarea_error,coarea_measure,normalized_coarea,coarea_error,wedge_coarea_measure,normalized_wedge_coarea,wedge_coarea_error,gated_coarea_measure,normalized_gated_coarea,gated_coarea_error,divergence_measure,normalized_divergence,divergence_error,coarea_middle_pass,coarea_endpoint_decreasing,wedge_middle_pass,wedge_endpoint_decreasing,gated_middle_pass,gated_endpoint_decreasing,divergence_middle_pass,divergence_endpoint_decreasing
+gaussian,30.0,2,0.5,4,3.0,0.9995599623304753,2.2162811557957435e-5,0.499779981165237,0.4985267036984996,0.002507658397633339,4.0236773405689625,2.0059106013401897,1.0,0.2499776623826019,1.0058294557772736,0.005829455777273562,0.24844832208948073,0.9996758838938229,0.00032411610617710807,0.13466900468405832,0.541864622624221,0.45813537737577903,0.12161681966233738,0.48934684150740876,0.5106531584925913,0.10322531645376595,0.20706075660130505,0.7929392433986949,true,false,false,false,false,true,false,false
+gaussian,60.0,2,0.5,4,3.0,0.9995599623304753,2.2162811557957435e-5,0.499779981165237,0.4985267036984996,0.002507658397633339,4.0236773405689625,2.0059106013401897,1.0,0.25381466654585555,1.0212683224846262,0.02126832248462618,0.25273418670647535,1.0169208202379705,0.01692082023797048,0.1598139064071139,0.6430395939181132,0.35696040608188684,0.14398421478816587,0.5793460224427576,0.42065397755724243,0.22457508478859262,0.4504775433743099,0.5495224566256901,true,true,false,false,false,true,false,false
+gaussian,90.0,2,0.5,4,3.0,0.9995599623304753,2.2162811557957435e-5,0.499779981165237,0.4985267036984996,0.002507658397633339,4.0236773405689625,2.0059106013401897,1.0,0.23097121550890423,0.9293536461668485,0.07064635383315154,0.2484483220894807,0.9996758838938228,0.0003241161061772191,0.1881652958172752,0.7571164370614261,0.24288356293857394,0.1664498910914237,0.6697406551247331,0.33025934487526687,0.46038117155812824,0.9234834726858661,0.07651652731413394,true,true,false,false,false,true,true,true
+gaussian,120.0,2,0.5,4,3.0,0.9995599623304753,2.2162811557957435e-5,0.499779981165237,0.4985267036984996,0.002507658397633339,4.0236773405689625,2.0059106013401897,1.0,0.24324225744607803,0.9787283595546261,0.021271640445373885,0.24416245747248594,0.9824309475496746,0.017569052450325362,0.2165166852274366,0.8711932802047393,0.1288067197952607,0.18816980750837495,0.7571345906506717,0.24286540934932832,1.4408771038737862,2.8902706578887774,1.8902706578887774,true,true,true,false,false,true,false,false
+gaussian,150.0,2,0.5,4,3.0,0.9995599623304753,2.2162811557957435e-5,0.499779981165237,0.4985267036984996,0.002507658397633339,4.0236773405689625,2.0059106013401897,1.0,0.24707926017443255,0.9941672204884076,0.005832779511592445,0.24844832208948067,0.9996758838938227,0.0003241161061773301,0.2416615869504922,0.9723682514986316,0.027631748501368403,0.20621792481887935,0.8297543913128788,0.17024560868712124,2.1590613928328732,4.330884136827776,3.330884136827776,true,false,true,false,true,true,false,false
+gaussian,30.0,4,0.25,4,3.0,0.9995599623304753,2.2162811557957435e-5,0.499779981165237,0.4985267036984996,0.002507658397633339,4.0236773405689625,2.0059106013401897,1.0,0.24984762791576987,1.0053062390395886,0.0053062390395886005,0.24852919030582749,1.0000012715035096,1.2715035095922644e-6,0.13425797092455463,0.540210755399897,0.45978924460010295,0.12922032620278612,0.5199408984830802,0.4800591015169198,0.0971372644260506,0.19484866849740018,0.8051513315025998,true,false,false,false,false,true,false,false
+gaussian,60.0,4,0.25,4,3.0,0.9995599623304753,2.2162811557957435e-5,0.499779981165237,0.4985267036984996,0.002507658397633339,4.0236773405689625,2.0059106013401897,1.0,0.25032095628123413,1.0072107596583557,0.007210759658355714,0.24951242579239524,1.0039574938512554,0.003957493851255434,0.1580105264540808,0.6357833748646576,0.36421662513534236,0.15085901973583554,0.6070080193315274,0.39299198066847263,0.21300033625207407,0.4272596325770605,0.5727403674229394,true,true,false,false,false,true,false,false
+gaussian,90.0,4,0.25,4,3.0,0.9995599623304753,2.2162811557957435e-5,0.499779981165237,0.4985267036984996,0.002507658397633339,4.0236773405689625,2.0059106013401897,1.0,0.23975020823118104,0.9646774802564935,0.03532251974350653,0.2484152308927891,0.9995427355955224,0.0004572644044775753,0.18672790999468564,0.7513328602974173,0.2486671397025827,0.17456727028358124,0.7024023698450235,0.29759763015497653,0.478053671517452,0.9589329277064577,0.04106707229354234,true,true,false,false,false,true,true,true
+gaussian,120.0,4,0.25,4,3.0,0.9995599623304753,2.2162811557957435e-5,0.499779981165237,0.4985267036984996,0.002507658397633339,4.0236773405689625,2.0059106013401897,1.0,0.2467367351673225,0.9927890103787206,0.007210989621279418,0.2473180359931829,0.9951279773397891,0.004872022660210917,0.21544529353529077,0.8668823457301782,0.1331176542698218,0.19576806262166113,0.7877075175778636,0.21229248242213639,1.4538803493035581,2.9163540057481856,1.9163540057481856,true,true,true,false,false,true,false,false
+gaussian,150.0,4,0.25,4,3.0,0.9995599623304753,2.2162811557957435e-5,0.499779981165237,0.4985267036984996,0.002507658397633339,4.0236773405689625,2.0059106013401897,1.0,0.24721006339118454,0.994693530427726,0.005306469572273986,0.24830127147975056,0.9990841996875347,0.000915800312465298,0.23919784906481711,0.9624549651949394,0.03754503480506055,0.21467546921093048,0.863784821040031,0.13621517895996904,2.178892313169621,4.370663190165592,3.370663190165592,true,false,true,false,true,true,false,false
+gaussian,30.0,8,0.125,4,3.0,0.9995599623304753,2.2162811557957435e-5,0.499779981165237,0.4985267036984996,0.002507658397633339,4.0236773405689625,2.0059106013401897,1.0,0.2490079370342109,1.0019275938663774,0.0019275938663774195,0.24873840416475096,1.000843080566993,0.0008430805669930042,0.1343334196222697,0.5405143366152686,0.4594856633847314,0.1258898066547747,0.5065399624454248,0.49346003755457524,0.09606121610871707,0.19269021177010656,0.8073097882298934,true,false,false,false,false,true,false,false
+gaussian,60.0,8,0.125,4,3.0,0.9995599623304753,2.2162811557957435e-5,0.499779981165237,0.4985267036984996,0.002507658397633339,4.0236773405689625,2.0059106013401897,1.0,0.24920536048730102,1.002721962141073,0.002721962141073009,0.24902374977374334,1.0019912192281264,0.001991219228126395,0.15796100146712144,0.6355841022968372,0.36441589770316285,0.14710527627019032,0.5919041668065019,0.40809583319349807,0.21088239013783838,0.4230112220134479,0.5769887779865521,true,true,false,false,false,true,false,false
+gaussian,90.0,8,0.125,4,3.0,0.9995599623304753,2.2162811557957435e-5,0.499779981165237,0.4985267036984996,0.002507658397633339,4.0236773405689625,2.0059106013401897,1.0,0.2441395532164807,0.9823387882137837,0.01766121178621627,0.24850944580043965,0.9999218259845798,7.817401542020708e-5,0.18648424922215243,0.7503524479681899,0.2496475520318101,0.16954510854772975,0.6821948114678054,0.31780518853219464,0.486987617800651,0.9768536252677302,0.02314637473226977,true,true,false,false,false,true,true,true
+gaussian,120.0,8,0.125,4,3.0,0.9995599623304753,2.2162811557957435e-5,0.499779981165237,0.4985267036984996,0.002507658397633339,4.0236773405689625,2.0059106013401897,1.0,0.24785238440866977,0.9972780229511526,0.002721977048847357,0.24799514182713542,0.997852432741031,0.0021475672589690298,0.21500749697718344,0.8651207936395428,0.13487920636045725,0.19048624636623004,0.7664551931938367,0.2335448068061633,1.4569231103114142,2.922457512311188,1.9224575123111882,true,true,true,false,false,true,false,false
+gaussian,150.0,8,0.125,4,3.0,0.9995599623304753,2.2162811557957435e-5,0.499779981165237,0.4985267036984996,0.002507658397633339,4.0236773405689625,2.0059106013401897,1.0,0.24804980788676934,0.9980723913264781,0.00192760867352193,0.24828048743612877,0.9990005714021684,0.000999428597831642,0.2386350788220358,0.9601905593211137,0.039809440678886254,0.20790965376763187,0.8365613627503588,0.16343863724964125,2.178701784287088,4.370281006260257,3.3702810062602566,true,false,true,false,true,true,false,false
+schoenberg_cubic,30.0,2,0.5,4,2.0,1.0,8.711858490014711e-49,0.5000000000000001,0.5000000000000001,2.220446049250313e-16,3.9999999999999982,1.9999999999999996,1.0,0.2514408264101155,1.0057633056404616,0.005763305640461569,0.24997532748013393,0.9999013099205353,9.869007946472497e-5,0.13606139803523645,0.5442455921409456,0.4557544078590544,0.133493651383177,0.5339746055327078,0.4660253944672922,0.1101065170029523,0.22021303400590456,0.7797869659940955,true,false,false,false,false,false,false,false
+schoenberg_cubic,60.0,2,0.5,4,2.0,1.0,8.711858490014711e-49,0.5000000000000001,0.5000000000000001,2.220446049250313e-16,3.9999999999999982,1.9999999999999996,1.0,0.2582340591996413,1.0329362367985648,0.03293623679856483,0.25685201690802356,1.0274080676320938,0.027408067632093802,0.1627198341725116,0.650879336690046,0.34912066330995395,0.15927443192882096,0.6370977277152835,0.3629022722847165,0.23280217979411902,0.4656043595882379,0.534395640411762,true,true,false,false,false,false,false,false
+schoenberg_cubic,90.0,2,0.5,4,2.0,1.0,8.711858490014711e-49,0.5000000000000001,0.5000000000000001,2.220446049250313e-16,3.9999999999999982,1.9999999999999996,1.0,0.2281250000520835,0.9125000002083336,0.08749999979166645,0.24997532748013399,0.9999013099205355,9.869007946450292e-5,0.19054014429625604,0.7621605771850238,0.23783942281497616,0.1834750722963885,0.7339002891855536,0.2660997108144464,0.45104575148099524,0.9020915029619903,0.09790849703800975,true,true,false,false,false,false,true,true
+schoenberg_cubic,120.0,2,0.5,4,2.0,1.0,8.711858490014711e-49,0.5000000000000001,0.5000000000000001,2.220446049250313e-16,3.9999999999999982,1.9999999999999996,1.0,0.24176593830487647,0.9670637532195054,0.03293624678049456,0.24309863805224433,0.9723945522089769,0.02760544779102314,0.2183604544200005,0.8734418176800016,0.12655818231999838,0.20383202366075212,0.8153280946430081,0.18467190535699185,1.227630148234225,2.4552602964684493,1.4552602964684493,true,true,true,false,true,false,false,false
+schoenberg_cubic,150.0,2,0.5,4,2.0,1.0,8.711858490014711e-49,0.5000000000000001,0.5000000000000001,2.220446049250313e-16,3.9999999999999982,1.9999999999999996,1.0,0.24855917358988502,0.9942366943595397,0.005763305640460348,0.24997532748013404,0.9999013099205357,9.869007946428088e-5,0.2450188905572757,0.9800755622291023,0.019924437770897696,0.2257450491719887,0.9029801966879544,0.09701980331204563,1.7967378772462776,3.5934757544925544,2.5934757544925544,true,false,true,false,true,false,false,false
+schoenberg_cubic,30.0,4,0.25,4,2.0,1.0,8.711858490014711e-49,0.5000000000000001,0.5000000000000001,2.220446049250313e-16,3.9999999999999982,1.9999999999999996,1.0,0.2514025160513133,1.0056100642052528,0.005610064205252785,0.25001217629597167,1.0000487051838862,4.8705183886221803e-5,0.13496532344961454,0.5398612937984579,0.46013870620154207,0.13161921491017725,0.5264768596407088,0.4735231403592912,0.0981011159014444,0.19620223180288873,0.8037977681971112,true,false,false,false,false,false,false,false
+schoenberg_cubic,60.0,4,0.25,4,2.0,1.0,8.711858490014711e-49,0.5000000000000001,0.5000000000000001,2.220446049250313e-16,3.9999999999999982,1.9999999999999996,1.0,0.25234174865076714,1.0093669946030681,0.009366994603068113,0.25152029735970044,1.0060811894388013,0.0060811894388013155,0.1589699281684131,0.6358797126736521,0.36412028732634794,0.1540078471588662,0.6160313886354646,0.3839686113645354,0.21841765644698385,0.4368353128939676,0.5631646871060324,true,true,false,false,false,false,false,false
+schoenberg_cubic,90.0,4,0.25,4,2.0,1.0,8.711858490014711e-49,0.5000000000000001,0.5000000000000001,2.220446049250313e-16,3.9999999999999982,1.9999999999999996,1.0,0.2390625000260417,0.9562500001041664,0.04374999989583361,0.2499915310751925,0.9999661243007696,3.387569923041234e-5,0.1881576675293838,0.7526306701175348,0.24736932988246518,0.17819502517747632,0.7127801007099049,0.28721989929009506,0.4735725672264054,0.9471451344528106,0.052854865547189434,true,true,false,false,false,false,true,true
+schoenberg_cubic,120.0,4,0.25,4,2.0,1.0,8.711858490014711e-49,0.5000000000000001,0.5000000000000001,2.220446049250313e-16,3.9999999999999982,1.9999999999999996,1.0,0.24765825135425792,0.9906330054170313,0.009366994582968746,0.248462764790685,0.9938510591627395,0.006148940837260475,0.21734540689035461,0.8693816275614181,0.13061837243858188,0.2005828365381764,0.8023313461527053,0.1976686538472947,1.2460197081471451,2.49203941629429,1.4920394162942898,true,true,true,false,true,false,false,false
+schoenberg_cubic,150.0,4,0.25,4,2.0,1.0,8.711858490014711e-49,0.5000000000000001,0.5000000000000001,2.220446049250313e-16,3.9999999999999982,1.9999999999999996,1.0,0.24859748394868703,0.9943899357947477,0.005610064205252341,0.2499708858544141,0.999883543417656,0.00011645658234404888,0.24135001160915365,0.9654000464366141,0.034599953563385855,0.21923852872149735,0.876954114885989,0.12304588511401104,1.8187151024403023,3.6374302048806038,2.6374302048806038,true,false,true,false,true,false,false,false
+schoenberg_cubic,30.0,8,0.125,4,2.0,1.0,8.711858490014711e-49,0.5000000000000001,0.5000000000000001,2.220446049250313e-16,3.9999999999999982,1.9999999999999996,1.0,0.25078247117718705,1.0031298847087478,0.0031298847087477544,0.25025894227806067,1.0010357691122422,0.0010357691122422352,0.13501978120343783,0.5400791248137511,0.4599208751862489,0.12730909014519576,0.5092363605807828,0.4907636394192172,0.09648466297130216,0.19296932594260427,0.8070306740573957,true,false,false,false,false,false,false,false
+schoenberg_cubic,60.0,8,0.125,4,2.0,1.0,8.711858490014711e-49,0.5000000000000001,0.5000000000000001,2.220446049250313e-16,3.9999999999999982,1.9999999999999996,1.0,0.25098464767729084,1.003938590709163,0.003938590709162915,0.2506996015134732,1.0027984060538924,0.002798406053892366,0.15881115316922687,0.6352446126769071,0.3647553873230929,0.1484847062954314,0.5939388251817254,0.40606117481827464,0.21547382753276448,0.43094765506552885,0.5690523449344711,true,true,false,false,false,false,false,false
+schoenberg_cubic,90.0,8,0.125,4,2.0,1.0,8.711858490014711e-49,0.5000000000000001,0.5000000000000001,2.220446049250313e-16,3.9999999999999982,1.9999999999999996,1.0,0.2445312500130212,0.9781250000520844,0.02187499994791564,0.24999924307777374,0.9999969723110945,3.0276889054769995e-6,0.18766056611427703,0.7506422644571078,0.2493577355428922,0.17079925453285155,0.6831970181314059,0.3168029818685941,0.4848038370803263,0.9696076741606524,0.03039232583934759,true,true,false,false,false,false,true,true
+schoenberg_cubic,120.0,8,0.125,4,2.0,1.0,8.711858490014711e-49,0.5000000000000001,0.5000000000000001,2.220446049250313e-16,3.9999999999999982,1.9999999999999996,1.0,0.24901535232302374,0.9960614092920945,0.003938590707905476,0.24929888464207414,0.9971955385682961,0.002804461431703875,0.21650997905932687,0.8660399162373071,0.13396008376269286,0.19191555123175413,0.7676622049270162,0.2323377950729838,1.2515001817978504,2.5030003635957003,1.5030003635957003,true,true,true,false,true,false,false,false
+schoenberg_cubic,150.0,8,0.125,4,2.0,1.0,8.711858490014711e-49,0.5000000000000001,0.5000000000000001,2.220446049250313e-16,3.9999999999999982,1.9999999999999996,1.0,0.24921752882281303,0.9968701152912517,0.0031298847087483095,0.24973954387748715,0.9989581755099481,0.001041824490051857,0.24030135102511624,0.9612054041004645,0.03879459589953549,0.2094798467279583,0.8379193869118329,0.16208061308816712,1.8215428433991192,3.6430856867982375,2.6430856867982375,true,false,true,false,true,false,false,false
+schoenberg_quartic,30.0,2,0.5,4,2.5,1.0,6.190147765049908e-64,0.49999999999999994,0.49999999999999994,1.1102230246251565e-16,4.000000000000001,2.0000000000000004,1.0,0.2514377929430363,1.0057511717721455,0.005751171772145458,0.24994608583854558,0.9997843433541825,0.00021565664581746358,0.13551369221827622,0.542054768873105,0.457945231126895,0.12849150161757739,0.5139660064703097,0.48603399352969034,0.10518336909084955,0.21036673818169915,0.7896332618183008,true,false,false,false,false,false,false,false
+schoenberg_quartic,60.0,2,0.5,4,2.5,1.0,6.190147765049908e-64,0.49999999999999994,0.49999999999999994,1.1102230246251565e-16,4.000000000000001,2.0000000000000004,1.0,0.25647854576820134,1.0259141830728056,0.025914183072805574,0.25535028315062325,1.0214011326024932,0.02140113260249321,0.1613929883519756,0.6455719534079025,0.3544280465920975,0.15198229618288614,0.6079291847315447,0.39207081526845533,0.23069861524363003,0.46139723048726017,0.5386027695127398,true,true,false,false,false,false,false,false
+schoenberg_quartic,90.0,2,0.5,4,2.5,1.0,6.190147765049908e-64,0.49999999999999994,0.49999999999999994,1.1102230246251565e-16,4.000000000000001,2.0000000000000004,1.0,0.23048502610015864,0.9219401044006348,0.07805989559936521,0.2499460858385457,0.999784343354183,0.0002156566458170195,0.18975759701930986,0.7590303880772397,0.24096961192276034,0.17638124662224433,0.7055249864889774,0.29447501351102257,0.458838705167423,0.9176774103348462,0.08232258966515382,true,true,false,false,false,false,true,true
+schoenberg_quartic,120.0,2,0.5,4,2.5,1.0,6.190147765049908e-64,0.49999999999999994,0.49999999999999994,1.1102230246251565e-16,4.000000000000001,2.0000000000000004,1.0,0.2435214548658571,0.9740858194634286,0.025914180536571352,0.24454188852646797,0.9781675541058721,0.021832445894127916,0.21812220568664428,0.8724888227465774,0.12751117725342265,0.1982204895555758,0.7928819582223035,0.20711804177769655,1.3613792286599615,2.7227584573199235,1.7227584573199235,true,true,true,false,false,false,false,false
+schoenberg_quartic,150.0,2,0.5,4,2.5,1.0,6.190147765049908e-64,0.49999999999999994,0.49999999999999994,1.1102230246251565e-16,4.000000000000001,2.0000000000000004,1.0,0.24856220705696383,0.9942488282278555,0.005751171772144459,0.2499460858385456,0.9997843433541826,0.00021565664581735255,0.2440015018203436,0.9760060072813747,0.023993992718625345,0.215917187494086,0.8636687499763442,0.13633125002365576,2.022153518373352,4.044307036746705,3.044307036746705,true,false,true,false,true,false,false,false
+schoenberg_quartic,30.0,4,0.25,4,2.5,1.0,6.190147765049908e-64,0.49999999999999994,0.49999999999999994,1.1102230246251565e-16,4.000000000000001,2.0000000000000004,1.0,0.25138820754859637,1.0055528301943857,0.005552830194385683,0.25006596865367936,1.0002638746147177,0.00026387461471766294,0.1349951445912775,0.5399805783651102,0.46001942163488985,0.12754742545736436,0.5101897018294576,0.48981029817054245,0.09792210136867419,0.19584420273734843,0.8041557972626516,true,false,false,false,false,false,false,false
+schoenberg_quartic,60.0,4,0.25,4,2.5,1.0,6.190147765049908e-64,0.49999999999999994,0.49999999999999994,1.1102230246251565e-16,4.000000000000001,2.0000000000000004,1.0,0.2520250036800589,1.008100014720236,0.008100014720235915,0.25125856180548534,1.0050342472219416,0.005034247221941568,0.15892085499057626,0.6356834199623052,0.36431658003769485,0.14933833509139763,0.5973533403655906,0.4026466596344094,0.21733691626578772,0.43467383253157554,0.5653261674684245,true,true,false,false,false,false,false,false
+schoenberg_quartic,90.0,4,0.25,4,2.5,1.0,6.190147765049908e-64,0.49999999999999994,0.49999999999999994,1.1102230246251565e-16,4.000000000000001,2.0000000000000004,1.0,0.24024251305007915,0.9609700522003168,0.03902994779968316,0.250001014228753,1.0000040569150122,4.056915012151663e-6,0.18802361141586257,0.7520944456634505,0.24790555433654948,0.17281428672839388,0.6912571469135756,0.30874285308642435,0.4786425560869989,0.957285112173998,0.04271488782600197,true,true,false,false,false,false,true,true
+schoenberg_quartic,120.0,4,0.25,4,2.5,1.0,6.190147765049908e-64,0.49999999999999994,0.49999999999999994,1.1102230246251565e-16,4.000000000000001,2.0000000000000004,1.0,0.24797499692438382,0.9918999876975355,0.008100012302464488,0.2487434666520201,0.9949738666080806,0.005026133391919374,0.21712636784114878,0.8685054713645953,0.13149452863540467,0.1940010202316258,0.7760040809265034,0.22399591907349659,1.3784524329471406,2.7569048658942816,1.7569048658942816,true,true,true,false,false,false,false,false
+schoenberg_quartic,150.0,4,0.25,4,2.5,1.0,6.190147765049908e-64,0.49999999999999994,0.49999999999999994,1.1102230246251565e-16,4.000000000000001,2.0000000000000004,1.0,0.24861179245140375,0.9944471698056152,0.005552830194384795,0.2499360598038263,0.9997442392153054,0.00025576078469458086,0.2410520782404475,0.9642083129617902,0.035791687038209785,0.2122434806081962,0.848973922432785,0.15102607756721498,2.0456013401466757,4.091202680293352,3.0912026802933523,true,false,true,false,true,false,false,false
+schoenberg_quartic,30.0,8,0.125,4,2.5,1.0,6.190147765049908e-64,0.49999999999999994,0.49999999999999994,1.1102230246251565e-16,4.000000000000001,2.0000000000000004,1.0,0.2506127822737623,1.0024511290950495,0.00245112909504952,0.25024979167210093,1.000999166688404,0.0009991666884039407,0.13504051758826446,0.540162070353058,0.45983792964694203,0.12756558055864337,0.5102623222345736,0.4897376777654264,0.09643235968703735,0.19286471937407476,0.8071352806259252,true,false,false,false,false,false,false,false
+schoenberg_quartic,60.0,8,0.125,4,2.5,1.0,6.190147765049908e-64,0.49999999999999994,0.49999999999999994,1.1102230246251565e-16,4.000000000000001,2.0000000000000004,1.0,0.2508163058499339,1.003265223399736,0.003265223399735895,0.25060779679823597,1.002431187192944,0.0024311871929441065,0.1588180557608401,0.6352722230433605,0.3647277769566395,0.14877051447367776,0.5950820578947111,0.40491794210528886,0.21469478156571395,0.429389563131428,0.570610436868572,true,true,false,false,false,false,false,false
+schoenberg_quartic,90.0,8,0.125,4,2.5,1.0,6.190147765049908e-64,0.49999999999999994,0.49999999999999994,1.1102230246251565e-16,4.000000000000001,2.0000000000000004,1.0,0.2451212565250402,0.980485026100161,0.019514973899839028,0.2500000338017211,1.0000001352068846,1.3520688457724361e-7,0.18762788859114435,0.7505115543645776,0.24948844563542238,0.17150303445237355,0.6860121378094943,0.3139878621905057,0.48851201497889346,0.9770240299577871,0.022975970042212857,true,true,false,false,false,false,true,true
+schoenberg_quartic,120.0,8,0.125,4,2.5,1.0,6.190147765049908e-64,0.49999999999999994,0.49999999999999994,1.1102230246251565e-16,4.000000000000001,2.0000000000000004,1.0,0.249183694243725,0.9967347769749002,0.003265223025099795,0.24939227080520637,0.9975690832208257,0.002430916779174286,0.2164377214214481,0.8657508856857926,0.13424911431420739,0.19236242869995931,0.7694497147998375,0.23055028520016252,1.38109988438911,2.7621997687782205,1.7621997687782205,true,true,true,false,false,false,false,false
+schoenberg_quartic,150.0,8,0.125,4,2.5,1.0,6.190147765049908e-64,0.49999999999999994,0.49999999999999994,1.1102230246251565e-16,4.000000000000001,2.0000000000000004,1.0,0.24938721772623684,0.9975488709049476,0.0024511290950524067,0.24975027593134158,0.9990011037253665,0.000998896274633454,0.24021525959402407,0.9608610383760965,0.03913896162390351,0.2099543150166926,0.8398172600667706,0.1601827399332294,2.0454178896448503,4.090835779289701,3.0908357792897014,true,false,true,false,true,false,false,false
+schoenberg_quintic,30.0,2,0.5,4,3.0,1.0,4.581629716393575e-80,0.5000000000000013,0.5000000000000013,2.6645352591003757e-15,3.9999999999999787,1.9999999999999947,1.0,0.25143596803787654,1.0057438721515009,0.005743872151500851,0.25000041652497984,1.000001666099914,1.6660999140327704e-6,0.1354151451015029,0.5416605804060087,0.45833941959399127,0.12440904023068779,0.4976361609227485,0.5023638390772516,0.10334843520371803,0.2066968704074355,0.7933031295925645,true,false,false,false,false,true,false,false
+schoenberg_quintic,60.0,2,0.5,4,3.0,1.0,4.581629716393575e-80,0.5000000000000013,0.5000000000000013,2.6645352591003757e-15,3.9999999999999787,1.9999999999999947,1.0,0.2553529745416035,1.0214118981664084,0.021411898166408427,0.2543892140133845,1.0175568560535326,0.017556856053532632,0.16071720250880875,0.6428688100352316,0.35713118996476845,0.14661455420054811,0.5864582168021893,0.41354178319781065,0.2284994954314167,0.4569989908628322,0.5430010091371678,true,true,false,false,false,true,false,false
+schoenberg_quintic,90.0,2,0.5,4,3.0,1.0,4.581629716393575e-80,0.5000000000000013,0.5000000000000013,2.6645352591003757e-15,3.9999999999999787,1.9999999999999947,1.0,0.2322172619174837,0.9288690476699298,0.07113095233007016,0.25000041652497973,1.0000016660999136,1.6660999135886811e-6,0.18937361617503512,0.7574944647001365,0.24250553529986352,0.16958213049545492,0.678328521981816,0.32167147801818397,0.4635822045510813,0.9271644091021601,0.07283559089783986,true,true,false,false,false,true,true,true
+schoenberg_quintic,120.0,2,0.5,4,3.0,1.0,4.581629716393575e-80,0.5000000000000013,0.5000000000000013,2.6645352591003757e-15,3.9999999999999787,1.9999999999999947,1.0,0.24464702535715213,0.9785881014286033,0.021411898571396693,0.24561161903657505,0.982446476146295,0.01755352385370501,0.2180300298412615,0.8721201193650413,0.1278798806349587,0.19185342458245036,0.7674136983297973,0.23258630167020267,1.4757180229641227,2.9514360459282374,1.9514360459282374,true,true,true,false,false,true,false,false
+schoenberg_quintic,150.0,2,0.5,4,3.0,1.0,4.581629716393575e-80,0.5000000000000013,0.5000000000000013,2.6645352591003757e-15,3.9999999999999787,1.9999999999999947,1.0,0.24856403185066372,0.9942561274026496,0.00574387259735043,0.2500004165249796,1.0000016660999131,1.666099913144592e-6,0.24333208724856736,0.9733283489942642,0.026671651005735764,0.2101047468382771,0.8404189873531039,0.15958101264689606,2.2167762030920755,4.433552406184139,3.4335524061841394,true,false,true,false,true,true,false,false
+schoenberg_quintic,30.0,4,0.25,4,3.0,1.0,4.581629716393575e-80,0.5000000000000013,0.5000000000000013,2.6645352591003757e-15,3.9999999999999787,1.9999999999999947,1.0,0.25134629851817747,1.0053851940727045,0.005385194072704547,0.2501078020453274,1.0004312081813043,0.00043120818130426564,0.13502395159795538,0.5400958063918186,0.45990419360818136,0.1311322267240242,0.524528906896094,0.475471093103906,0.097504599387282,0.19500919877456346,0.8049908012254365,true,false,false,false,false,true,false,false
+schoenberg_quintic,60.0,4,0.25,4,3.0,1.0,4.581629716393575e-80,0.5000000000000013,0.5000000000000013,2.6645352591003757e-15,3.9999999999999787,1.9999999999999947,1.0,0.25181213458425233,1.007248538337004,0.007248538337004007,0.25110235088286115,1.0044094035314393,0.00440940353143926,0.1589081905109734,0.6356327620438902,0.3643672379561098,0.15281886402672543,0.6112754561068985,0.3887245438931015,0.2163131525716079,0.43262630514321465,0.5673736948567853,true,true,false,false,false,true,false,false
+schoenberg_quintic,90.0,4,0.25,4,3.0,1.0,4.581629716393575e-80,0.5000000000000013,0.5000000000000013,2.6645352591003757e-15,3.9999999999999787,1.9999999999999947,1.0,0.24110863098517546,0.9644345239406967,0.03556547605930327,0.24999992606991514,0.9999997042796552,2.957203447673251e-7,0.1879320811334822,0.7517283245339248,0.2482716754660752,0.17635768593038711,0.7054307437215447,0.2945692562784553,0.4814602795879185,0.9629205591758344,0.03707944082416559,true,true,false,false,false,true,true,true
+schoenberg_quintic,120.0,4,0.25,4,3.0,1.0,4.581629716393575e-80,0.5000000000000013,0.5000000000000013,2.6645352591003757e-15,3.9999999999999787,1.9999999999999947,1.0,0.2481878654172774,0.9927514616691042,0.007248538330895782,0.24889750125696872,0.9955900050278695,0.00440999497213046,0.21695597175599055,0.8678238870239575,0.13217611297604248,0.19855891562353792,0.7942356624941475,0.20576433750585255,1.4891749342504725,2.978349868500937,1.978349868500937,true,true,true,false,false,true,false,false
+schoenberg_quintic,150.0,4,0.25,4,3.0,1.0,4.581629716393575e-80,0.5000000000000013,0.5000000000000013,2.6645352591003757e-15,3.9999999999999787,1.9999999999999947,1.0,0.24865370148300128,0.9946148059319998,0.005385194068000199,0.24989205009450222,0.9995682003780035,0.0004317996219964648,0.2408402106690088,0.9633608426760301,0.03663915732396994,0.21719930114415284,0.8687972045766067,0.1312027954233933,2.237585992057907,4.475171984115803,3.4751719841158026,true,false,true,false,true,true,false,false
+schoenberg_quintic,30.0,8,0.125,4,3.0,1.0,4.581629716393575e-80,0.5000000000000013,0.5000000000000013,2.6645352591003757e-15,3.9999999999999787,1.9999999999999947,1.0,0.25049355143975804,1.0019742057590268,0.001974205759026848,0.2502341780285791,1.0009367121143111,0.0009367121143111223,0.13504569317669718,0.5401827727067858,0.45981722729321417,0.12811322934603622,0.5124529173841421,0.4875470826158579,0.09644272999012436,0.1928854599802482,0.8071145400197518,true,false,false,false,false,true,false,false
+schoenberg_quintic,60.0,8,0.125,4,3.0,1.0,4.581629716393575e-80,0.5000000000000013,0.5000000000000013,2.6645352591003757e-15,3.9999999999999787,1.9999999999999947,1.0,0.2506939597864552,1.0027758391458155,0.002775839145815473,0.25053159640742834,1.002126385629708,0.0021263856297080164,0.15881081117021492,0.6352432446808564,0.36475675531914364,0.14945809898376772,0.5978323959350677,0.40216760406493235,0.2141146591665109,0.42822931833302064,0.5717706816669794,true,true,false,false,false,true,false,false
+schoenberg_quintic,90.0,8,0.125,4,3.0,1.0,4.581629716393575e-80,0.5000000000000013,0.5000000000000013,2.6645352591003757e-15,3.9999999999999787,1.9999999999999947,1.0,0.24555431549226905,0.982217261969071,0.017782738030929002,0.24999999895622205,0.9999999958248829,4.1751171409032395e-9,0.18760604935668793,0.7504241974267477,0.2495758025732523,0.17216268719415037,0.6886507487765978,0.3113492512234022,0.490397072351811,0.9807941447036194,0.019205855296380636,true,true,false,false,false,true,true,true
+schoenberg_quintic,120.0,8,0.125,4,3.0,1.0,4.581629716393575e-80,0.5000000000000013,0.5000000000000013,2.6645352591003757e-15,3.9999999999999787,1.9999999999999947,1.0,0.24930604021371114,0.9972241608548392,0.0027758391451607745,0.2494684015050181,0.997873606020067,0.0021263939799329723,0.2164012875431616,0.8656051501726417,0.13439484982735828,0.19329411440556044,0.7731764576222376,0.22682354237776237,1.4920770886197956,2.984154177239583,1.9841541772395832,true,true,true,false,false,true,false,false
+schoenberg_quintic,150.0,8,0.125,4,3.0,1.0,4.581629716393575e-80,0.5000000000000013,0.5000000000000013,2.6645352591003757e-15,3.9999999999999787,1.9999999999999947,1.0,0.2495064485601715,0.9980257942406807,0.0019742057593192808,0.24976581988386776,0.9990632795354657,0.0009367204645343019,0.24016640553667903,0.960665622146711,0.03933437785328897,0.21102138849046517,0.8440855539618561,0.15591444603814386,2.237145308265167,4.474290616530323,3.4742906165303227,true,false,true,false,true,true,false,false
+wendland_c2,30.0,2,0.5,4,2.0,1.0,3.1736597428234396e-64,0.5000000000000004,0.5000000000000004,8.881784197001252e-16,3.999999999999993,1.9999999999999982,1.0,0.25143097484129545,1.00572389936518,0.00572389936518003,0.24970644066787795,0.99882576267151,0.0011742373284899799,0.13652553286316227,0.5461021314526481,0.4538978685473519,0.1350913666333437,0.5403654665333738,0.4596345334666262,0.1143127969634207,0.2286255939268412,0.7713744060731588,true,true,false,false,false,false,false,false
+wendland_c2,60.0,2,0.5,4,2.0,1.0,3.1736597428234396e-64,0.5000000000000004,0.5000000000000004,8.881784197001252e-16,3.999999999999993,1.9999999999999982,1.0,0.2593891948290387,1.037556779316153,0.03755677931615309,0.25734400569416005,1.0293760227766384,0.029376022776638422,0.16354423439134103,0.654176937565363,0.345823062434637,0.16165448757671122,0.6466179503068438,0.35338204969315623,0.23474181154539792,0.4694836230907954,0.5305163769092046,true,true,false,false,false,false,false,false
+wendland_c2,90.0,2,0.5,4,2.0,1.0,3.1736597428234396e-64,0.5000000000000004,0.5000000000000004,8.881784197001252e-16,3.999999999999993,1.9999999999999982,1.0,0.22653571523359597,0.9061428609343823,0.09385713906561766,0.249706440667878,0.9988257626715102,0.0011742373284897578,0.19096009304692607,0.763840372187703,0.23615962781229705,0.18628970818359708,0.745158832734387,0.254841167265613,0.4497130822436496,0.8994261644872984,0.1005738355127016,true,true,false,false,false,false,true,true
+wendland_c2,120.0,2,0.5,4,2.0,1.0,3.1736597428234396e-64,0.5000000000000004,0.5000000000000004,8.881784197001252e-16,3.999999999999993,1.9999999999999982,1.0,0.2405516785344192,0.9622067141376751,0.03779328586232489,0.24206887564159593,0.9682755025663821,0.03172449743361794,0.21837595170251095,0.8735038068100422,0.12649619318995775,0.2069861684479963,0.8279446737919838,0.17205532620801622,1.3006901475710109,2.6013802951420195,1.6013802951420195,true,true,true,false,true,false,false,false
+wendland_c2,150.0,2,0.5,4,2.0,1.0,3.1736597428234396e-64,0.5000000000000004,0.5000000000000004,8.881784197001252e-16,3.999999999999993,1.9999999999999982,1.0,0.24850989715558636,0.9940395886223437,0.005960411377656349,0.249706440667878,0.9988257626715102,0.0011742373284897578,0.2453946532306899,0.9815786129227578,0.01842138707724217,0.2296821044924859,0.918728417969942,0.08127158203005802,1.9242823530195254,3.8485647060390473,2.8485647060390473,true,true,true,false,true,false,false,false
+wendland_c2,30.0,4,0.25,4,2.0,1.0,3.1736597428234396e-64,0.5000000000000004,0.5000000000000004,8.881784197001252e-16,3.999999999999993,1.9999999999999982,1.0,0.25141534576837493,1.005661383073498,0.00566138307349795,0.2500108698063474,1.0000434792253878,4.347922538783244e-5,0.13504756803685683,0.5401902721474263,0.4598097278525737,0.13262546094931704,0.5305018437972673,0.4694981562027327,0.098149459721995,0.19629891944398983,0.8037010805560102,true,true,false,false,false,false,false,false
+wendland_c2,60.0,4,0.25,4,2.0,1.0,3.1736597428234396e-64,0.5000000000000004,0.5000000000000004,8.881784197001252e-16,3.999999999999993,1.9999999999999982,1.0,0.2526015210732147,1.010406084292857,0.010406084292857054,0.25176116611884064,1.0070446644753608,0.0070446644753607846,0.15914434491056717,0.6365773796422676,0.36342262035773243,0.15453843207803955,0.6181537283121571,0.3818462716878429,0.21806589556882977,0.43613179113765915,0.5638682088623408,true,true,false,false,false,false,false,false
+wendland_c2,90.0,4,0.25,4,2.0,1.0,3.1736597428234396e-64,0.5000000000000004,0.5000000000000004,8.881784197001252e-16,3.999999999999993,1.9999999999999982,1.0,0.23828080986806974,0.9531232394722773,0.046876760527722716,0.2500009104080541,1.0000036416322147,3.641632214668178e-6,0.1882693176442488,0.7530772705769939,0.24692272942300608,0.17741716353087544,0.7096686541235006,0.29033134587649945,0.4738079056785738,0.9476158113571467,0.052384188642853324,true,true,false,false,false,false,true,true
+wendland_c2,120.0,4,0.25,4,2.0,1.0,3.1736597428234396e-64,0.5000000000000004,0.5000000000000004,8.881784197001252e-16,3.999999999999993,1.9999999999999982,1.0,0.24739755505855313,0.9895902202342107,0.010409779765789273,0.24824065469726725,0.9929626187890672,0.0070373812109327805,0.21739429037793034,0.8695771615117198,0.13042283848828018,0.2001735536399794,0.8006942145599162,0.19930578544008382,1.3206027239721034,2.6412054479442046,1.6412054479442046,true,true,true,false,true,false,false,false
+wendland_c2,150.0,4,0.25,4,2.0,1.0,3.1736597428234396e-64,0.5000000000000004,0.5000000000000004,8.881784197001252e-16,3.999999999999993,1.9999999999999982,1.0,0.24858373035781187,0.9943349214312457,0.0056650785687543115,0.2499909510097604,0.9999638040390398,3.619596096016142e-5,0.24149106725164055,0.9659642690065605,0.034035730993439484,0.21926568121668696,0.8770627248667463,0.1229372751332537,1.9487493614476017,3.8974987228952,2.8974987228952,true,true,true,false,true,false,false,false
+wendland_c2,30.0,8,0.125,4,2.0,1.0,3.1736597428234396e-64,0.5000000000000004,0.5000000000000004,8.881784197001252e-16,3.999999999999993,1.9999999999999982,1.0,0.25087464061398024,1.0034985624559192,0.003498562455919174,0.25025407506417224,1.0010163002566872,0.001016300256687197,0.13506680662517342,0.5402672265006927,0.4597327734993073,0.12929271655845698,0.517170866233827,0.482829133766173,0.09639133246443168,0.1927826649288632,0.8072173350711368,true,true,false,false,false,false,false,false
+wendland_c2,60.0,8,0.125,4,2.0,1.0,3.1736597428234396e-64,0.5000000000000004,0.5000000000000004,8.881784197001252e-16,3.999999999999993,1.9999999999999982,1.0,0.25108715977505136,1.0043486391002037,0.004348639100203666,0.2507477472336396,1.0029909889345565,0.0029909889345565155,0.15886998386626103,0.635479935465043,0.364520064534957,0.1508861248031858,0.6035444992127421,0.3964555007872579,0.21451894085534218,0.42903788171068397,0.5709621182893161,true,true,false,false,false,false,false,false
+wendland_c2,90.0,8,0.125,4,2.0,1.0,3.1736597428234396e-64,0.5000000000000004,0.5000000000000004,8.881784197001252e-16,3.999999999999993,1.9999999999999982,1.0,0.24414061797034006,0.9765624718813585,0.023437528118641526,0.24999978451056898,0.9999991380422741,8.619577258617284e-7,0.18768501009656488,0.7507400403862582,0.2492599596137418,0.17362210887217502,0.6944884354886989,0.3055115645113011,0.4857199819354194,0.9714399638708379,0.028560036129162136,true,true,false,false,false,false,true,true
+wendland_c2,120.0,8,0.125,4,2.0,1.0,3.1736597428234396e-64,0.5000000000000004,0.5000000000000004,8.881784197001252e-16,3.999999999999993,1.9999999999999982,1.0,0.24891282578981969,0.995651303159277,0.004348696840723032,0.24925182178749877,0.9970072871499933,0.0029927128500066846,0.21650003632686893,0.8660001453074742,0.13399985469252584,0.19495646979600004,0.7798258791839988,0.22017412081600118,1.3270284127465848,2.6540568254931673,1.6540568254931673,true,true,true,false,true,false,false,false
+wendland_c2,150.0,8,0.125,4,2.0,1.0,3.1736597428234396e-64,0.5000000000000004,0.5000000000000004,8.881784197001252e-16,3.999999999999993,1.9999999999999982,1.0,0.24912534495054114,0.9965013798021628,0.003498620197837199,0.24974549395696596,0.9989819758278621,0.0010180241721379213,0.24030321356795661,0.9612128542718248,0.03878714572817521,0.2132917787086381,0.8531671148345509,0.14683288516544912,1.9524923787092043,3.904984757418405,2.904984757418405,true,true,true,false,true,false,false,false
+wendland_c4,30.0,2,0.5,4,2.0,1.0000000000000002,2.151510708863984e-95,0.5000000000000013,0.5000000000000013,2.4424906541753444e-15,3.9999999999999787,1.9999999999999947,1.0,0.2514619989212535,1.0058479956850086,0.0058479956850086,0.2496832269332212,0.9987329077328795,0.0012670922671205176,0.13797684862211587,0.5519073944884606,0.4480926055115394,0.13933235096728644,0.5573294038691428,0.44267059613085724,0.12141967832622984,0.24283935665245904,0.757160643347541,true,true,false,false,false,false,false,false
+wendland_c4,60.0,2,0.5,4,2.0,1.0000000000000002,2.151510708863984e-95,0.5000000000000013,0.5000000000000013,2.4424906541753444e-15,3.9999999999999787,1.9999999999999947,1.0,0.26217869027481633,1.0487147610992598,0.04871476109925976,0.2590616304576285,1.0362465218305084,0.03624652183050836,0.16556893052379015,0.6622757220951571,0.33772427790484294,0.16601750243087646,0.6640700097235023,0.3359299902764977,0.25034691768762013,0.5006938353752389,0.4993061646247611,true,true,false,false,false,false,false,false
+wendland_c4,90.0,2,0.5,4,2.0,1.0000000000000002,2.151510708863984e-95,0.5000000000000013,0.5000000000000013,2.4424906541753444e-15,3.9999999999999787,1.9999999999999947,1.0,0.22312478990800064,0.8924991596319978,0.10750084036800223,0.2496832269332213,0.9987329077328798,0.0012670922671201845,0.1923554011610017,0.7694216046440027,0.23057839535599733,0.19100096389751767,0.7640038555900666,0.23599614440993344,0.4451365903363133,0.8902731806726243,0.10972681932737571,true,true,false,false,false,false,true,true
+wendland_c4,120.0,2,0.5,4,2.0,1.0000000000000002,2.151510708863984e-95,0.5000000000000013,0.5000000000000013,2.4424906541753444e-15,3.9999999999999787,1.9999999999999947,1.0,0.23777707444780005,0.9511082977911951,0.0488917022088049,0.24030482340881393,0.9612192936352506,0.038780706364749395,0.21914187179821282,0.8765674871928466,0.1234325128071534,0.21280381895585515,0.851215275823416,0.14878472417658395,1.4646750272048383,2.9293500544096687,1.9293500544096687,true,true,true,false,false,false,false,false
+wendland_c4,150.0,2,0.5,4,2.0,1.0000000000000002,2.151510708863984e-95,0.5000000000000013,0.5000000000000013,2.4424906541753444e-15,3.9999999999999787,1.9999999999999947,1.0,0.24849376561705394,0.9939750624682104,0.006024937531789565,0.24968322693322131,0.9987329077328799,0.0012670922671200735,0.24673395369988727,0.9869358147995438,0.013064185200456158,0.23704810213495342,0.9481924085398086,0.05180759146019143,2.2120077823500215,4.424015564700031,3.4240155647000314,true,true,true,false,true,false,false,false
+wendland_c4,30.0,4,0.25,4,2.0,1.0000000000000002,2.151510708863984e-95,0.5000000000000013,0.5000000000000013,2.4424906541753444e-15,3.9999999999999787,1.9999999999999947,1.0,0.25142377096387575,1.0056950838554977,0.005695083855497662,0.2499999521378331,0.9999998085513271,1.9144867291487344e-7,0.13513869630831724,0.540554785233266,0.45944521476673394,0.12856161285158405,0.5142464514063334,0.4857535485936666,0.10026206308446402,0.2005241261689275,0.7994758738310725,true,true,false,false,false,false,false,false
+wendland_c4,60.0,4,0.25,4,2.0,1.0000000000000002,2.151510708863984e-95,0.5000000000000013,0.5000000000000013,2.4424906541753444e-15,3.9999999999999787,1.9999999999999947,1.0,0.2532055185265528,1.012822074106206,0.01282207410620595,0.25233411931912264,1.0093364772764852,0.009336477276485233,0.15944500605782946,0.6377800242313144,0.3622199757686856,0.15053091682994862,0.6021236673197913,0.3978763326802087,0.21972932324797562,0.43945864649595007,0.56054135350405,true,true,false,false,false,false,false,false
+wendland_c4,90.0,4,0.25,4,2.0,1.0000000000000002,2.151510708863984e-95,0.5000000000000013,0.5000000000000013,2.4424906541753444e-15,3.9999999999999787,1.9999999999999947,1.0,0.2365721803122493,0.9462887212489922,0.053711278751007785,0.24999849963458864,0.9999939985383492,6.001461650750883e-6,0.18851887980853416,0.7540755192341326,0.24592448076586737,0.17510156546065803,0.7004062618426283,0.29959373815737167,0.47241421413892337,0.9448284282778442,0.05517157172215581,true,true,false,false,false,false,true,true
+wendland_c4,120.0,4,0.25,4,2.0,1.0000000000000002,2.151510708863984e-95,0.5000000000000013,0.5000000000000013,2.4424906541753444e-15,3.9999999999999787,1.9999999999999947,1.0,0.24679430105833142,0.9871772042333204,0.012822795766679551,0.24766287995005462,0.9906515198002132,0.009348480199786846,0.21759275355923904,0.8703710142369515,0.1296289857630485,0.19673254440575458,0.7869301776230141,0.2130698223769859,1.4887188741809194,2.977437748361831,1.9774377483618308,true,true,true,false,false,false,false,false
+wendland_c4,150.0,4,0.25,4,2.0,1.0000000000000002,2.151510708863984e-95,0.5000000000000013,0.5000000000000013,2.4424906541753444e-15,3.9999999999999787,1.9999999999999947,1.0,0.24857604861659438,0.9943041944663722,0.005695805533627807,0.24999704713134416,0.9999881885253713,1.1811474628697916e-5,0.24189906330875094,0.9675962532349986,0.03240374676500135,0.2166683527535981,0.8666734110143878,0.13332658898561223,2.2421756502222263,4.484351300444441,3.484351300444441,true,true,true,false,true,false,false,false
+wendland_c4,30.0,8,0.125,4,2.0,1.0000000000000002,2.151510708863984e-95,0.5000000000000013,0.5000000000000013,2.4424906541753444e-15,3.9999999999999787,1.9999999999999947,1.0,0.25106772849331044,1.0042709139732364,0.004270913973236423,0.2502283689315262,1.0009134757260996,0.0009134757260995663,0.13507835164474227,0.5403134065789662,0.4596865934210338,0.12835736673980194,0.513429466959205,0.486570533040795,0.09682453123051289,0.19364906246102526,0.8063509375389748,true,true,false,false,false,false,false,false
+wendland_c4,60.0,8,0.125,4,2.0,1.0000000000000002,2.151510708863984e-95,0.5000000000000013,0.5000000000000013,2.4424906541753444e-15,3.9999999999999787,1.9999999999999947,1.0,0.25130219888993777,1.0052087955597457,0.005208795559745738,0.25083829193728374,1.0033531677491296,0.0033531677491296374,0.15890132870381804,0.6356053148152687,0.3643946851847313,0.15004205543627055,0.600168221745079,0.399831778254921,0.21478444291096324,0.4295688858219253,0.5704311141780747,true,true,false,false,false,false,false,false
+wendland_c4,90.0,8,0.125,4,2.0,1.0000000000000002,2.151510708863984e-95,0.5000000000000013,0.5000000000000013,2.4424906541753444e-15,3.9999999999999787,1.9999999999999947,1.0,0.24328613249114295,0.9731445299645666,0.026855470035433426,0.24999999593258737,0.9999999837303442,1.6269655844780573e-8,0.1877438453180128,0.7509753812720472,0.24902461872795278,0.1726071859168056,0.6904287436672187,0.30957125633278126,0.485908490186285,0.9718169803725675,0.028183019627432526,true,true,false,false,false,false,true,true
+wendland_c4,120.0,8,0.125,4,2.0,1.0000000000000002,2.151510708863984e-95,0.5000000000000013,0.5000000000000013,2.4424906541753444e-15,3.9999999999999787,1.9999999999999947,1.0,0.24869780039821457,0.994791201592853,0.005208798407147053,0.24916169992789164,0.9966467997115612,0.0033532002884387735,0.21658636193220684,0.8663454477288227,0.13365455227117728,0.19414932336146426,0.7765972934458529,0.22340270655414707,1.4975872377896184,2.995174475579229,1.995174475579229,true,true,true,false,false,false,false,false
+wendland_c4,150.0,8,0.125,4,2.0,1.0000000000000002,2.151510708863984e-95,0.5000000000000013,0.5000000000000013,2.4424906541753444e-15,3.9999999999999787,1.9999999999999947,1.0,0.24893227079448293,0.9957290831779264,0.004270916822073589,0.24977162293364907,0.999086491734591,0.0009135082654090354,0.24040933899128306,0.9616373559651271,0.038362644034872884,0.21193877348032686,0.8477550939213029,0.15224490607869712,2.2484295139568204,4.496859027913629,3.4968590279136293,true,true,true,false,true,false,false,false
+wendland_c6,30.0,2,0.5,4,2.0,1.0,1.2928215767597379e-126,0.4999999999999993,0.4999999999999993,1.4432899320127035e-15,4.0000000000000115,2.000000000000003,1.0,0.2514932533913342,1.0059730135653397,0.0059730135653397465,0.2498199441953588,0.9992797767814381,0.0007202232185619106,0.14086982471917173,0.5634792988766886,0.4365207011233114,0.11955376626055875,0.4782150650422364,0.5217849349577637,0.12346111849650866,0.2469222369930177,0.7530777630069823,true,false,false,false,false,true,false,false
+wendland_c6,60.0,2,0.5,4,2.0,1.0,1.2928215767597379e-126,0.4999999999999993,0.4999999999999993,1.4432899320127035e-15,4.0000000000000115,2.000000000000003,1.0,0.2646499992706827,1.058599997082734,0.058599997082734,0.2603283778991775,1.0413135115967131,0.04131351159671315,0.16815547891571317,0.6726219156628546,0.32737808433714544,0.14448003736037973,0.5779201494415206,0.4220798505584794,0.26819784166194166,0.5363956833238842,0.4636043166761158,true,true,false,false,false,true,false,false
+wendland_c6,90.0,2,0.5,4,2.0,1.0,1.2928215767597379e-126,0.4999999999999993,0.4999999999999993,1.4432899320127035e-15,4.0000000000000115,2.000000000000003,1.0,0.22007880059339077,0.8803152023735656,0.11968479762643436,0.2498199441953589,0.9992797767814385,0.0007202232185614665,0.1943226139408136,0.7772904557632566,0.22270954423674338,0.1666078496348534,0.6664313985394155,0.33356860146058454,0.4397774866137553,0.879554973227512,0.12044502677248803,true,true,false,false,false,true,true,true
+wendland_c6,120.0,2,0.5,4,2.0,1.0,1.2928215767597379e-126,0.4999999999999993,0.4999999999999993,1.4432899320127035e-15,4.0000000000000115,2.000000000000003,1.0,0.23531825751512575,0.9412730300605057,0.05872696993949433,0.23931151049153998,0.9572460419661627,0.0427539580338373,0.22048974896591397,0.8819589958636584,0.11804100413634155,0.1857447241270819,0.7429788965083297,0.2570211034916703,1.5999782474441206,3.199956494888246,2.199956494888246,true,true,true,false,true,true,false,false
+wendland_c6,150.0,2,0.5,4,2.0,1.0,1.2928215767597379e-126,0.4999999999999993,0.4999999999999993,1.4432899320127035e-15,4.0000000000000115,2.000000000000003,1.0,0.24847500317872134,0.9939000127148883,0.006099987285111741,0.24981994419535886,0.9992797767814383,0.0007202232185616886,0.2477754031624555,0.9911016126498249,0.008898387350175119,0.2057797127559775,0.8231188510239124,0.17688114897608764,2.4491809297390508,4.8983618594781095,3.8983618594781095,true,false,true,false,true,true,false,false
+wendland_c6,30.0,4,0.25,4,2.0,1.0,1.2928215767597379e-126,0.4999999999999993,0.4999999999999993,1.4432899320127035e-15,4.0000000000000115,2.000000000000003,1.0,0.25143149203751775,1.0057259681500739,0.005725968150073868,0.25000002822083367,1.0000001128833376,1.128833375663163e-7,0.13523857681361165,0.5409543072544482,0.45904569274555185,0.13192858322597292,0.5277143329038932,0.47228566709610675,0.10189586426052052,0.20379172852104135,0.7962082714789587,true,false,false,false,false,true,false,false
+wendland_c6,60.0,4,0.25,4,2.0,1.0,1.2928215767597379e-126,0.4999999999999993,0.4999999999999993,1.4432899320127035e-15,4.0000000000000115,2.000000000000003,1.0,0.2538519829334657,1.0154079317338658,0.015407931733865787,0.2529601666603989,1.0118406666415984,0.01184066664159844,0.15981375691893787,0.6392550276757534,0.3607449723242466,0.15557031151421025,0.6222812460568428,0.37771875394315724,0.22088659138270494,0.44177318276541055,0.5582268172345894,true,true,false,false,false,true,false,false
+wendland_c6,90.0,4,0.25,4,2.0,1.0,1.2928215767597379e-126,0.4999999999999993,0.4999999999999993,1.4432899320127035e-15,4.0000000000000115,2.000000000000003,1.0,0.23504637149581334,0.940185485983256,0.05981451401674398,0.24999982530523956,0.9999993012209611,6.987790388546244e-7,0.18878366323540804,0.7551346529416344,0.2448653470583656,0.18042697967252339,0.7217079186900957,0.27829208130990435,0.4698959488452661,0.9397918976905336,0.06020810230946638,true,true,false,false,false,true,true,true
+wendland_c6,120.0,4,0.25,4,2.0,1.0,1.2928215767597379e-126,0.4999999999999993,0.4999999999999993,1.4432899320127035e-15,4.0000000000000115,2.000000000000003,1.0,0.24614798454270373,0.9845919381708178,0.015408061829182196,0.2470394839500802,0.9881579358003237,0.011842064199676261,0.21775356955187855,0.8710142782075168,0.12898572179248324,0.2003047772070116,0.8012191088280487,0.1987808911719513,1.628827171458419,3.257654342916843,2.257654342916843,true,true,true,false,true,true,false,false
+wendland_c6,150.0,4,0.25,4,2.0,1.0,1.2928215767597379e-126,0.4999999999999993,0.4999999999999993,1.4432899320127035e-15,4.0000000000000115,2.000000000000003,1.0,0.24856847543192448,0.9942739017277008,0.005726098272299196,0.24999962238964538,0.9999984895585844,1.510441415608632e-6,0.24232874965720452,0.9693149986288209,0.030685001371179133,0.22197608468085442,0.8879043387234202,0.11209566127657977,2.487334769391142,4.974669538782292,3.974669538782292,true,false,true,false,true,true,false,false
+wendland_c6,30.0,8,0.125,4,2.0,1.0,1.2928215767597379e-126,0.4999999999999993,0.4999999999999993,1.4432899320127035e-15,4.0000000000000115,2.000000000000003,1.0,0.2511963575749011,1.0047854302996073,0.004785430299607318,0.2501890658923823,1.0007562635695322,0.0007562635695321784,0.13507634849351371,0.5403053939740564,0.4596946060259436,0.13076821373669642,0.5230728549467872,0.47692714505321276,0.09721498293267614,0.19442996586535258,0.8055700341346475,true,false,false,false,false,true,false,false
+wendland_c6,60.0,8,0.125,4,2.0,1.0,1.2928215767597379e-126,0.4999999999999993,0.4999999999999993,1.4432899320127035e-15,4.0000000000000115,2.000000000000003,1.0,0.25148218022507546,1.0059287209003047,0.005928720900304718,0.25091728696136184,1.0036691478454502,0.003669147845450249,0.158917381219747,0.6356695248789899,0.36433047512101013,0.15250380093820234,0.6100152037528112,0.38998479624718885,0.21504577297071312,0.4300915459414269,0.569908454058573,true,true,false,false,false,true,false,false
+wendland_c6,90.0,8,0.125,4,2.0,1.0,1.2928215767597379e-126,0.4999999999999993,0.4999999999999993,1.4432899320127035e-15,4.0000000000000115,2.000000000000003,1.0,0.2425231933784977,0.9700927735139936,0.02990722648600641,0.24999999980303828,0.999999999212156,7.87844012251071e-10,0.1878034534532331,0.7512138138129346,0.24878618618706538,0.1758921456724708,0.7035685826898852,0.2964314173101148,0.48487082030359085,0.9697416406071833,0.030258359392816736,true,true,false,false,false,true,true,true
+wendland_c6,120.0,8,0.125,4,2.0,1.0,1.2928215767597379e-126,0.4999999999999993,0.4999999999999993,1.4432899320127035e-15,4.0000000000000115,2.000000000000003,1.0,0.2485178197429596,0.9940712789718413,0.005928721028158668,0.24908271264471454,0.9963308505788611,0.0036691494211389397,0.21668952568672012,0.866758102746883,0.13324189725311697,0.19689232482798336,0.7875692993119356,0.21243070068806436,1.6401407161930817,3.280281432386168,2.280281432386168,true,true,true,false,true,true,false,false
+wendland_c6,150.0,8,0.125,4,2.0,1.0,1.2928215767597379e-126,0.4999999999999993,0.4999999999999993,1.4432899320127035e-15,4.0000000000000115,2.000000000000003,1.0,0.24880364239295547,0.9952145695718247,0.0047854304281752524,0.2498109337136948,0.9992437348547821,0.0007562651452178715,0.24053055841295362,0.9621222336518173,0.03787776634818274,0.2155200200714547,0.8620800802858213,0.13791991971417872,2.4959800077635412,4.9919600155270905,3.9919600155270905,true,false,true,false,true,true,false,false
+poly6,30.0,2,0.5,4,1.0,1.0,1.7151471402216462e-47,0.500000000000001,0.500000000000001,1.9984014443252818e-15,3.999999999999984,1.999999999999996,1.0,0.25039758048743715,1.0015903219497446,0.001590321949744622,0.28347841042529454,1.1339136417011737,0.1339136417011737,0.1661769992148278,0.6647079968593085,0.3352920031406915,0.1661769992148278,0.6647079968593085,0.3352920031406915,0.11512797633943202,0.23025595267886356,0.7697440473211364,true,true,false,false,false,false,false,false
+poly6,60.0,2,0.5,4,1.0,1.0,1.7151471402216462e-47,0.500000000000001,0.500000000000001,1.9984014443252818e-15,3.999999999999984,1.999999999999996,1.0,0.2709095681411565,1.0836382725646216,0.08363827256462164,0.29814108682660284,1.1925643473064067,0.1925643473064067,0.19550235201744448,0.7820094080697748,0.21799059193022519,0.19550235201744448,0.7820094080697748,0.21799059193022519,0.32819289749453195,0.6563857949890626,0.3436142050109374,true,true,false,false,false,false,false,false
+poly6,90.0,2,0.5,4,1.0,1.0,1.7151471402216462e-47,0.500000000000001,0.500000000000001,1.9984014443252818e-15,3.999999999999984,1.999999999999996,1.0,0.2106147359504183,0.8424589438016699,0.15754105619833014,0.2834784104252945,1.1339136417011735,0.13391364170117348,0.22482770482006115,0.899310819280241,0.10068918071975896,0.22482770482006115,0.899310819280241,0.10068918071975896,0.4337975136066244,0.867595027213247,0.132404972786753,true,true,false,false,false,false,true,true
+poly6,120.0,2,0.5,4,1.0,1.0,1.7151471402216462e-47,0.500000000000001,0.500000000000001,1.9984014443252818e-15,3.999999999999984,1.999999999999996,1.0,0.22688499398789322,0.9075399759515692,0.09246002404843079,0.2688157340239862,1.0752629360959405,0.07526293609594048,0.2541530576226778,1.0166122304907073,0.016612230490707258,0.2541530576226778,1.0166122304907073,0.016612230490707258,1.1369871320968459,2.2739742641936873,1.2739742641936873,true,true,true,false,true,false,false,true
+poly6,150.0,2,0.5,4,1.0,1.0,1.7151471402216462e-47,0.500000000000001,0.500000000000001,1.9984014443252818e-15,3.999999999999984,1.999999999999996,1.0,0.24739626418723887,0.9895850567489515,0.010414943251048525,0.2834784104252945,1.1339136417011735,0.13391364170117348,0.2834784104252945,1.1339136417011735,0.13391364170117348,0.2834784104252945,1.1339136417011735,0.13391364170117348,1.6672015468459023,3.334403093691798,2.334403093691798,true,true,true,true,true,false,false,true
+poly6,30.0,4,0.25,4,1.0,1.0,1.7151471402216462e-47,0.500000000000001,0.500000000000001,1.9984014443252818e-15,3.999999999999984,1.999999999999996,1.0,0.25137749317265107,1.0055099726906003,0.005509972690600273,0.2528156745345034,1.0112626981380095,0.011262698138009508,0.13711642998428866,0.5484657199371524,0.45153428006284757,0.13142056083760964,0.5256822433504365,0.47431775664956355,0.10308444896352997,0.20616889792705953,0.7938311020729405,true,true,false,false,false,false,false,false
+poly6,60.0,4,0.25,4,1.0,1.0,1.7151471402216462e-47,0.500000000000001,0.500000000000001,1.9984014443252818e-15,3.999999999999984,1.999999999999996,1.0,0.25628184627802875,1.025127385112111,0.02512738511211099,0.25824296465588487,1.0329718586235352,0.032971858623535244,0.1631362114495981,0.6525448457983899,0.3474551542016101,0.15541211922254516,0.6216484768901782,0.3783515231098218,0.23581723869641752,0.4716344773928341,0.528365522607166,true,true,false,false,false,false,false,false
+poly6,90.0,4,0.25,4,1.0,1.0,1.7151471402216462e-47,0.500000000000001,0.500000000000001,1.9984014443252818e-15,3.999999999999984,1.999999999999996,1.0,0.23075716062398566,0.923028642495939,0.07697135750406103,0.2528156745345034,1.0112626981380095,0.011262698138009508,0.19196759599689617,0.7678703839875816,0.23212961601241844,0.18049319288027657,0.7219727715211034,0.2780272284788966,0.4542836240150697,0.9085672480301377,0.09143275196986234,true,true,false,false,false,false,true,true
+poly6,120.0,4,0.25,4,1.0,1.0,1.7151471402216462e-47,0.500000000000001,0.500000000000001,1.9984014443252818e-15,3.999999999999984,1.999999999999996,1.0,0.2436818197938321,0.9747272791753245,0.025272720824675465,0.2473883844131217,0.9895535376524828,0.010446462347517227,0.22079898054419414,0.883195922176773,0.11680407782322699,0.2030070196654883,0.8120280786619499,0.18797192133805007,1.1082661254660537,2.216532250932103,1.216532250932103,true,true,true,false,true,false,false,true
+poly6,150.0,4,0.25,4,1.0,1.0,1.7151471402216462e-47,0.500000000000001,0.500000000000001,1.9984014443252818e-15,3.999999999999984,1.999999999999996,1.0,0.24858617278954162,0.9943446911581625,0.005655308841837514,0.25281567453450327,1.011262698138009,0.011262698138009064,0.24681876200950367,0.9872750480380107,0.012724951961989306,0.22037475129067727,0.8814990051627055,0.11850099483729448,1.585825041539347,3.1716500830786876,2.1716500830786876,true,true,true,true,true,false,false,true
+poly6,30.0,8,0.125,4,1.0,1.0,1.7151471402216462e-47,0.500000000000001,0.500000000000001,1.9984014443252818e-15,3.999999999999984,1.999999999999996,1.0,0.25136690023609376,1.005467600944371,0.005467600944371043,0.25019489653573884,1.0007795861429514,0.0007795861429513717,0.13489040723858778,0.539561628954349,0.460438371045651,0.12932821513889461,0.5173128605555763,0.48268713944442365,0.0970991548574732,0.194198309714946,0.805801690285054,true,true,false,false,false,false,false,false
+poly6,60.0,8,0.125,4,1.0,1.0,1.7151471402216462e-47,0.500000000000001,0.500000000000001,1.9984014443252818e-15,3.999999999999984,1.999999999999996,1.0,0.2519590512261957,1.0078362049047789,0.007836204904778876,0.2513533050776273,1.0054132203105053,0.0054132203105052845,0.15877336782553633,0.6350934713021428,0.3649065286978572,0.15122424376124363,0.6048969750449721,0.3951030249550279,0.2211827827795818,0.44236556555916273,0.5576344344408373,true,true,false,false,false,false,false,false
+poly6,90.0,8,0.125,4,1.0,1.0,1.7151471402216462e-47,0.500000000000001,0.500000000000001,1.9984014443252818e-15,3.999999999999984,1.999999999999996,1.0,0.24038668653722506,0.9615467461488963,0.03845325385110365,0.25015176407757156,1.0006070563102822,0.000607056310282239,0.18813576153062078,0.7525430461224801,0.24745695387751987,0.17502471324306765,0.7000988529722678,0.2999011470277322,0.4722089874416136,0.9444179748832253,0.05558202511677468,true,true,false,false,false,false,true,true
+poly6,120.0,8,0.125,4,1.0,1.0,1.7151471402216462e-47,0.500000000000001,0.500000000000001,1.9984014443252818e-15,3.999999999999984,1.999999999999996,1.0,0.24804037378436813,0.9921614951374685,0.007838504862531459,0.2489502230775157,0.9958008923100587,0.0041991076899412505,0.21749815523570515,0.8699926209428172,0.13000737905718285,0.19644545266559524,0.7857818106623778,0.21421818933762216,1.1184045012723065,2.2368090025446086,1.2368090025446086,true,true,true,false,true,false,false,true
+poly6,150.0,8,0.125,4,1.0,1.0,1.7151471402216462e-47,0.500000000000001,0.500000000000001,1.9984014443252818e-15,3.999999999999984,1.999999999999996,1.0,0.24863252476862674,0.994530099074503,0.00546990092549704,0.2501086316194042,1.0004345264776129,0.0004345264776128843,0.24138111582265356,0.9655244632906104,0.03447553670938963,0.21486335128918962,0.8594534051567551,0.14054659484324494,1.596064426134757,3.1921288522695077,2.1921288522695077,true,true,true,true,true,false,false,true
+spiky,30.0,2,0.5,4,1.0,1.0,6.533893867511033e-48,0.4999999999999991,0.4999999999999991,1.7763568394002505e-15,4.000000000000014,2.0000000000000036,1.0,0.24488360952102622,0.9795344380841083,0.02046556191589166,0.2286221959496708,0.9144887837986864,0.08551121620131363,0.15894636413955254,0.6357854565582124,0.3642145434417876,0.15894636413955254,0.6357854565582124,0.3642145434417876,0.07381515009814049,0.14763030019628123,0.8523696998037188,true,true,false,false,false,false,false,true
+spiky,60.0,2,0.5,4,1.0,1.0,6.533893867511033e-48,0.4999999999999991,0.4999999999999991,1.7763568394002505e-15,4.000000000000014,2.0000000000000036,1.0,0.26998720836143747,1.0799488334457537,0.07994883344575365,0.23733167492593552,0.9493266997037454,0.05067330029625461,0.1763653220920821,0.705461288368331,0.294538711631669,0.1763653220920821,0.705461288368331,0.294538711631669,0.26134782271572676,0.5226956454314544,0.4773043545685456,true,true,false,false,false,false,false,false
+spiky,90.0,2,0.5,4,1.0,1.0,6.533893867511033e-48,0.4999999999999991,0.4999999999999991,1.7763568394002505e-15,4.000000000000014,2.0000000000000036,1.0,0.19760048421928825,0.7904019368771558,0.20959806312284424,0.22862219594967073,0.9144887837986861,0.08551121620131386,0.19378428004461162,0.7751371201784493,0.22486287982155073,0.19378428004461162,0.7751371201784493,0.22486287982155073,0.38107974032912956,0.7621594806582604,0.23784051934173955,true,true,false,false,false,false,true,true
+spiky,120.0,2,0.5,4,1.0,1.0,6.533893867511033e-48,0.4999999999999991,0.4999999999999991,1.7763568394002505e-15,4.000000000000014,2.0000000000000036,1.0,0.21635636244653608,0.8654254497861474,0.13457455021385256,0.21991271697340598,0.879650867893627,0.12034913210637299,0.2112032379971412,0.8448129519885678,0.15518704801143224,0.2112032379971412,0.8448129519885678,0.15518704801143224,1.1547349794697463,2.3094699589394967,1.3094699589394967,true,true,true,true,true,false,false,false
+spiky,150.0,2,0.5,4,1.0,1.0,6.533893867511033e-48,0.4999999999999991,0.4999999999999991,1.7763568394002505e-15,4.000000000000014,2.0000000000000036,1.0,0.24144135429721597,0.9657654171888673,0.034234582811132674,0.22862219594967076,0.9144887837986863,0.08551121620131374,0.22862219594967076,0.9144887837986863,0.08551121620131374,0.22862219594967076,0.9144887837986863,0.08551121620131374,1.6118967553212507,3.223793510642507,2.223793510642507,true,true,true,true,true,false,false,false
+spiky,30.0,4,0.25,4,1.0,1.0,6.533893867511033e-48,0.4999999999999991,0.4999999999999991,1.7763568394002505e-15,4.000000000000014,2.0000000000000036,1.0,0.251074054668542,1.0042962186741715,0.004296218674171515,0.24861283194549413,0.9944513277819801,0.005548672218019912,0.13673427482173822,0.5469370992869549,0.4530629007130451,0.13372108591346946,0.5348843436538797,0.4651156563461203,0.1130201880354087,0.22604037607081778,0.7739596239291822,true,true,false,false,false,false,false,true
+spiky,60.0,4,0.25,4,1.0,1.0,6.533893867511033e-48,0.4999999999999991,0.4999999999999991,1.7763568394002505e-15,4.000000000000014,2.0000000000000036,1.0,0.25868304952075855,1.034732198083038,0.03473219808303796,0.25564846210808945,1.0225938484323613,0.02259384843236134,0.1631145726083513,0.6524582904334075,0.34754170956659247,0.15956419606312994,0.638256784252522,0.36174321574747803,0.23038170370861003,0.4607634074172209,0.5392365925827791,true,true,false,false,false,false,false,false
+spiky,90.0,4,0.25,4,1.0,1.0,6.533893867511033e-48,0.4999999999999991,0.4999999999999991,1.7763568394002505e-15,4.000000000000014,2.0000000000000036,1.0,0.22617721245218908,0.9047088498087595,0.09529115019124046,0.2486128319454941,0.99445132778198,0.005548672218020023,0.19028378530280407,0.761135141211219,0.23886485878878105,0.18360233966387526,0.7344093586555037,0.2655906413444963,0.44727769852444754,0.8945553970488966,0.10544460295110336,true,true,false,false,false,false,true,true
+spiky,120.0,4,0.25,4,1.0,1.0,6.533893867511033e-48,0.4999999999999991,0.4999999999999991,1.7763568394002505e-15,4.000000000000014,2.0000000000000036,1.0,0.24046263264233356,0.9618505305693377,0.03814946943066233,0.24157720178289877,0.9663088071315985,0.0336911928684015,0.2174529979972568,0.8698119919890303,0.13018800801096975,0.20360532792893485,0.8144213117157423,0.18557868828425772,1.2595989626408943,2.519197925281793,1.519197925281793,true,true,true,true,true,false,false,false
+spiky,150.0,4,0.25,4,1.0,1.0,6.533893867511033e-48,0.4999999999999991,0.4999999999999991,1.7763568394002505e-15,4.000000000000014,2.0000000000000036,1.0,0.2480712653655228,0.9922850614620947,0.00771493853790528,0.24861283194549416,0.9944513277819802,0.005548672218019801,0.2438332957838699,0.975333183135483,0.024666816864516994,0.22521591675982208,0.9008636670392915,0.09913633296070845,1.852724545258553,3.7054490905171127,2.7054490905171127,true,true,true,true,true,false,false,false
+spiky,30.0,8,0.125,4,1.0,1.0,6.533893867511033e-48,0.4999999999999991,0.4999999999999991,1.7763568394002505e-15,4.000000000000014,2.0000000000000036,1.0,0.2514290613314619,1.0057162453258512,0.0057162453258512436,0.24992382252898263,0.999695290115934,0.0003047098840659457,0.135208145203722,0.5408325808148899,0.45916741918511006,0.13182630083423827,0.527305203336955,0.47269479666304504,0.09752716237357556,0.19505432474715145,0.8049456752528485,true,true,false,false,false,false,false,true
+spiky,60.0,8,0.125,4,1.0,1.0,6.533893867511033e-48,0.4999999999999991,0.4999999999999991,1.7763568394002505e-15,4.000000000000014,2.0000000000000036,1.0,0.25261016849894374,1.0104406739957785,0.010440673995778527,0.25166468616474524,1.0066587446589845,0.006658744658984528,0.15930968535502404,0.6372387414200984,0.3627612585799016,0.15420054220018095,0.616802168800726,0.383197831199274,0.21394185749455258,0.42788371498910593,0.5721162850108941,true,true,false,false,false,false,false,false
+spiky,90.0,8,0.125,4,1.0,1.0,6.533893867511033e-48,0.4999999999999991,0.4999999999999991,1.7763568394002505e-15,4.000000000000014,2.0000000000000036,1.0,0.23825591807216845,0.9530236722886771,0.046976327711322874,0.24990525069196154,0.9996210027678497,0.000378997232150291,0.188216675794824,0.7528667031792987,0.2471332968207013,0.1773470249075084,0.7093880996300361,0.29061190036996387,0.4724546623674706,0.9449093247349428,0.055090675265057176,true,true,false,false,false,false,true,true
+spiky,120.0,8,0.125,4,1.0,1.0,6.533893867511033e-48,0.4999999999999991,0.4999999999999991,1.7763568394002505e-15,4.000000000000014,2.0000000000000036,1.0,0.24733642551571908,0.9893457020628799,0.010654297937120139,0.24814581521917722,0.9925832608767124,0.007416739123287552,0.21712366623462367,0.8684946649384978,0.1315053350615022,0.20044098360126314,0.8017639344050554,0.19823606559494455,1.2852639374709403,2.570527874941885,1.570527874941885,true,true,true,true,true,false,false,false
+spiky,150.0,8,0.125,4,1.0,1.0,6.533893867511033e-48,0.4999999999999991,0.4999999999999991,1.7763568394002505e-15,4.000000000000014,2.0000000000000036,1.0,0.24851752446677025,0.9940700978670846,0.005929902132915443,0.24988667885493995,0.9995467154197634,0.00045328458023663476,0.24122520638592582,0.9649008255437067,0.03509917445629329,0.21877766015265807,0.8751106406106354,0.12488935938936463,1.888200102395898,3.776400204791803,2.776400204791803,true,true,true,true,true,false,false,false
+laguerre_gauss,30.0,2,0.5,4,2.0,0.9999813377555299,0.007425347385773283,0.4999906688777643,0.3755777793688689,0.2488304227519731,7.089248832823966,2.6625643340253706,1.0,0.14189473885921888,1.005927111841579,0.005927111841578903,0.13335376484119713,0.9453780217531383,0.05462197824686166,0.07136804769662325,0.5059458488342115,0.49405415116578855,0.06933911502023767,0.4915622402262666,0.5084377597737334,0.06200943595348371,0.16510411254277624,0.8348958874572238,true,true,false,true,false,true,false,false
+laguerre_gauss,60.0,2,0.5,4,2.0,0.9999813377555299,0.007425347385773283,0.4999906688777643,0.3755777793688689,0.2488304227519731,7.089248832823966,2.6625643340253706,1.0,0.14522703355598063,1.0295505781312226,0.029550578131222638,0.1366582771014116,0.9688045314369162,0.03119546856308375,0.08565979468892461,0.6072635995183994,0.39273640048160063,0.0818908602036114,0.5805446851174026,0.41945531488259735,0.1273143543310762,0.338982659051392,0.661017340948608,true,true,false,true,false,true,false,false
+laguerre_gauss,90.0,2,0.5,4,2.0,0.9999813377555299,0.007425347385773283,0.4999906688777643,0.3755777793688689,0.2488304227519731,7.089248832823966,2.6625643340253706,1.0,0.12923169325210104,0.9161556305513221,0.0838443694486779,0.13335376484119718,0.9453780217531387,0.054621978246861325,0.10091429161899501,0.7154065240752177,0.28459347592478235,0.09533942101232372,0.6758848791337286,0.32411512086627137,0.2456252053791112,0.6539929113800781,0.3460070886199219,true,true,false,true,false,true,false,true
+laguerre_gauss,120.0,2,0.5,4,2.0,0.9999813377555299,0.007425347385773283,0.4999906688777643,0.3755777793688689,0.2488304227519731,7.089248832823966,2.6625643340253706,1.0,0.1368902882689339,0.9704493163354759,0.02955068366452407,0.13004925258098285,0.9219515120693618,0.07804848793063823,0.11616878854906545,0.8235494486320364,0.17645055136796362,0.10844018618403124,0.768759463336357,0.23124053666364297,0.6306508818256105,1.6791485451705193,0.6791485451705193,true,true,true,true,true,true,false,false
+laguerre_gauss,150.0,2,0.5,4,2.0,0.9999813377555299,0.007425347385773283,0.4999906688777643,0.3755777793688689,0.2488304227519731,7.089248832823966,2.6625643340253706,1.0,0.14022259170826526,0.994072844603371,0.005927155396629025,0.13335376484119718,0.9453780217531387,0.054621978246861325,0.13046053554136683,0.9248671993162243,0.0751328006837757,0.11996721952182422,0.8504774709722288,0.1495225290277712,0.9175534564561528,2.4430451077218533,1.4430451077218533,true,true,true,true,true,true,false,false
+laguerre_gauss,30.0,4,0.25,4,2.0,0.9999813377555299,0.007425347385773283,0.4999906688777643,0.3755777793688689,0.2488304227519731,7.089248832823966,2.6625643340253706,1.0,0.14186903960973557,1.005744923467175,0.005744923467174923,0.13869340996816626,0.9832320947371984,0.016767905262801586,0.07462249093186016,0.5290174067411066,0.47098259325889336,0.07395734263595671,0.5243020049607182,0.4756979950392818,0.05536548035554765,0.14741415333086347,0.8525858466691365,true,true,false,true,false,true,false,false
+laguerre_gauss,60.0,4,0.25,4,2.0,0.9999813377555299,0.007425347385773283,0.4999906688777643,0.3755777793688689,0.2488304227519731,7.089248832823966,2.6625643340253706,1.0,0.14232549239548797,1.0089808308458093,0.008980830845809251,0.13946031653733504,0.9886688862375632,0.011331113762436784,0.08800727411545457,0.6239054653030052,0.37609453469699483,0.0866385934336277,0.6142025473768553,0.3857974526231447,0.11985973292707913,0.3191342499774472,0.6808657500225528,true,true,false,true,false,true,false,false
+laguerre_gauss,90.0,4,0.25,4,2.0,0.9999813377555299,0.007425347385773283,0.4999906688777643,0.3755777793688689,0.2488304227519731,7.089248832823966,2.6625643340253706,1.0,0.1351451573268201,0.9580776488409706,0.041922351159029425,0.1386601996254287,0.9829966583537084,0.01700334164629158,0.10414988700741895,0.7383444649060927,0.26165553509390727,0.10094011099874908,0.7155895640830034,0.28441043591699655,0.26268818208052297,0.699424184577563,0.30057581542243705,true,true,false,true,false,true,false,true
+laguerre_gauss,120.0,4,0.25,4,2.0,0.9999813377555299,0.007425347385773283,0.4999906688777643,0.3755777793688689,0.2488304227519731,7.089248832823966,2.6625643340253706,1.0,0.13979178303004605,0.991018734684135,0.008981265315864961,0.1378600827135222,0.9773244304698527,0.022675569530147266,0.12029249989938338,0.8527834645091806,0.14721653549081937,0.1137677642492014,0.8065279899166431,0.1934720100833569,0.6486881233328317,1.7271738610918483,0.7271738610918483,true,true,true,true,true,true,false,false
+laguerre_gauss,150.0,4,0.25,4,2.0,0.9999813377555299,0.007425347385773283,0.4999906688777643,0.3755777793688689,0.2488304227519731,7.089248832823966,2.6625643340253706,1.0,0.14024824728462307,0.994254723368121,0.005745276631878959,0.13862698928269107,0.9827612219702181,0.01723877802978191,0.13367728308297777,0.947671523071079,0.05232847692892095,0.12507180080627558,0.8866651178850807,0.11333488211491927,0.9356621304448651,2.491260617220692,1.491260617220692,true,true,true,true,true,true,false,false
+laguerre_gauss,30.0,8,0.125,4,2.0,0.9999813377555299,0.007425347385773283,0.4999906688777643,0.3755777793688689,0.2488304227519731,7.089248832823966,2.6625643340253706,1.0,0.14144015487478545,1.0027044528605138,0.002704452860513795,0.1405787356739825,0.9965976377966492,0.0034023622033507728,0.07589424597749636,0.538033194714021,0.461966805285979,0.07367717581351929,0.5223158326417577,0.47768416735824226,0.05459893940705802,0.14537318874084498,0.8546268112591551,true,true,false,true,false,true,false,false
+laguerre_gauss,60.0,8,0.125,4,2.0,0.9999813377555299,0.007425347385773283,0.4999906688777643,0.3755777793688689,0.2488304227519731,7.089248832823966,2.6625643340253706,1.0,0.1415627651701093,1.0035736677535305,0.0035736677535305184,0.1408018497754835,0.9981793491803017,0.0018206508196982885,0.08927417250877792,0.6328868232591792,0.3671131767408208,0.0863038918267464,0.6118297644009277,0.38817023559907227,0.11859422794256008,0.3157647615411355,0.6842352384588646,true,true,false,true,false,true,false,false
+laguerre_gauss,90.0,8,0.125,4,2.0,0.9999813377555299,0.007425347385773283,0.4999906688777643,0.3755777793688689,0.2488304227519731,7.089248832823966,2.6625643340253706,1.0,0.13810192263048354,0.9790388938189011,0.02096110618109892,0.1404426598274283,0.9956329622602893,0.0043670377397107,0.10538255627721005,0.7470831640882172,0.2529168359117828,0.1001975544858053,0.710325396190311,0.289674603809689,0.27052354619042707,0.720286345600696,0.27971365439930396,true,true,false,true,false,true,false,true
+laguerre_gauss,120.0,8,0.125,4,2.0,0.9999813377555299,0.007425347385773283,0.4999906688777643,0.3755777793688689,0.2488304227519731,7.089248832823966,2.6625643340253706,1.0,0.14055456810197867,0.9964263078650288,0.0035736921349711626,0.14008346987937342,0.9930865753402792,0.006913424659720779,0.12149094004564243,0.861279504917257,0.13872049508274298,0.11296853784587013,0.8008620750696649,0.1991379249303351,0.6543728985803808,1.742309940912923,0.742309940912923,true,true,true,true,true,true,false,false
+laguerre_gauss,150.0,8,0.125,4,2.0,0.9999813377555299,0.007425347385773283,0.4999906688777643,0.3755777793688689,0.2488304227519731,7.089248832823966,2.6625643340253706,1.0,0.14067717759411827,0.9972955170640727,0.002704482935927288,0.14030658398087406,0.9946682867239292,0.0053317132760708486,0.1348708665769239,0.9561331334624146,0.043866866537585425,0.12335886732932143,0.8745217062328784,0.1254782937671216,0.9396222744435814,2.501804755389278,1.5018047553892782,true,true,true,true,true,true,false,false
diff --git a/validation/surface_tension_3d/contact_measure_protocol.csv b/validation/surface_tension_3d/contact_measure_protocol.csv
new file mode 100644
index 0000000000..248cb1e099
--- /dev/null
+++ b/validation/surface_tension_3d/contact_measure_protocol.csv
@@ -0,0 +1,31 @@
+series,variant,target,requested_particles,particle_count_min,particle_count_max,smoothing_length,cells_per_h,phase_count,coarea_normalization,line_length_phases,line_length_phase_min,line_length_phase_max,line_length,analytic_line_length,signed_error,line_length_error,phase_standard_error,middle_pass,endpoint_pass,protocol_pass
+fixed_h_quadrature,analytic_both_control,30.0,1500,1496,1512,0.0012230126506308182,1.4000000000000001,8,3.999999999999993,0.07127428300891625;0.067456481018583717;0.068656213173252587;0.06745648101858373;0.069069142308051196;0.070123777621592068;0.068656213173252489;0.070123777621592193,0.06745648101858372,0.07127428300891625,0.06910204611797803,0.08318250925018945,-0.16927192097393184,0.16927192097393184,0.005707141029543184,true,true,true
+fixed_h_quadrature,analytic_both_control,60.0,1500,1486,1507,0.0012230126506308182,1.4000000000000001,8,3.999999999999993,0.057613884219493448;0.056888547705053201;0.055840769745301416;0.056888547705053291;0.05692453962385409;0.057901150758304321;0.055840769745301347;0.057901150758304258,0.05584076974530135,0.05790115075830432,0.05697492003258317,0.06267210593303608,-0.09090465073154308,0.09090465073154308,0.004629484888605038,true,true,true
+fixed_h_quadrature,analytic_both_control,90.0,1500,1487,1516,0.0012230126506308182,1.4000000000000001,8,3.999999999999993,0.040493454053258424;0.046593649496690079;0.046565223399209911;0.0465936494966901;0.049217721295182391;0.044903512956041315;0.046565223399209904;0.044903512956041287,0.040493454053258424,0.04921772129518239,0.04572949338154043,0.049108914031371555,-0.06881481125142164,0.06881481125142164,0.01801667885994168,true,true,true
+fixed_h_quadrature,analytic_both_control,120.0,1500,1496,1506,0.0012230126506308182,1.4000000000000001,8,3.999999999999993,0.033195118732931059;0.033640397065937987;0.034221506823622849;0.033640397065937973;0.03685317746775308;0.033841874743087939;0.034221506823622835;0.033841874743087932,0.03319511873293106,0.03685317746775308,0.0341819816832477,0.035722597891021876,-0.04312721634843297,0.04312721634843297,0.011178809765346789,true,true,true
+fixed_h_quadrature,analytic_both_control,150.0,1500,1492,1507,0.0012230126506308182,1.4000000000000001,8,3.999999999999993,0.0192137835143718;0.017792776556798194;0.018695523709728542;0.017792776556798159;0.017690750841383461;0.018993415500783029;0.018695523709728549;0.018993415500783019,0.01769075084138346,0.0192137835143718,0.018483495736296846,0.01957315597740063,-0.05567115708687542,0.05567115708687542,0.011271974259776985,true,true,true
+fixed_h_quadrature,analytic_both_control,30.0,12000,11992,12006,0.0012230126506308182,2.8000000000000003,8,3.999999999999993,0.069774304499530246;0.069338507663355781;0.069853599585792861;0.069338507663356058;0.069139553601343107;0.069907085281822462;0.069853599585792764;0.069907085281822559,0.0691395536013431,0.06990708528182256,0.06963903039535198,0.08318250925018945,-0.16281642591596412,0.16281642591596412,0.0013288089706245162,true,true,true
+fixed_h_quadrature,analytic_both_control,60.0,12000,11964,12012,0.0012230126506308182,2.8000000000000003,8,3.999999999999993,0.05625216324588727;0.057306753532016995;0.056969810173080226;0.057306753532016995;0.055750715670863142;0.057096756242784798;0.056969810173080192;0.057096756242784999,0.05575071567086314,0.057306753532016995,0.05684368985156432,0.06267210593303608,-0.0929985676195294,0.0929985676195294,0.0031142436331413332,true,true,true
+fixed_h_quadrature,analytic_both_control,90.0,12000,11993,12026,0.0012230126506308182,2.8000000000000003,8,3.999999999999993,0.044837403019997717;0.045821434273496678;0.046163706879942333;0.045821434273496525;0.047150188097596947;0.045646060997938047;0.04616370687994216;0.045646060997938082,0.04483740301999772,0.04715018809759695,0.04590624942754355,0.049108914031371555,-0.06521554522223993,0.06521554522223993,0.004691182545264584,true,true,true
+fixed_h_quadrature,analytic_both_control,120.0,12000,11973,12003,0.0012230126506308182,2.8000000000000003,8,3.999999999999993,0.03353287243709252;0.03359979313359375;0.033430939398935849;0.033599793133593861;0.033557971136778275;0.033944239500058565;0.033430939398935822;0.033944239500058607,0.03343093939893582,0.03394423950005861,0.03363009845488091,0.035722597891021876,-0.05857635109642667,0.05857635109642667,0.0020255243704550454,true,true,true
+fixed_h_quadrature,analytic_both_control,150.0,12000,11990,12010,0.0012230126506308182,2.8000000000000003,8,3.999999999999993,0.017554534192410452;0.017505367767828951;0.017759913687862904;0.017505367767828951;0.017828536050691426;0.017773006087732274;0.017759913687862974;0.017773006087732284,0.01750536776782895,0.017828536050691426,0.01768245566624378,0.01957315597740063,-0.09659659961530342,0.09659659961530342,0.002450416690502976,true,true,true
+fixed_h_quadrature,analytic_both_control,30.0,40500,40466,40522,0.0012230126506308182,4.199999999999999,8,3.999999999999993,0.069529322297485513;0.069748920463835906;0.069662586315065542;0.069748920463836087;0.069890677327403672;0.069733020644961605;0.069662586315065569;0.069733020644961563,0.06952932229748551,0.06989067732740367,0.06971363180907693,0.08318250925018945,-0.1619195857701522,0.1619195857701522,0.00043632531772291156,true,true,true
+fixed_h_quadrature,analytic_both_control,60.0,40500,40494,40518,0.0012230126506308182,4.199999999999999,8,3.999999999999993,0.056834590255278622;0.056676059006105832;0.056910456006604299;0.056676059006105804;0.056921036737260296;0.056723507546922104;0.056910456006604389;0.056723507546922104,0.056676059006105804,0.056921036737260296,0.05679695901397544,0.06267210593303608,-0.09374420775549053,0.09374420775549053,0.0006127981978328055,true,true,true
+fixed_h_quadrature,analytic_both_control,90.0,40500,40496,40526,0.0012230126506308182,4.199999999999999,8,3.999999999999993,0.044792843382988778;0.045796575393903513;0.045735261780852229;0.04579657539390352;0.045988394715377877;0.045821096210514708;0.045735261780852507;0.045821096210514667,0.04479284338298878,0.04598839471537788,0.04568588810861348,0.049108914031371555,-0.06970274114738906,0.06970274114738906,0.0026591503835060094,true,true,true
+fixed_h_quadrature,analytic_both_control,120.0,40500,40452,40526,0.0012230126506308182,4.199999999999999,8,3.999999999999993,0.033732395655862925;0.033800522332708166;0.033655166863802939;0.033800522332708326;0.033430056884792676;0.033652721762972994;0.033655166863802793;0.033652721762973105,0.033430056884792676,0.033800522332708326,0.03367240930745299,0.035722597891021876,-0.05739192288935291,0.05739192288935291,0.0011622279239086885,true,true,true
+fixed_h_quadrature,analytic_both_control,150.0,40500,40461,40518,0.0012230126506308182,4.199999999999999,8,3.999999999999993,0.017642605916854057;0.017527540041959264;0.017642814856533993;0.01752754004195935;0.017718051014657572;0.017634277800767019;0.017642814856533975;0.017634277800767092,0.017527540041959264,0.017718051014657572,0.017621240291254038,0.01957315597740063,-0.09972411645829093,0.09972411645829093,0.0011557844105012484,true,true,true
+production_resolution,analytic_both_control,30.0,750,743,758,0.0015408993828174924,1.4000000000000001,8,3.999999999999993,0.064805994409237691;0.065091016265079793;0.064214514500846656;0.065091016265079876;0.065776556645096643;0.065455601456680002;0.064214514500846684;0.065455601456679904,0.06421451450084666,0.06577655664509664,0.0650131019374434,0.08318250925018945,-0.21842821858255823,0.21842821858255823,0.0024389831896923686,true,true,true
+production_resolution,analytic_both_control,60.0,750,742,768,0.0015408993828174924,1.4000000000000001,8,3.999999999999993,0.054798122840678025;0.054883783063536015;0.054258984502427658;0.054883783063536043;0.058420277055939142;0.055680584186634241;0.054258984502427637;0.055680584186634179,0.05425898450242764,0.05842027705593914,0.055358137925226626,0.06267210593303608,-0.11670212607223207,0.11670212607223207,0.007617888005368585,true,true,true
+production_resolution,analytic_both_control,90.0,750,749,756,0.0015408993828174924,1.4000000000000001,8,3.999999999999993,0.042304776900952835;0.045393159968814288;0.046390848239538464;0.045393159968814281;0.045244656678456625;0.045758558093939597;0.04639084823953845;0.045758558093939597,0.042304776900952835,0.046390848239538464,0.04532932077299926,0.049108914031371555,-0.07696348683169474,0.07696348683169474,0.009341508012397068,true,true,true
+production_resolution,analytic_both_control,120.0,750,747,758,0.0015408993828174924,1.4000000000000001,8,3.999999999999993,0.035485334696227513;0.032795928062704496;0.033904480148102996;0.032795928062704482;0.029659409321183047;0.034843873530704096;0.033904480148103031;0.034843873530704075,0.029659409321183047,0.03548533469622751,0.033529163437554216,0.035722597891021876,-0.06140187396670094,0.06140187396670094,0.018178653984927382,true,true,true
+production_resolution,analytic_both_control,150.0,750,740,757,0.0015408993828174924,1.4000000000000001,8,3.999999999999993,0.01768692957231785;0.017831631314553741;0.018074784738045403;0.017831631314553759;0.017362974983958095;0.018701656000033481;0.018074784738045409;0.018701656000033481,0.017362974983958095,0.01870165600003348,0.018033256082692652,0.01957315597740063,-0.07867407261690262,0.07867407261690262,0.008492959690489825,true,true,true
+production_resolution,analytic_both_control,30.0,1500,1496,1512,0.0012230126506308182,1.4000000000000001,8,3.999999999999993,0.07127428300891625;0.067456481018583717;0.068656213173252587;0.06745648101858373;0.069069142308051196;0.070123777621592068;0.068656213173252489;0.070123777621592193,0.06745648101858372,0.07127428300891625,0.06910204611797803,0.08318250925018945,-0.16927192097393184,0.16927192097393184,0.005707141029543184,true,true,true
+production_resolution,analytic_both_control,60.0,1500,1486,1507,0.0012230126506308182,1.4000000000000001,8,3.999999999999993,0.057613884219493448;0.056888547705053201;0.055840769745301416;0.056888547705053291;0.05692453962385409;0.057901150758304321;0.055840769745301347;0.057901150758304258,0.05584076974530135,0.05790115075830432,0.05697492003258317,0.06267210593303608,-0.09090465073154308,0.09090465073154308,0.004629484888605038,true,true,true
+production_resolution,analytic_both_control,90.0,1500,1487,1516,0.0012230126506308182,1.4000000000000001,8,3.999999999999993,0.040493454053258424;0.046593649496690079;0.046565223399209911;0.0465936494966901;0.049217721295182391;0.044903512956041315;0.046565223399209904;0.044903512956041287,0.040493454053258424,0.04921772129518239,0.04572949338154043,0.049108914031371555,-0.06881481125142164,0.06881481125142164,0.01801667885994168,true,true,true
+production_resolution,analytic_both_control,120.0,1500,1496,1506,0.0012230126506308182,1.4000000000000001,8,3.999999999999993,0.033195118732931059;0.033640397065937987;0.034221506823622849;0.033640397065937973;0.03685317746775308;0.033841874743087939;0.034221506823622835;0.033841874743087932,0.03319511873293106,0.03685317746775308,0.0341819816832477,0.035722597891021876,-0.04312721634843297,0.04312721634843297,0.011178809765346789,true,true,true
+production_resolution,analytic_both_control,150.0,1500,1492,1507,0.0012230126506308182,1.4000000000000001,8,3.999999999999993,0.0192137835143718;0.017792776556798194;0.018695523709728542;0.017792776556798159;0.017690750841383461;0.018993415500783029;0.018695523709728549;0.018993415500783019,0.01769075084138346,0.0192137835143718,0.018483495736296846,0.01957315597740063,-0.05567115708687542,0.05567115708687542,0.011271974259776985,true,true,true
+production_resolution,analytic_both_control,30.0,3000,2999,3007,0.0009707057840908884,1.4000000000000001,8,3.999999999999993,0.073857286154257476;0.072579386789040856;0.072757310113941889;0.07257938678904069;0.071253816740517173;0.073114353200932888;0.072757310113941959;0.073114353200932888,0.07125381674051717,0.07385728615425748,0.07275165038782572,0.08318250925018945,-0.1253972614722485,0.1253972614722485,0.0031268360526720343,true,true,true
+production_resolution,analytic_both_control,60.0,3000,2983,3003,0.0009707057840908884,1.4000000000000001,8,3.999999999999993,0.05606169368450558;0.059091387093059333;0.058494335365918339;0.059091387093059319;0.05646622569256167;0.058585774176446469;0.058494335365918346;0.058585774176446441,0.05606169368450558,0.05909138709305933,0.058108864080989434,0.06267210593303608,-0.0728113693342679,0.0728113693342679,0.006596772797180258,true,true,true
+production_resolution,analytic_both_control,90.0,3000,2987,3005,0.0009707057840908884,1.4000000000000001,8,3.999999999999993,0.047597704910925222;0.046654539362890719;0.045492412883151284;0.046654539362890671;0.043426033643711628;0.046484716831095306;0.045492412883151326;0.046484716831095271,0.04342603364371163,0.04759770491092522,0.04603588458861392,0.049108914031371555,-0.06257579715150152,0.06257579715150152,0.00902886828454569,true,true,true
+production_resolution,analytic_both_control,120.0,3000,2988,3013,0.0009707057840908884,1.4000000000000001,8,3.999999999999993,0.032964220308225892;0.032942038867305463;0.034676085983817551;0.032942038867305505;0.032614057203740401;0.03516823299972302;0.034676085983817544;0.035168232999723055,0.0326140572037404,0.035168232999723055,0.03389387415170731,0.035722597891021876,-0.05119235014467349,0.05119235014467349,0.011088047930664506,true,true,true
+production_resolution,analytic_both_control,150.0,3000,2995,3014,0.0009707057840908884,1.4000000000000001,8,3.999999999999993,0.0198272021882249;0.018672851565196217;0.018456823010602523;0.018672851565196224;0.018515744418396294;0.019142347221270291;0.018456823010602495;0.019142347221270291,0.018456823010602495,0.0198272021882249,0.018860873775094905,0.01957315597740063,-0.0363907692314992,0.0363907692314992,0.008658969732644248,true,true,true
diff --git a/validation/surface_tension_3d/plot_surface_tension_3d.jl b/validation/surface_tension_3d/plot_surface_tension_3d.jl
new file mode 100644
index 0000000000..4ee267e2f2
--- /dev/null
+++ b/validation/surface_tension_3d/plot_surface_tension_3d.jl
@@ -0,0 +1,201 @@
+using CairoMakie
+using CSV
+using DataFrames
+using JSON
+using Statistics
+
+reference = JSON.parsefile(joinpath(@__DIR__, "validation_reference.json"))
+young_laplace = reference["young_laplace"]["results"]
+sessile = CSV.read(joinpath(@__DIR__, "sessile_drop_matrix.csv"), DataFrame)
+scorecard = CSV.read(joinpath(@__DIR__, "contact_angle_scorecard.csv"), DataFrame)
+perturbation = CSV.read(joinpath(@__DIR__, "contact_angle_perturbation.csv"), DataFrame)
+cost = CSV.read(joinpath(@__DIR__, "contact_angle_cost.csv"), DataFrame)
+normal_components = CSV.read(joinpath(@__DIR__, "contact_angle_normal_components.csv"),
+ DataFrame)
+force_sign = CSV.read(joinpath(@__DIR__, "contact_angle_force_sign.csv"), DataFrame)
+ghost_sign = CSV.read(joinpath(@__DIR__, "contact_angle_force_sign_ghost_geometric.csv"),
+ DataFrame)
+wall_energy_sign = CSV.read(joinpath(@__DIR__, "contact_angle_force_sign_wall_energy.csv"),
+ DataFrame)
+cap_transfer = CSV.read(joinpath(@__DIR__, "contact_line_cap_transfer.csv"), DataFrame)
+wetted_area = CSV.read(joinpath(@__DIR__, "wetted_area_measure.csv"), DataFrame)
+wetted_area_sign = CSV.read(joinpath(@__DIR__,
+ "contact_angle_force_sign_wetted_area.csv"), DataFrame)
+measure_protocol = CSV.read(joinpath(@__DIR__, "contact_measure_protocol.csv"), DataFrame)
+corrected_wetted_area = CSV.read(joinpath(@__DIR__, "wetted_area_corrected.csv"),
+ DataFrame)
+extended_recovery = CSV.read(joinpath(@__DIR__, "contact_angle_recovery_extended.csv"),
+ DataFrame)
+extended_sign = CSV.read(joinpath(@__DIR__, "contact_angle_force_sign_extended.csv"),
+ DataFrame)
+r4_perturbation = CSV.read(joinpath(@__DIR__,
+ "contact_angle_perturbation_r4_wetted_area_extended_production.csv"),
+ DataFrame)
+r4_cost = CSV.read(joinpath(@__DIR__,
+ "contact_angle_cost_r4_wetted_area_active_production.csv"),
+ DataFrame)
+
+figure = Figure(size=(1450, 1250))
+young_laplace_axis = Axis(figure[1, 1];
+ title="3D Young-Laplace convergence",
+ xlabel="particle count", ylabel="fitted sigma error [%]",
+ xscale=log10, yscale=log10)
+geometric_axis = Axis(figure[1, 2]; title="Geometric contact angle",
+ xlabel="target angle [deg]", ylabel="local-fit angle [deg]",
+ limits=(20, 160, 20, 160))
+clf_axis = Axis(figure[1, 3]; title="Contact-line force",
+ xlabel="target angle [deg]", ylabel="local-fit angle [deg]",
+ limits=(20, 160, 20, 160))
+mae_axis = Axis(figure[2, 1]; title="Static angle error",
+ xlabel="particle count", ylabel="mean absolute error [deg]",
+ xscale=log10)
+response_axis = Axis(figure[2, 2]; title="Off-target restoring response",
+ xlabel="target <- initial [deg]", ylabel="error reduction [%]",
+ xticks=(1:4, ["60 <- 90", "90 <- 60", "90 <- 120", "120 <- 90"]))
+cost_axis = Axis(figure[2, 3]; title="Repeated solver cost",
+ xlabel="mechanism", ylabel="runtime / no-contact runtime",
+ xticks=(1:4, ["none", "geometric", "CLF", "R4-W"]))
+normal_axis = Axis(figure[3, 1]; title="CLF normal diagnostics",
+ xlabel="local-circle angle [deg]", ylabel="weighted normal angle [deg]",
+ limits=(20, 160, 20, 160))
+line_axis = Axis(figure[3, 2]; title="Contact-line normalization",
+ xlabel="target angle [deg]", ylabel="line-length error [%]")
+sign_axis = Axis(figure[3, 3]; title="Fixed signs (measure-gated separately)",
+ xlabel="validation-only variant", ylabel="passing cases",
+ xticks=(1:9,
+ ["geometric", "CLF", "ghost", "wall 1x", "wall 2x",
+ "wetted R6", "wetted R7", "geometry", "Young BC"]),
+ xticklabelrotation=pi / 5,
+ limits=(nothing, nothing, 0, 4.4))
+
+particles = getindex.(young_laplace, "particle_count")
+errors = 100 .* getindex.(young_laplace, "relative_error")
+scatterlines!(young_laplace_axis, particles, errors;
+ color=:navy, marker=:circle, label="CSS operator fit")
+hlines!(young_laplace_axis, [5.0]; color=:firebrick, linestyle=:dash,
+ label="5% acceptance")
+axislegend(young_laplace_axis; position=:rt)
+
+colors = (:dodgerblue, :darkorange, :seagreen)
+markers = (:circle, :diamond, :utriangle)
+for (index, resolution) in enumerate((750, 1500, 3000))
+ for (mechanism, axis) in (("geometric", geometric_axis),
+ ("contact_line_force", clf_axis))
+ rows = sessile[(sessile.requested_particles .== resolution) .& (sessile.mechanism .== mechanism),
+ :]
+ sort!(rows, :target)
+ scatterlines!(axis, rows.target, rows.circle_angle;
+ color=colors[index], marker=markers[index],
+ label="$resolution particles")
+ end
+end
+for axis in (geometric_axis, clf_axis)
+ lines!(axis, [20, 160], [20, 160]; color=:black, linestyle=:dash,
+ label="target")
+ axislegend(axis; position=:lt)
+end
+
+mechanism_colors = Dict("none" => :gray45, "geometric" => :dodgerblue,
+ "contact_line_force" => :darkorange,
+ "r4_wetted_area" => :firebrick)
+mechanism_markers = Dict("none" => :rect, "geometric" => :circle,
+ "contact_line_force" => :diamond,
+ "r4_wetted_area" => :star5)
+for mechanism in ("geometric", "contact_line_force")
+ row = only(eachrow(scorecard[scorecard.mechanism .== mechanism, :]))
+ scatterlines!(mae_axis, [750, 1500, 3000],
+ [row.mae_750, row.mae_1500, row.mae_3000];
+ color=mechanism_colors[mechanism], marker=mechanism_markers[mechanism],
+ label=replace(mechanism, "contact_line_force" => "CLF"))
+end
+hlines!(mae_axis, [5.0]; color=:firebrick, linestyle=:dash, label="5 deg gate")
+axislegend(mae_axis; position=:rt)
+
+for mechanism in ("none", "geometric", "contact_line_force")
+ rows = perturbation[perturbation.mechanism .== mechanism, :]
+ scatterlines!(response_axis, 1:4, 100 .* rows.error_reduction;
+ color=mechanism_colors[mechanism], marker=mechanism_markers[mechanism],
+ label=replace(mechanism, "contact_line_force" => "CLF"))
+end
+r4_response = r4_perturbation[r4_perturbation.mechanism .== "wetted_area_production", :]
+scatterlines!(response_axis, 1:4, 100 .* r4_response.error_reduction;
+ color=mechanism_colors["r4_wetted_area"],
+ marker=mechanism_markers["r4_wetted_area"], label="production (0.02 s)")
+hlines!(response_axis, [0.0]; color=:firebrick, linestyle=:dash)
+axislegend(response_axis; position=:lb)
+
+cost_mechanisms = ("none", "geometric", "contact_line_force")
+cost_medians = [median(cost[cost.mechanism .== mechanism, :normalized_runtime])
+ for mechanism in cost_mechanisms]
+push!(cost_medians,
+ median(r4_cost[r4_cost.mechanism .== "wetted_area_production",
+ :normalized_runtime]))
+barplot!(cost_axis, 1:4, cost_medians;
+ color=[mechanism_colors[mechanism]
+ for mechanism in (cost_mechanisms..., "r4_wetted_area")])
+hlines!(cost_axis, [1.0]; color=:black, linestyle=:dash)
+
+for (variant, color, marker, label) in (("baseline_total", :darkorange, :circle,
+ "wall-completed"),
+ ("fluid_only", :purple, :diamond, "fluid-only"))
+ rows = normal_components[(normal_components.variant .== variant) .& (normal_components.requested_particles .== 1500) .& (normal_components.target .== normal_components.initial_angle),
+ :]
+ scatterlines!(normal_axis, rows.local_angle, rows.angle_mean;
+ color, marker, label)
+end
+lines!(normal_axis, [20, 160], [20, 160]; color=:black, linestyle=:dash,
+ label="reference")
+axislegend(normal_axis; position=:lt)
+
+line_rows = normal_components[(normal_components.variant .== "baseline_total") .& (normal_components.requested_particles .== 1500) .& (normal_components.target .== normal_components.initial_angle),
+ :]
+scatterlines!(line_axis, line_rows.target, 100 .* line_rows.line_length_error;
+ color=:darkorange, marker=:circle, label="current divergence")
+scatterlines!(line_axis, line_rows.target, 100 .* line_rows.raw_cross_error_4x;
+ color=:purple, marker=:diamond, label="raw coarea, one-sided")
+scatterlines!(line_axis, line_rows.target, 100 .* line_rows.corrected_cross_error_4x;
+ color=:seagreen, marker=:utriangle, label="coarea / support moment")
+compatible_rows = cap_transfer[(cap_transfer.variant .== "compatible_indicator") .& (cap_transfer.requested_particles .== 1500),
+ :]
+scatterlines!(line_axis, compatible_rows.target, 100 .* compatible_rows.line_length_error;
+ color=:steelblue, marker=:rect, label="compatible continuation")
+wetted_rows = wetted_area[wetted_area.requested_particles .== 1500, :]
+scatterlines!(line_axis, wetted_rows.target, 100 .* wetted_rows.area_error;
+ color=:gray55, marker=:cross, linestyle=:dot, label="wetted area R6")
+control_rows = measure_protocol[(measure_protocol.series .== "production_resolution") .& (measure_protocol.requested_particles .== 1500),
+ :]
+scatterlines!(line_axis, control_rows.target, 100 .* control_rows.line_length_error;
+ color=:black, marker=:star5, linestyle=:dash,
+ label="phase-averaged exact control")
+for (variant, color, marker, label) in
+ (("compatible_geometry_wall", :purple, :hexagon, "compatible + geometry"),
+ ("young_color_boundary", :darkorange, :pentagon, "Young color BC"))
+ rows = extended_recovery[(extended_recovery.variant .== variant) .& (extended_recovery.requested_particles .== 1500),
+ :]
+ scatterlines!(line_axis, rows.target, 100 .* rows.line_length_error;
+ color, marker, label)
+end
+corrected_rows = corrected_wetted_area[corrected_wetted_area.requested_particles .== 1500,
+ :]
+scatterlines!(line_axis, corrected_rows.target,
+ 100 .* corrected_rows.corrected_area_error;
+ color=:firebrick, marker=:xcross, label="corrected wetted area")
+hlines!(line_axis, [20.0]; color=:firebrick, linestyle=:dash, label="20% gate")
+axislegend(line_axis; position=:lt)
+
+sign_counts = [count(force_sign[(force_sign.mechanism .== "geometric"), :sign_pass]),
+ count(force_sign[(force_sign.mechanism .== "contact_line_force"), :sign_pass]),
+ count(ghost_sign.sign_pass),
+ count(wall_energy_sign[wall_energy_sign.variant .== "wall_energy_1x", :sign_pass]),
+ count(wall_energy_sign[wall_energy_sign.variant .== "wall_energy_2x", :sign_pass]),
+ count(wetted_area_sign.sign_pass),
+ count(extended_sign[(extended_sign.variant .== "corrected_wetted_area"), :sign_pass]),
+ count(extended_sign[(extended_sign.variant .== "compatible_geometry_wall"), :sign_pass]),
+ count(extended_sign[(extended_sign.variant .== "young_color_boundary"), :sign_pass])]
+barplot!(sign_axis, 1:9, sign_counts;
+ color=[:dodgerblue, :darkorange, :seagreen, :gray55, :purple, :steelblue,
+ :firebrick, :mediumpurple, :goldenrod])
+hlines!(sign_axis, [4.0]; color=:black, linestyle=:dash)
+
+save(joinpath(@__DIR__, "surface_tension_3d_validation.png"), figure)
+figure
diff --git a/validation/surface_tension_3d/sessile_drop_matrix.csv b/validation/surface_tension_3d/sessile_drop_matrix.csv
new file mode 100644
index 0000000000..0674307133
--- /dev/null
+++ b/validation/surface_tension_3d/sessile_drop_matrix.csv
@@ -0,0 +1,31 @@
+target,mechanism,requested_particles,particle_count,cap_angle,circle_angle,normal_angle,line_angle,cap_error,circle_error,circle_residual,circle_particles,contact_line_delta_min,contact_line_delta_max,rms_speed,density_min,density_max,below_wall,wall_contact_particles,active_contact,runtime,minimum_dt,accepted_steps,rejected_steps,settled,pass
+30.0,geometric,750,760,26.920499014559073,30.88697777556394,30.0,NaN,3.0795009854409265,0.8869777755639383,0.0026055138850754555,3,NaN,NaN,0.002102947133908399,996.1257023182767,1000.0029711081191,0,648,440,15.01505849,2.990649902317316e-6,253,33,true,true
+60.0,geometric,750,776,55.64270448130161,60.69546515528803,60.00000000000001,NaN,4.357295518698393,0.6954651552880335,0.014922548102401187,5,NaN,NaN,0.0017874527296501707,998.4641953154359,1000.0040788459729,0,456,188,10.860812935,1.2024098237502343e-5,257,51,true,true
+90.0,geometric,750,736,89.12328389083845,89.46123116246471,90.00000000000001,NaN,0.8767161091615492,0.5387688375352866,0.031177329136510265,6,NaN,NaN,0.0013751644081274527,999.1477966813627,1000.0046758354353,0,304,120,10.341213254,1.2012674060611338e-5,260,53,true,true
+120.0,geometric,750,768,119.77592722778436,121.2470454059745,120.00000000000003,NaN,0.22407277221563504,1.2470454059745038,0.02752704978682369,6,NaN,NaN,0.0009867024286288602,999.6361957947536,1000.0060352986809,0,200,88,10.213386537,1.0646272313595541e-5,265,35,true,true
+150.0,geometric,750,728,166.29528608774538,152.86207768028865,150.00000000000003,NaN,16.29528608774538,2.8620776802886496,0.04295638740647965,6,NaN,NaN,0.0006961771483194766,999.8056887131236,1000.0037072751904,0,104,64,9.344236264,1.3038998752400024e-5,260,28,true,true
+30.0,contact_line_force,750,760,26.954939723319125,30.885677886816357,22.154718625982145,26.32437008297446,3.045060276680875,0.8856778868163566,0.0026490645088642906,3,0.35435582264178833,247037.6312042993,0.0020470613537054145,997.1391926082517,1000.0032712466277,0,648,440,13.528474774,1.3304300504571544e-5,251,29,true,true
+60.0,contact_line_force,750,776,55.63051792248059,60.78723612318782,47.62753355675624,50.72825898255678,4.369482077519407,0.7872361231878173,0.01441950582031235,5,165.90537432289318,330375.9207002578,0.0019462656136560298,999.1462783756499,1000.0031497696168,0,456,188,12.211942985,3.0684658060085163e-6,255,40,true,true
+90.0,contact_line_force,750,736,89.12159593970178,89.69736219121037,72.86783618781452,69.30319778896389,0.878404060298223,0.3026378087896262,0.030515972902721032,6,26776.2726568933,340995.92266749055,0.0015941044199258038,999.5559962901253,1000.0045874437917,0,304,120,12.346863226,1.3138195770046186e-5,260,53,true,true
+120.0,contact_line_force,750,768,119.77698080851438,121.48532405962995,101.31693356977595,86.46156466742325,0.22301919148561922,1.4853240596299457,0.02947669843699982,6,170172.70995426743,263362.072225258,0.0010886594879424263,999.8226238504733,1000.0064000434708,0,200,88,11.9837939,6.106697564926475e-6,265,38,true,true
+150.0,contact_line_force,750,728,166.28808893896334,153.17473823390688,108.64928575349461,84.77109485735821,16.28808893896334,3.1747382339068793,0.04015861429865923,6,145970.96124327622,244199.5917655198,0.0008987981509955039,999.883081946122,1000.004651448429,0,104,64,9.767131225,1.4636268623262645e-5,257,7,true,true
+30.0,geometric,1500,1504,29.421529099986376,29.920274581136944,30.000000000000007,NaN,0.5784709000136239,0.07972541886305606,0.005167948084500418,4,NaN,NaN,0.002605075276133711,992.334046835657,1000.0048967656232,0,1108,572,23.815135705,9.881050214002021e-6,313,3,true,true
+60.0,geometric,1500,1480,65.7006823469473,59.31144064053378,60.00000000000001,NaN,5.700682346947303,0.6885593594662183,0.01237084275899368,6,NaN,NaN,0.002293351507315767,996.341401965904,1000.0048171437479,0,724,240,25.052916271,8.721156976723016e-6,315,40,true,true
+90.0,geometric,1500,1508,90.58640638332251,87.69334976331784,89.99999999999997,NaN,0.5864063833225117,2.3066502366821595,0.014414515226628035,6,NaN,NaN,0.001706369981150055,998.5266419909758,1000.004946729065,0,504,152,25.644853946,9.581207075000356e-6,324,47,true,true
+120.0,geometric,1500,1492,116.74896153179755,118.05268994867912,120.0,NaN,3.2510384682024522,1.9473100513208834,0.021525100023165655,6,NaN,NaN,0.0013611145762850822,999.0652135290425,1000.0055170854615,0,312,112,24.731249546,9.122174922319764e-6,326,37,true,true
+150.0,geometric,1500,1504,144.62627626414607,148.87814467567287,150.0,NaN,5.373723735853929,1.1218553243271288,0.02043480035940678,6,NaN,NaN,0.0008449036919432823,999.4741684233477,1000.0054920712984,0,148,80,22.456293513,9.706736095773336e-6,324,5,true,true
+30.0,contact_line_force,1500,1504,29.41600236189788,29.92814602562485,23.969491956251513,27.41972785910578,0.5839976381021188,0.07185397437514851,0.005177125630774616,4,1.9791174179585451,390648.67625240487,0.0025891691703652966,994.1803700317246,1000.0045482955857,0,1108,572,31.97762712,1.0012118607281843e-5,316,50,true,true
+60.0,contact_line_force,1500,1480,65.69750027009712,59.448783834337874,50.42505579176885,52.707528061331786,5.697500270097123,0.551216165662126,0.012121173708090611,6,91.28277161043081,521802.8229248563,0.002421758274025191,997.9602306313226,1000.0048037446023,0,724,240,29.861127174,8.53417495188906e-6,316,41,true,true
+90.0,contact_line_force,1500,1508,90.58625647929773,88.10141294954403,74.38681837244559,74.28946091162494,0.586256479297731,1.8985870504559728,0.01470395600144048,6,28886.438611329213,516851.55192913796,0.0019233693740171843,999.2120738295713,1000.0056341752288,0,504,152,30.539032945,1.0853040827514874e-5,322,44,true,true
+120.0,contact_line_force,1500,1492,116.75085584383369,118.53377283952287,96.93148977097279,84.67035436934817,3.2491441561663095,1.466227160477132,0.020792996323214234,6,277135.9291144772,431622.4371394571,0.0014601511442604654,999.6316784029657,1000.0048768951992,0,312,112,29.183058122,1.1126882728085577e-5,326,33,true,true
+150.0,contact_line_force,1500,1504,144.62543367617283,149.24768579350564,119.28171151568445,93.82106167855582,5.3745663238271675,0.7523142064943613,0.019300181838670636,6,215457.5124469365,301389.468674067,0.000990554948334853,999.670982406427,1000.0045797398249,0,148,80,26.374780598,1.1811766148082001e-5,322,2,true,true
+30.0,geometric,3000,3004,29.105878363154012,30.251996118342028,30.000000000000018,NaN,0.8941216368459877,0.25199611834202784,0.001980163881208549,5,NaN,NaN,0.003312793167729143,985.1533023160614,1000.0054016031571,0,1864,732,64.262688171,7.598089854785776e-6,397,55,true,true
+60.0,geometric,3000,2980,57.437481000237995,60.1282412846519,60.000000000000014,NaN,2.5625189997620055,0.12824128465189943,0.007299988129954849,6,NaN,NaN,0.0028733402389355444,995.9594621605468,1000.0057542336615,0,1172,288,61.216900831,4.7774123110577715e-6,395,27,true,true
+90.0,geometric,3000,3004,87.4692135222045,91.01670375319902,90.0,NaN,2.530786477795502,1.0167037531990246,0.010452504337191958,6,NaN,NaN,0.0021564393325065345,998.4478021851147,1000.0061146728438,0,784,200,59.281764905,7.643041298297752e-6,398,5,true,true
+120.0,geometric,3000,2992,118.26701436093805,118.97157563908297,120.0,NaN,1.7329856390619511,1.028424360917029,0.015187494864333712,6,NaN,NaN,0.001675000782067182,998.7460003372303,1000.0067583333268,0,472,144,56.257043682,1.6516015193587114e-6,403,2,true,true
+150.0,geometric,3000,3004,153.6504052045864,151.31392178229422,150.0,NaN,3.6504052045864057,1.3139217822942157,0.023575600303453632,6,NaN,NaN,0.0011076661616290667,998.6853288257505,1000.0069241777505,0,212,100,57.854040984,7.989144772329271e-6,409,10,true,true
+30.0,contact_line_force,3000,3004,29.106846716768555,30.270780002799746,26.30422955692083,28.86369299816991,0.8931532832314453,0.27078000279974646,0.0019949309152669497,5,4.920838760125473,594977.4176644931,0.0032614856967259805,987.5003492054914,1000.0052920861474,0,1864,732,83.709309205,2.2557370645426983e-6,396,108,true,true
+60.0,contact_line_force,3000,2980,57.43452780734388,60.29764894469492,58.15538265482376,63.17042858428314,2.5654721926561166,0.2976489446949202,0.007808501712398238,6,706.8091048942641,773822.4495392913,0.0028488252484061252,996.7929597078186,1000.0051739837326,0,1172,288,69.889725033,6.6712154759059755e-6,394,21,true,true
+90.0,contact_line_force,3000,3004,87.47029232115011,91.30594244762742,85.21469227469628,85.80894749825475,2.5297076788498885,1.305942447627416,0.011618209917243041,6,386246.3256219384,737273.0706732658,0.002172768727999379,998.9475027765128,1000.0060193666966,0,784,200,68.458771322,8.160331821236937e-6,398,9,true,true
+120.0,contact_line_force,3000,2992,118.26901236708602,119.46029679396479,109.95373393323099,97.27984442429576,1.7309876329139797,0.5397032060352132,0.0189527425329251,6,365419.7319687133,525349.0442159885,0.001641360972585478,999.2551871311144,1000.0068360065661,0,472,144,68.247226945,8.385441621113548e-6,402,8,true,true
+150.0,contact_line_force,3000,3004,153.65195747415927,151.77591069417699,123.888901054213,99.359362921928,3.651957474159275,1.7759106941769858,0.020058204529680795,6,311115.069874384,395310.6056429551,0.0012098632466197802,999.0896701977525,1000.0069981924015,0,212,100,68.166689485,8.72248113278068e-6,409,8,true,true
diff --git a/validation/surface_tension_3d/sessile_drop_sensitivity.csv b/validation/surface_tension_3d/sessile_drop_sensitivity.csv
new file mode 100644
index 0000000000..9f347d34c8
--- /dev/null
+++ b/validation/surface_tension_3d/sessile_drop_sensitivity.csv
@@ -0,0 +1,9 @@
+target,mechanism,requested_particles,particle_count,cap_angle,circle_angle,normal_angle,line_angle,cap_error,circle_error,circle_residual,circle_particles,contact_line_delta_min,contact_line_delta_max,rms_speed,density_min,density_max,below_wall,wall_contact_particles,active_contact,runtime,minimum_dt,accepted_steps,rejected_steps,settled,pass,boundary_contact_threshold,damping_coefficient
+90.0,geometric,1500,1508,90.62277073549457,87.58487401828759,89.99999999999999,NaN,0.6227707354945693,2.415125981712407,0.013696044131690222,6,NaN,NaN,0.0029203217153465563,996.5138098217276,1000.0058692910537,0,504,152,31.321608317,2.005098208621159e-6,327,60,true,true,0.0,2000.0
+90.0,contact_line_force,1500,1508,90.62286688238271,88.12178597289912,76.82754293358803,78.13739979031836,0.6228668823827093,1.8782140271008814,0.013135551511804702,6,10747.593053199895,484689.8116232382,0.003088429658874799,997.9424886432711,1000.0054227662058,0,504,152,32.452910317,1.0949555714667174e-5,326,51,true,true,0.0,2000.0
+90.0,geometric,1500,1508,90.55318539043495,87.73867353723509,90.00000000000001,NaN,0.5531853904349475,2.261326462764913,0.014887560060616001,6,NaN,NaN,0.000989259169355456,999.3765306131346,1000.0054252399374,0,504,152,21.909437312,9.490288862012525e-6,314,2,true,true,0.0,8000.0
+90.0,contact_line_force,1500,1508,90.55317701181929,88.03084140832455,72.86754215924022,71.15486713315799,0.5531770118192867,1.969158591675452,0.01485613047233434,6,10049.809078548247,534576.624651186,0.0011566252652476117,999.6914956950644,1000.0056099263002,0,504,152,25.75723981,1.0690859347634518e-5,313,1,true,true,0.0,8000.0
+90.0,geometric,1500,1508,90.6230740655244,87.61677624918168,90.0,NaN,0.6230740655244063,2.383223750818317,0.013569204031720662,6,NaN,NaN,0.002903626520698283,996.5186959539869,1000.0059536638425,0,504,152,26.820368705,9.529206474289304e-6,327,61,true,true,0.1,2000.0
+90.0,contact_line_force,1500,1508,90.62264445713208,88.05017654009669,78.26374453125104,82.18409323432341,0.6226444571320826,1.949823459903314,0.013265482897604873,6,8111.392809736609,545618.0690035581,0.003084331372083374,997.7051721321341,1000.0054294737424,0,504,152,31.663820354,1.0333681196929338e-5,326,61,true,true,0.1,2000.0
+90.0,geometric,1500,1508,90.55326407410888,87.73769708951859,90.00000000000001,NaN,0.5532640741088812,2.2623029104814094,0.014892092244911424,6,NaN,NaN,0.0009776148927421095,999.3847185595515,1000.0053788894674,0,504,152,22.016270481,2.741312206767932e-6,315,2,true,true,0.1,8000.0
+90.0,contact_line_force,1500,1508,90.55300743406777,87.98727099971686,75.18151720414643,77.50438399811257,0.5530074340677658,2.0127290002831444,0.014964367442330772,6,8652.700044334875,585651.0629066807,0.0011215567832018557,999.6020763070381,1000.0057701287841,0,504,152,27.068283721,1.0108800443218643e-5,314,16,true,true,0.1,8000.0
diff --git a/validation/surface_tension_3d/validation_reference.json b/validation/surface_tension_3d/validation_reference.json
new file mode 100644
index 0000000000..9906f201ac
--- /dev/null
+++ b/validation/surface_tension_3d/validation_reference.json
@@ -0,0 +1,67 @@
+{
+ "young_laplace": {
+ "observed_order": 1.249718322535093,
+ "results": [
+ {
+ "interface_particles": 224,
+ "ndims": 3,
+ "particle_count": 251,
+ "particle_spacing": 0.001500154038483742,
+ "pressure_jump": 365.34738597693996,
+ "radius": 0.005870353120050318,
+ "relative_error": 0.07235908358597865,
+ "residual": 0.28593133893764117,
+ "sigma_fit": 1.0723590835859786,
+ "sigma_virial": 0.823033964062752,
+ "surface_measure_ratio": 0.8217444519910914,
+ "target_particle_count": 268,
+ "total_force": 3.7919961295383246e-18
+ },
+ {
+ "interface_particles": 650,
+ "ndims": 3,
+ "particle_count": 925,
+ "particle_spacing": 0.0009999184774182866,
+ "pressure_jump": 327.89090236253725,
+ "radius": 0.006043877244111552,
+ "relative_error": 0.00913381832992921,
+ "residual": 0.3509693014284436,
+ "sigma_fit": 0.9908661816700708,
+ "sigma_virial": 0.8877136823485055,
+ "surface_measure_ratio": 0.8821540662569126,
+ "target_particle_count": 905,
+ "total_force": 3.018953685142852e-17
+ },
+ {
+ "interface_particles": 1190,
+ "ndims": 3,
+ "particle_count": 2109,
+ "particle_spacing": 0.0007499604390380596,
+ "pressure_jump": 331.4410201612655,
+ "radius": 0.005966244011452984,
+ "relative_error": 0.01127099915649099,
+ "residual": 0.4585775528547945,
+ "sigma_fit": 0.988729000843509,
+ "sigma_virial": 0.919700075089,
+ "surface_measure_ratio": 0.9107357664791975,
+ "target_particle_count": 2145,
+ "total_force": 4.40752503181308e-18
+ },
+ {
+ "interface_particles": 2018,
+ "ndims": 3,
+ "particle_count": 4169,
+ "particle_spacing": 0.0005999899833508688,
+ "pressure_jump": 325.50563438484306,
+ "radius": 0.005990435944573899,
+ "relative_error": 0.02503967380985328,
+ "residual": 0.5238183258453253,
+ "sigma_fit": 0.9749603261901467,
+ "sigma_virial": 0.9359970271453172,
+ "surface_measure_ratio": 0.9258425271396964,
+ "target_particle_count": 4189,
+ "total_force": 2.211333595625336e-17
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/validation/surface_tension_3d/validation_surface_tension_3d.jl b/validation/surface_tension_3d/validation_surface_tension_3d.jl
new file mode 100644
index 0000000000..266a88cbd3
--- /dev/null
+++ b/validation/surface_tension_3d/validation_surface_tension_3d.jl
@@ -0,0 +1,37 @@
+using JSON
+using Printf
+
+if !isdefined(@__MODULE__, :SurfaceTensionValidation)
+ include(joinpath(@__DIR__, "..", "surface_tension_common.jl"))
+end
+using .SurfaceTensionValidation
+
+resolution_levels = (4, 6, 8, 10)
+targets_3d = round.(Int, (4pi / 3) .* resolution_levels .^ 3)
+write_results = true
+
+young_laplace_3d = young_laplace_series(3; targets_3d)
+
+println("3D Young-Laplace operator fit")
+for result in young_laplace_3d.results
+ @printf(" n=%5d dx=%.4e sigma=%.6f error=%6.3f%% virial=%.6f |F|=%.3e\n",
+ result.particle_count, result.particle_spacing, result.sigma_fit,
+ 100result.relative_error, result.sigma_virial, result.total_force)
+end
+@printf(" observed order: %.3f\n", young_laplace_3d.observed_order)
+
+if write_results
+ output_path = joinpath(@__DIR__, "validation_reference.json")
+ output = Dict(
+ "young_laplace" => Dict(
+ "observed_order" => young_laplace_3d.observed_order,
+ "results" => [Dict(string(key) => value
+ for (key, value) in pairs(result))
+ for result in young_laplace_3d.results]
+ )
+ )
+ open(output_path, "w") do io
+ JSON.print(io, output, 2)
+ end
+ println("Wrote $output_path")
+end
diff --git a/validation/surface_tension_3d/wetted_area_corrected.csv b/validation/surface_tension_3d/wetted_area_corrected.csv
new file mode 100644
index 0000000000..905d28b5de
--- /dev/null
+++ b/validation/surface_tension_3d/wetted_area_corrected.csv
@@ -0,0 +1,16 @@
+variant,target,requested_particles,particle_count_min,particle_count_max,exposed_particles,phase_count,colorfield_reference,observed_maximum,reference_ratio,normalized_edge_shift,edge_shift,area_derivative,raw_area_phases,raw_area,raw_area_error,raw_phase_standard_error,corrected_area_phases,corrected_area,analytic_area,corrected_area_error,phase_standard_error,middle_pass,endpoint_pass,raw_middle_pass,raw_endpoint_pass,protocol_valid,measure_eligible
+corrected_wetted_area,30.0,750,743,758,2304,8,0.25057808255633596,0.23269146006979147,1.0768684155331212,-0.5071758000217979,-0.0007815068772335563,1.0651316916881841,0.00045220444935572217;0.00045252860746449353;0.00045020107356863367;0.00045252860746449353;0.00045379351315799084;0.00045350039211792338;0.00045020107356863383;0.00045350039211792327,0.00045230726360197677,0.1785532944398286,0.0009092514449434458,0.0005130353743553654;0.00051338064397941203;0.00051090135604674387;0.00051338064397941203;0.00051472785722802433;0.00051441567299076853;0.00051090135604674397;0.00051441567299076831,0.0005131448222021548,0.0005506227738700755,0.06806465959354602,0.0009685004140691069,true,true,true,true,true,true
+corrected_wetted_area,60.0,750,742,768,1296,8,0.25057790554820597,0.2326914225681465,1.07686783399491,-0.17468253476671905,-0.00026916821001103257,1.028475659062489,0.00028026509334455591;0.00028024024877211956;0.0002791891343733042;0.00028024024877211951;0.00028390454474261649;0.00028132657299943857;0.0002791891343733042;0.00028132657299943857,0.0002807101937971121,0.10191080478374615,0.0017224116686473097,0.00029646668899770362;0.00029644113638925123;0.00029536003787501486;0.00029644113638925107;0.00030020952289547964;0.00029755839009870796;0.00029536003787501486;0.00029755839009870796,0.0002969244175773914,0.0003125638247206826,0.05003588357439348,0.0017713893126961367,true,true,true,true,true,true
+corrected_wetted_area,90.0,750,749,756,784,8,0.2505777571734913,0.23269154610580686,1.0768666291340994,6.643990912991171e-16,1.0237721497273123e-18,0.9999999999999999,0.00018097941561281034;0.00018377189229162069;0.00018491542858617009;0.00018377189229162069;0.00018372969525392666;0.00018459584952869795;0.00018491542858617012;0.00018459584952869795,0.0001839094314599643,0.04171827742576084,0.0023737057421456625,0.00018097941561281031;0.00018377189229162064;0.00018491542858617004;0.00018377189229162064;0.00018372969525392663;0.00018459584952869792;0.00018491542858617009;0.00018459584952869792,0.00018390943145996428,0.0001919158292677513,0.04171827742576095,0.002373705742145658,true,true,true,true,true,true
+corrected_wetted_area,120.0,750,747,758,576,8,0.25057766920460617,0.2326926360496302,1.0768612049089,0.17468253476672238,0.00026916821001103766,0.9539517365372822,0.00011000849751556087;0.00010663709392338589;0.00010778157943900337;0.00010663709392338589;0.00010226683839720977;0.00010895363168506267;0.00010778157943900337;0.00010895363168506267,0.00010737749325095931,0.0573945193682992,0.008263349791698096,0.00010022823701517812;9.7011381165533933e-05;9.8103132252425502e-05;9.7011381165533933e-05;9.2845144813814324e-05;9.9221469258168284e-05;9.8103132252425502e-05;9.9221469258168284e-05,9.771816839765598e-5,0.00010154912975632594,0.037725201268219655,0.00788039099776169,true,true,true,true,true,true
+corrected_wetted_area,150.0,750,740,757,529,8,0.2505776396837185,0.2326910730054384,1.0768683035700208,0.5071758000217992,0.0007815068772335581,0.7956506449219525,4.4859868291166911e-05;4.5933575207371347e-05;4.5973848711353483e-05;4.5933575207371353e-05;4.4732771056769992e-05;4.7133910866329259e-05;4.5973848711353483e-05;4.7133910866329259e-05,4.595941361475563e-5,0.5075184257657939,0.010255529654508315,2.8223358710135831e-05;2.9076321662171194e-05;2.9108365790849474e-05;2.9076321662171197e-05;2.8122565535507624e-05;3.003291245012841e-05;2.9108365790849474e-05;3.003291245012841e-05,2.9097640506492702e-5,3.0486800578513014e-5,0.045565951351398515,0.00816004417289543,true,true,false,false,true,true
+corrected_wetted_area,30.0,1500,1496,1512,3721,8,0.25057808255633574,0.23269140668089314,1.0768686627311952,-0.5071758000217979,-0.0006202824195204649,1.050688325695331,0.00047405736695721229;0.00046743490763436103;0.00046963871643834792;0.00046743490763436103;0.00047035328489417695;0.00047255162706558011;0.00046963871643834792;0.00047255162706558011,0.000470457644265996,0.14558992727568365,0.0015606602887277834,0.00052314121326483773;0.00051618317630508287;0.00051849892033068443;0.00051618317630508287;0.00051924972653441808;0.00052155938046428236;0.00051849892033068443;0.00052155938046428236,0.0005193592367499194,0.0005506227738700755,0.05677850354866176,0.0016397584441505452,true,true,true,true,true,true
+corrected_wetted_area,60.0,1500,1486,1507,2116,8,0.25057790554820564,0.23269148795710684,1.076867526213514,-0.17468253476671905,-0.00021363894986395517,1.0223658257684543,0.00028680522023405089;0.00028670567276964484;0.00028536423891480165;0.00028670567276964479;0.00028726749183944863;0.00028747112074928121;0.0002853642389148016;0.00028747112074928115,0.0002866443471176194,0.08292539172191704,0.0009622930409546085,0.00029977424302821967;0.00029967246954035225;0.00029830100153334133;0.00029967246954035225;0.00030024684662821035;0.00030045502410533495;0.00029830100153334116;0.00030045502410533479,0.00029960976000181083,0.0003125638247206826,0.04144454250407248,0.000983825941946608,true,true,true,true,true,true
+corrected_wetted_area,90.0,1500,1487,1516,1296,8,0.25057775717349107,0.2326923730881462,1.076862797633186,6.643990912991171e-16,8.1256849372644025e-19,0.9999999999999999,0.00018062719089693483;0.00018566585413037589;0.00018565945891771364;0.00018566585413037592;0.00018732736876018332;0.00018428210076140952;0.00018565945891771364;0.00018428210076140949,0.00018489617340951452,0.03657674244495657,0.0036340568802274717,0.00018062719089693481;0.00018566585413037583;0.00018565945891771358;0.00018566585413037586;0.00018732736876018327;0.00018428210076140949;0.00018565945891771358;0.00018428210076140944,0.0001848961734095145,0.0001919158292677513,0.03657674244495668,0.003634056880227454,true,true,true,true,true,true
+corrected_wetted_area,120.0,1500,1496,1506,900,8,0.25057766920460584,0.2326914030136282,1.0768669041591514,0.17468253476672238,0.00021363894986395918,0.9632890074356243,0.00010527970371660537;0.00010593423499340593;0.00010661338614836795;0.00010593423499340593;0.00010886236792645595;0.00010598715489701588;0.00010661338614836798;0.00010598715489701585,0.0001064014529650801,0.04778301124192441,0.0037645748780084872,9.7652434855336249e-05;9.8282848203458024e-05;9.8937052880268935e-05;9.8282848203458024e-05;0.00010110398760000125;9.8333821393661443e-05;9.8937052880268976e-05;9.8333821393661416e-05,9.87329834262643e-5,0.00010154912975632594,0.02773186079308776,0.003626893488042049,true,true,true,true,true,true
+corrected_wetted_area,150.0,1500,1492,1507,841,8,0.25057763968371827,0.23269108327168053,1.0768682599288688,0.5071758000217992,0.0006202824195204664,0.8325364456879332,4.3852931033165351e-05;4.2372591172539023e-05;4.3121904915949915e-05;4.2372591172539023e-05;4.2376814574034325e-05;4.3822762091767203e-05;4.3121904915949929e-05;4.3822762091767203e-05,4.3108032745963996e-5,0.41399005234896236,0.00783282247495509,3.0500575350426613e-05;2.9268114027759736e-05;2.9891425551312825e-05;2.9268114027759736e-05;2.9271624127249501e-05;3.0475415972463355e-05;2.9891425551312839e-05;3.0475415972463355e-05,2.9880263822593497e-5,3.0486800578513014e-5,0.019895060957856092,0.00652106709075611,true,true,false,false,true,true
+corrected_wetted_area,30.0,3000,2999,3007,5776,8,0.2505780825563359,0.23269186855920576,1.0768665355378537,-0.5071758000217979,-0.0004923184826320829,1.0395778691286353,0.00048757746322905862;0.00048572154110057379;0.00048619739150893596;0.00048572154110057379;0.00048429550103565658;0.00048660186347670997;0.00048619739150893601;0.00048660186347670997,0.0004861143195546443,0.11715544175921166,0.0006065618041194389,0.00052687546922001332;0.00052494613410663908;0.00052544082063779021;0.00052494613410663908;0.00052346359016613889;0.00052586129602902754;0.00052544082063779021;0.00052586129602902754,0.0005253544451166332,0.0005506227738700755,0.04589045341485376,0.0006305737104548554,true,true,true,true,true,true
+corrected_wetted_area,60.0,3000,2983,3003,3364,8,0.2505779055482058,0.2326912847337963,1.0768684692353767,-0.17468253476671905,-0.0001695653468777119,1.017618154944232,0.00028924069235929795;0.00029197973534524698;0.00029121210523074822;0.00029197973534524698;0.00029008634918677607;0.00029117983417407571;0.00029121210523074816;0.00029117983417407571,0.00029100879888077695,0.06896199795087599,0.0010475567912209293,0.00029955386464700746;0.00030234119757182758;0.00030156005689531304;0.00030234119757182758;0.00030041445489902192;0.00030152721746681019;0.00030156005689531304;0.00030152721746681019,0.0003013531579267414,0.0003125638247206826,0.035866808335735634,0.0010660245685499873,true,true,true,true,true,true
+corrected_wetted_area,90.0,3000,2987,3005,2025,8,0.25057775717349123,0.2326912717320645,1.0768678889808005,6.643990912991171e-16,6.449360408687833e-19,0.9999999999999999,0.00018659542906630265;0.00018611586748968085;0.0001852367822430434;0.00018611586748968085;0.00018389824202709259;0.00018575293702323284;0.00018523678224304337;0.00018575293702323287,0.00018558810557566368,0.032971348513725296,0.0015141240717448457,0.00018659542906630257;0.00018611586748968083;0.00018523678224304334;0.00018611586748968083;0.00018389824202709251;0.00018575293702323282;0.00018523678224304329;0.00018575293702323284,0.00018558810557566362,0.0001919158292677513,0.03297134851372563,0.0015141240717448667,true,true,true,true,true,true
+corrected_wetted_area,120.0,3000,2988,3013,1444,8,0.250577669204606,0.23269264884971372,1.0768611457628912,0.17468253476672238,0.00016956534687771513,0.9706324937612856,0.00010431846329079085;0.00010394545439226646;0.00010531845550104119;0.00010394545439226647;0.00010354009454481809;0.00010576522167929551;0.00010531845550104119;0.00010576522167929549,0.0001047396026226019,0.03141802272389449,0.0031283098609884456,9.8269438358629036e-05;9.7907415454841099e-05;9.9240074965924261e-05;9.7907415454841099e-05;9.751401678791111e-05;9.9673771004088474e-05;9.9240074965924261e-05;9.9673771004088447e-05,9.867824724953098e-5,0.00010154912975632594,0.028270872568616157,0.00303643047533628,true,true,true,true,true,true
+corrected_wetted_area,150.0,3000,2995,3014,1296,8,0.2505776396837183,0.2326924008266501,1.0768621652164259,0.5071758000217992,0.0004923184826320842,0.8629343337673344,4.1308690734345384e-05;4.0360695128505118e-05;3.9908086643500546e-05;4.0360695128505118e-05;4.0781691321230779e-05;4.0824566628365092e-05;3.9908086643500552e-05;4.0824566628365092e-05,4.0534634857039716e-5,0.3295798210327252,0.005671084391101705,3.0853269462438945e-05;3.0034729226169979e-05;2.964446366350296e-05;3.0034729226169979e-05;3.0398049859423145e-05;3.0435068080545256e-05;2.964446366350297e-05;3.0435068080545256e-05,3.0184980157787312e-5,3.0486800578513014e-5,0.0099000359171314,0.00489385886190173,true,true,false,false,true,true
diff --git a/validation/surface_tension_3d/wetted_area_measure.csv b/validation/surface_tension_3d/wetted_area_measure.csv
new file mode 100644
index 0000000000..faa4e96be8
--- /dev/null
+++ b/validation/surface_tension_3d/wetted_area_measure.csv
@@ -0,0 +1,16 @@
+target,requested_particles,particle_count,exposed_particles,colorfield_reference,wetted_area,analytic_area,area_error,middle_pass,endpoint_decreasing
+30.0,750,760,2304,0.23263755884624285,0.00046999147286848857,0.0005506227738700755,0.14643655298684144,true,true
+60.0,750,776,1296,0.23265780556840482,0.00029759716482772783,0.0003125638247206826,0.047883531967685244,true,false
+90.0,750,736,784,0.23268219156681075,0.00018762717496956515,0.0001919158292677513,0.02234653761781602,true,true
+120.0,750,768,576,0.23264047231618298,0.00010670666124211234,0.00010154912975632594,0.05078853455625132,true,true
+150.0,750,728,529,0.23268686525928847,5.302053657638251e-5,3.0486800578513014e-5,0.7391308884590269,false,true
+30.0,1500,1504,3721,0.2326882121728516,0.00048458274241632346,0.0005506227738700755,0.11993697788703306,true,true
+60.0,1500,1480,2116,0.23267134435944858,0.00029684253619467776,0.0003125638247206826,0.050297850495187424,true,false
+90.0,1500,1508,1296,0.23265112352485573,0.00019451686178252898,0.0001919158292677513,0.0135529858308292,true,true
+120.0,1500,1492,900,0.23263269067405629,0.00011283870459231507,0.00010154912975632594,0.11117352618460874,true,true
+150.0,1500,1504,841,0.2326895729884707,4.5920322005379596e-5,3.0486800578513014e-5,0.5062361787397287,false,true
+30.0,3000,3004,5776,0.2326787231917608,0.0004989373496030085,0.0005506227738700755,0.09386721131019304,true,true
+60.0,3000,2980,3364,0.2326871368839974,0.00029464925044996905,0.0003125638247206826,0.057314931715858775,true,false
+90.0,3000,3004,2025,0.23268073363268899,0.00018807120808691185,0.0001919158292677513,0.0200328508362676,true,true
+120.0,3000,2992,1444,0.2326386743922583,0.00010416571646550096,0.00010154912975632594,0.025766707360798558,true,true
+150.0,3000,3004,1296,0.23263249972901354,4.250390600823551e-5,3.0486800578513014e-5,0.3941740425917999,false,true
diff --git a/validation/surface_tension_common.jl b/validation/surface_tension_common.jl
new file mode 100644
index 0000000000..4bd0c1d8f3
--- /dev/null
+++ b/validation/surface_tension_common.jl
@@ -0,0 +1,314 @@
+module SurfaceTensionValidation
+
+using LinearAlgebra
+using OrdinaryDiffEqLowStorageRK
+using Statistics
+using TrixiParticles
+
+export matched_2d_targets, observed_order, quadratic_peak_times, rayleigh_mode2,
+ rayleigh_mode2_stiffness, fit_angular_frequency, young_laplace_operator_fit,
+ young_laplace_series
+
+function spherical_drop_initial_condition(ndims, target_particle_count;
+ radius=0.006, reference_density=1000.0,
+ surface_tension_coefficient=1.0,
+ stretch=1.0,
+ initialize_laplace_pressure=false)
+ volume = ndims == 2 ? pi * radius^2 : 4pi * radius^3 / 3
+ particle_spacing = (volume / target_particle_count)^(1 / ndims)
+ center = ntuple(_ -> 0.0, ndims)
+ sphere_type = ndims == 2 ? RoundSphere() : VoxelSphere()
+ shape = SphereShape(particle_spacing, radius + particle_spacing / 2, center,
+ reference_density; sphere_type)
+ coordinates = copy(shape.coordinates)
+ coordinates[1, :] .*= stretch
+ if ndims == 2
+ coordinates[2, :] ./= stretch
+ else
+ coordinates[2, :] ./= sqrt(stretch)
+ coordinates[3, :] ./= sqrt(stretch)
+ end
+
+ pressure_jump = (ndims - 1) * surface_tension_coefficient / radius
+ state_equation = StateEquationCole(; sound_speed=100.0, reference_density,
+ exponent=7, clip_negative_pressure=true)
+ density = initialize_laplace_pressure ?
+ TrixiParticles.inverse_state_equation(state_equation, pressure_jump) :
+ reference_density
+ initial_condition = InitialCondition(; coordinates, velocity=zero(shape.velocity),
+ mass=shape.mass,
+ density=fill(density, size(coordinates, 2)),
+ particle_spacing)
+ return (; initial_condition, state_equation, particle_spacing)
+end
+
+function css_system(initial_condition, state_equation;
+ surface_tension_coefficient=1.0, viscosity=nothing,
+ density_diffusion=nothing, source_terms=nothing,
+ ideal_density_threshold=0.95, shifting_technique=nothing,
+ pressure_acceleration=nothing)
+ particle_spacing = initial_condition.particle_spacing
+ smoothing_kernel = WendlandC2Kernel{size(initial_condition.coordinates, 1)}()
+ smoothing_length = 1.4particle_spacing
+ normal_method = ColorfieldSurfaceNormal(; boundary_contact_threshold=Inf,
+ interface_threshold=0.01,
+ ideal_density_threshold)
+ surface_tension = SurfaceTensionMomentumMorris(; surface_tension_coefficient)
+ return WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel,
+ smoothing_length,
+ density_calculator=ContinuityDensity(),
+ state_equation, viscosity, density_diffusion,
+ pressure_acceleration,
+ shifting_technique,
+ surface_tension,
+ surface_normal_method=normal_method,
+ reference_particle_spacing=particle_spacing,
+ source_terms)
+end
+
+function initial_acceleration(system)
+ semi = Semidiscretization(system; parallelization_backend=SerialBackend())
+ 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)
+ acceleration = 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.reset_interaction_caches!(semi)
+ TrixiParticles.interact!(dv, v, u, v, u, system, system, semi)
+ Array(dv[1:ndims(system), :])
+ end
+ return acceleration, system
+end
+
+function equivalent_radius(volume, ndims)
+ return ndims == 2 ? sqrt(volume / pi) : cbrt(3volume / (4pi))
+end
+
+function exact_surface_measure(radius, ndims)
+ return ndims == 2 ? 2pi * radius : 4pi * radius^2
+end
+
+function young_laplace_operator_fit(ndims, target_particle_count;
+ radius=0.006, reference_density=1000.0,
+ surface_tension_coefficient=1.0,
+ pressure_basis=1.0)
+ setup = spherical_drop_initial_condition(ndims, target_particle_count; radius,
+ reference_density,
+ surface_tension_coefficient)
+ (; initial_condition, state_equation, particle_spacing) = setup
+ system = css_system(initial_condition, state_equation; surface_tension_coefficient)
+ capillary_acceleration, system = initial_acceleration(system)
+
+ sound_speed = 100.0
+ pressure_reference_density = reference_density - pressure_basis / sound_speed^2
+ pressure_state_equation = StateEquationCole(; sound_speed,
+ reference_density=pressure_reference_density,
+ exponent=1)
+ pressure_system = WeaklyCompressibleSPHSystem(initial_condition;
+ smoothing_kernel=WendlandC2Kernel{ndims}(),
+ smoothing_length=1.4particle_spacing,
+ density_calculator=ContinuityDensity(),
+ state_equation=pressure_state_equation)
+ pressure_acceleration, _ = initial_acceleration(pressure_system)
+ pressure_acceleration ./= pressure_basis
+
+ interface = findall(>(0), system.cache.delta_s)
+ capillary = vec(capillary_acceleration[:, interface])
+ unit_pressure = vec(pressure_acceleration[:, interface])
+ pressure_jump = -dot(capillary, unit_pressure) / dot(unit_pressure, unit_pressure)
+ residual = capillary + pressure_jump * unit_pressure
+
+ mass = system.mass
+ volume = sum(mass) / reference_density
+ radius_discrete = equivalent_radius(volume, ndims)
+ sigma_fit = pressure_jump * radius_discrete / (ndims - 1)
+ represented_surface = sum(mass .* system.cache.delta_s) / reference_density
+ exact_surface = exact_surface_measure(radius_discrete, ndims)
+ coordinates = initial_condition.coordinates
+ center = vec(sum(coordinates .* reshape(mass, 1, :); dims=2) / sum(mass))
+ relative_coordinates = coordinates .- center
+ virial = -sum(mass .* vec(sum(relative_coordinates .* capillary_acceleration; dims=1)))
+ sigma_virial = virial / ((ndims - 1) * exact_surface)
+ total_force = vec(sum(capillary_acceleration .* reshape(mass, 1, :); dims=2))
+
+ return (; ndims, particle_count=nparticles(system), target_particle_count,
+ particle_spacing, radius=radius_discrete,
+ interface_particles=length(interface), pressure_jump, sigma_fit,
+ sigma_virial, relative_error=abs(sigma_fit / surface_tension_coefficient - 1),
+ residual=norm(residual) / norm(capillary),
+ surface_measure_ratio=represented_surface / exact_surface,
+ total_force=norm(total_force))
+end
+
+function observed_order(results)
+ spacings = [result.particle_spacing for result in results]
+ errors = [max(result.relative_error, eps()) for result in results]
+ x = log.(spacings)
+ y = log.(errors)
+ x_mean = mean(x)
+ y_mean = mean(y)
+ return sum((x .- x_mean) .* (y .- y_mean)) / sum(abs2, x .- x_mean)
+end
+
+function matched_2d_targets(targets_3d)
+ return [round(Int, pi / (4pi / (3target))^(2 / 3)) for target in targets_3d]
+end
+
+function young_laplace_series(ndims;
+ targets_3d=(375, 750, 1500, 3000), kwargs...)
+ targets = ndims == 2 ? matched_2d_targets(targets_3d) : collect(targets_3d)
+ results = [young_laplace_operator_fit(ndims, target; kwargs...) for target in targets]
+ return (; ndims, targets, results, observed_order=observed_order(results))
+end
+
+function signed_axes(state, system, semi)
+ v_ode, u_ode = state.x
+ u = TrixiParticles.wrap_u(u_ode, system, semi)
+ coordinates = Array(TrixiParticles.current_coordinates(u, system))
+ mass = system.mass
+ center = vec(sum(coordinates .* reshape(mass, 1, :); dims=2) / sum(mass))
+ relative = coordinates .- center
+ covariance = (relative .* reshape(mass, 1, :)) * transpose(relative) / sum(mass)
+ axis_x = 2sqrt(max(covariance[1, 1], zero(eltype(covariance))))
+ axis_y = 2sqrt(max(covariance[2, 2], zero(eltype(covariance))))
+ return axis_x, axis_y
+end
+
+function quadratic_peak_times(time, signal)
+ peaks = Float64[]
+ for index in 2:(length(signal) - 1)
+ signal[index] > signal[index - 1] || continue
+ signal[index] >= signal[index + 1] || continue
+ y_left = signal[index - 1]
+ y_center = signal[index]
+ y_right = signal[index + 1]
+ denominator = y_left - 2y_center + y_right
+ offset = iszero(denominator) ? 0.0 :
+ 0.5 * (y_left - y_right) / denominator
+ dt = (time[index + 1] - time[index - 1]) / 2
+ push!(peaks, time[index] + offset * dt)
+ end
+ return peaks
+end
+
+function fit_angular_frequency(time, signal, omega_reference;
+ minimum_ratio=0.5, maximum_ratio=1.5,
+ samples=1001)
+ frequencies = range(minimum_ratio * omega_reference,
+ maximum_ratio * omega_reference; length=samples)
+ best_frequency = first(frequencies)
+ best_residual = Inf
+ centered_time = time .- first(time)
+ for omega in frequencies
+ design = hcat(ones(length(time)), centered_time,
+ cos.(omega .* centered_time), sin.(omega .* centered_time))
+ coefficients = design \ signal
+ residual = sum(abs2, design * coefficients - signal)
+ if residual < best_residual
+ best_residual = residual
+ best_frequency = omega
+ end
+ end
+ return (; omega=best_frequency,
+ residual=best_residual / sum(abs2, signal .- mean(signal)))
+end
+
+function rayleigh_mode2_stiffness(target_particle_count;
+ radius=0.01, reference_density=1000.0,
+ surface_tension_coefficient=1.0, stretch=1.02,
+ viscosity_alpha=0.0)
+ setup = spherical_drop_initial_condition(2, target_particle_count; radius,
+ reference_density,
+ surface_tension_coefficient, stretch,
+ initialize_laplace_pressure=true)
+ (; initial_condition, state_equation, particle_spacing) = setup
+ viscosity = ArtificialViscosityMonaghan(; alpha=viscosity_alpha, beta=0.0)
+ system = css_system(initial_condition, state_equation; surface_tension_coefficient,
+ viscosity)
+ acceleration, system = initial_acceleration(system)
+ coordinates = initial_condition.coordinates
+ mass = system.mass
+ center = vec(sum(coordinates .* reshape(mass, 1, :); dims=2) / sum(mass))
+ relative = coordinates .- center
+ quadrupole = sum(mass .* (relative[1, :] .^ 2 .- relative[2, :] .^ 2)) /
+ sum(mass)
+ quadrupole_acceleration = 2sum(mass .* (relative[1, :] .* acceleration[1, :] .-
+ relative[2, :] .* acceleration[2, :])) /
+ sum(mass)
+ omega_squared = -quadrupole_acceleration / quadrupole
+ area = sum(mass) / reference_density
+ radius_discrete = equivalent_radius(area, 2)
+ omega_exact = sqrt(6surface_tension_coefficient /
+ (reference_density * radius_discrete^3))
+ omega_measured = sqrt(max(omega_squared, zero(omega_squared)))
+ return (; target_particle_count, particle_count=nparticles(system), particle_spacing,
+ radius=radius_discrete, quadrupole, quadrupole_acceleration,
+ omega_squared, omega_exact, omega_measured,
+ frequency_error=abs(omega_measured / omega_exact - 1),
+ acceleration_rms=sqrt(mean(abs2, acceleration)))
+end
+
+function rayleigh_mode2(target_particle_count;
+ radius=0.01, reference_density=1000.0,
+ surface_tension_coefficient=1.0, stretch=1.0,
+ mode_amplitude=0.02, periods=1.2,
+ viscosity_alpha=0.05)
+ setup = spherical_drop_initial_condition(2, target_particle_count; radius,
+ reference_density,
+ surface_tension_coefficient, stretch,
+ initialize_laplace_pressure=true)
+ (; initial_condition, state_equation, particle_spacing) = setup
+ area = sum(initial_condition.mass) / reference_density
+ radius_discrete = equivalent_radius(area, 2)
+ omega_exact = sqrt(6surface_tension_coefficient /
+ (reference_density * radius_discrete^3))
+ velocity = zeros(size(initial_condition.velocity))
+ velocity[1, :] .= mode_amplitude * omega_exact .* initial_condition.coordinates[1, :]
+ velocity[2, :] .= -mode_amplitude * omega_exact .* initial_condition.coordinates[2, :]
+ initial_condition = InitialCondition(; coordinates=initial_condition.coordinates,
+ velocity, mass=initial_condition.mass,
+ density=initial_condition.density,
+ particle_spacing)
+ viscosity = ArtificialViscosityMonaghan(; alpha=viscosity_alpha, beta=0.0)
+ density_diffusion = DensityDiffusionAntuono(; delta=0.05)
+ system = css_system(initial_condition, state_equation; surface_tension_coefficient,
+ viscosity, density_diffusion)
+ semi = Semidiscretization(system; parallelization_backend=SerialBackend())
+
+ period_exact = 2pi / omega_exact
+ final_time = periods * period_exact
+ ode = semidiscretize(semi, (0.0, final_time))
+ capillary_dt = sqrt(reference_density * (1.4particle_spacing)^3 /
+ (2pi * surface_tension_coefficient))
+ saveat = range(0.0, final_time; step=period_exact / 50)
+ solution = nothing
+ runtime = @elapsed solution = solve(ode, RDPK3SpFSAL35(); abstol=1.0e-8,
+ reltol=2.0e-5,
+ dtmax=min(period_exact / 120, capillary_dt),
+ save_everystep=false, saveat)
+
+ axes = [signed_axes(state, system, semi) for state in solution.u]
+ axis_x = first.(axes)
+ axis_y = last.(axes)
+ deformation = axis_x .- axis_y
+ peak_times = quadratic_peak_times(solution.t, deformation)
+ fit = fit_angular_frequency(solution.t, deformation, omega_exact)
+ omega_measured = fit.omega
+ measured_period = 2pi / omega_measured
+ frequency_error = abs(omega_measured / omega_exact - 1)
+ midpoint = length(deformation) รท 2
+ damping_ratio = std(deformation[(midpoint + 1):end]) /
+ std(deformation[1:midpoint])
+
+ return (; target_particle_count, particle_count=nparticles(system), particle_spacing,
+ radius=radius_discrete, omega_exact, omega_measured, period_exact,
+ measured_period, frequency_error, fit_residual=fit.residual,
+ damping_ratio, runtime,
+ accepted_steps=solution.stats.naccept, rejected_steps=solution.stats.nreject,
+ time=collect(solution.t), axis_x, axis_y, deformation,
+ peak_times)
+end
+
+end # module