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()`). - - - -
-
2D Dam Break
+
+
2D Dam Break
-
Moving Wall
+
+
Moving Wall
-
Oscillating Beam
+
+
Oscillating Beam
-
Dam Break with Elastic Plate
+
+
Dam Break with Elastic Plate
diff --git a/compare_akinci/.gitignore b/compare_akinci/.gitignore new file mode 100644 index 0000000000..ba37a5de2f --- /dev/null +++ b/compare_akinci/.gitignore @@ -0,0 +1,2 @@ +/Manifest.toml +!/*.png diff --git a/compare_akinci/1-s2.0-S0045782521005892-main.pdf b/compare_akinci/1-s2.0-S0045782521005892-main.pdf new file mode 100644 index 0000000000..bb39162b28 Binary files /dev/null and b/compare_akinci/1-s2.0-S0045782521005892-main.pdf differ diff --git a/compare_akinci/CSS_plan.md b/compare_akinci/CSS_plan.md new file mode 100644 index 0000000000..6f765e08cd --- /dev/null +++ b/compare_akinci/CSS_plan.md @@ -0,0 +1,1678 @@ +# CSS Surface Tension Transition Plan + +Tracking document for replacing the Akinci surface tension model with a validated +continuum-surface-stress (CSS) model (`SurfaceTensionMomentumMorris`) as the recommended +production model in TrixiParticles.jl. + +| Field | Value | +|---|---| +| Branch | `surface_tension_fix` (single PR, no upstream splitting) | +| Created | 2026-08-03 | +| Status | Phases 0-3 complete; R4-W production replay, D5 cleanup, and final G3 verification passed | +| Companion documents | `compare_akinci/README.md` (Akinci investigation and acceptance workflow) | + +## Phase status + +| Phase | Status | Gate | +|---|---|---| +| Phase 0 - Stabilize working tree | **Complete** | G0 closed | +| Phase 1 - Correct Morris and smooth activation | **Complete** | G1 closed | +| Phase 2 - Quantitative validation | **Complete** | G2 closed | +| Phase 3 - Contact mechanism decision | **Complete** | G3 closed | +| Phase 4 - Akinci-parity demonstrations | **In progress** - Figure 2 CSS bulk parity fails | G4 open | +| Phase 5 - Cleanup and handoff | Blocked by G4 | G5 open | + +## Goal and success criteria + +The Akinci (2013) model does not reliably work with WCSPH (see the Figure 8 investigation +in `compare_akinci/README.md`). The CSS model must be **at least the same quality as Akinci +in the cases shown by Akinci, but more reliable and scientifically accurate**. This is +operationalized as a two-track acceptance: + +- **Track A - scientific accuracy (physical sigma).** Quantitative validation against + analytic references: Young-Laplace pressure jump, Rayleigh droplet oscillation frequency, + and zero-gravity sessile-drop contact angles. Convergence under refinement demonstrated + and documented. +- **Track B - Akinci parity (calibrated sigma).** Side-by-side reproduction of the seven + Akinci paper experiments in the `compare_akinci/` workbench, using per-case calibrated + coefficients that are documented as such. CSS must match or beat the accepted Akinci rows + on the per-case metrics, with stricter reliability requirements (no adaptive-dt collapse, + stability under coefficient perturbation, two resolutions). + +**Reliability** is defined throughout as: no sustained adaptive-timestep collapse under the +efficiency criteria in section 1.E, zero wall penetration, density within documented bounds, +and metric stability under +-20% coefficient perturbation. The capillary timestep is an upper +stability bound, not a lower bound on every accepted adaptive step. + +## Scope decisions (agreed 2026-08-03) + +1. **Contact-angle mechanism: benchmark decides, including rejection of both.** Both candidate + mechanisms (geometric normal rotation, Breinlinger et al. 2013; contact-line force, + Huber et al. 2016) remain behind an explicit selector. Phase 2 accepted their static + target-preservation results, but Phase 3 rejected both as the recommendation because neither + passed all off-target restoring-response cases. +2. **Two-track acceptance** as described above. Matching the paper's near-spherical resting + drops at physical sigma is out of reach for physical reasons (Bond number ~5 for the 1 mL + drop; capillary length 2.7 mm) and is not a gate. +3. **In scope besides CSS:** the smooth-cutoff reliability fix is shared with + `SurfaceTensionMorris` (CSF); a surface-tension entry in `validation/`. +4. **Out of scope:** upstream PR splitting, full Morris-CSF validation campaign, GPU/backend + support for the new caches, Riemann/Godunov WCSPH solvers, repulsive boundary models. + +--- + +## Baseline: verified state of the working tree (2026-08-03) + +### What already works + +- CSS core (`SurfaceTensionMomentumMorris`, `src/schemes/fluid/surface_tension.jl:199`): + retains the unnormalized color-gradient magnitude as surface delta (`delta_s`, x2 + one-sided factor in `store_surface_delta!`), symmetric scalar reproducing correction + accumulated during the normal pass (`divergence_correction`), stress projection evaluated + on demand in the pair force (no stored tensor, no extra neighbor pass), exact pairwise + momentum conservation. +- Static Laplace balance validated (`compare_akinci/css_validation.jl`, unit test + `test/schemes/fluid/surface_tension.jl` "CSS static Laplace balance"): pressure-fit sigma + within 4.5% across 389-6031 particles, virial sigma converging toward the coefficient, + total capillary force at roundoff. +- Capillary time-step bound `dt <= sqrt(rho h^3 / (2 pi sigma))` wired into + `calculate_dt` (`src/schemes/fluid/fluid.jl:233-244`) for Morris, CSS, and + `SurfaceTensionAkinciCohesionPhysical`; unit-tested. +- CLF plumbing exists for both solvers: `src/schemes/fluid/weakly_compressible_sph/rhs.jl:44` + and `src/schemes/fluid/entropically_damped_sph/rhs.jl:28`. +- Workbench: `compare_akinci/css_sessile_drop.jl` (zero-g spherical-cap benchmark with + damping and CLF diagnostics), `compare_akinci/css_validation.jl` (static balance), + `compare_akinci/surface_tension_calibration.jl` (radius-series and instantaneous probes), + seven Akinci example scripts in `examples/fluid/akinci_*.jl`. + +### Baseline blockers and current disposition + +- **B1 - RESOLVED IN PHASE 0: two half-finished contact-angle mechanisms competed for one + keyword.** + - Baseline: source implemented only CLF behavior while tests and prose expected geometric + rotation; the geometric assertion failed. + - Resolution: `GeometricContactAngle` and `ContactLineForce` now have separate dispatch and + cache requirements, the geometric assertion passes, and all prose uses the explicit API. +- **B2 - RESOLVED IN PHASE 0: CLF internals had zero unit tests.** Wall-normal orientation, + `delta'`, unclamped `delta_CL` sign, force direction, zero cases, and WCSPH/EDAC wiring are + now covered by focused and integration tests. +- **B3 - RESOLVED IN PHASE 2: no accepted contact-angle results.** The complete 30-cell + sessile-drop matrix now reports two angle estimators, settlement, density, penetration, + timestep, and cost data; every cell passes the primary local-fit gate. +- **B4 - RESOLVED IN PHASE 1: adaptive-dt collapse and incorrect Morris force assembly.** + Morris now applies `-sigma kappa delta_s n/rho` once per particle; Morris/CSS normals use C1 + gradient/support activity. The formerly blocked Morris radius series completes in 137 s. +- **B5 - RESOLVED IN PHASE 2: `validation/` had no surface-tension case.** Physical-sigma + Young-Laplace, Rayleigh stiffness, sessile-drop, reference-data, plot, and CI drivers now + live under `validation/surface_tension_2d/` and `validation/surface_tension_3d/`. + +### Known physics limits (documented, not gates) + +- WCSPH dummy-particle walls cannot statically support a compact resting drop: NNLS-solved + pressure operators leave a mean vertical residual near -9.6 m/s^2 regardless of Adami + offsets, mirroring, mDBC reflection, semi-analytic wall integrals, or 8x resolution + (`compare_akinci/README.md`, Figure 8 investigation). +- Dynamically relaxed coarse CSS drops overpredict the Laplace pressure: inferred sigma + 1.50 / 1.36 / 1.16 N/m at 389 / 739 / 1503 particles for input 1 N/m - a first-order + resolution error, converging under refinement. +- Free Rayleigh mode-2 trajectories develop tensile instability after roughly 1-2 periods. + Phase 2 therefore validates their deterministic linear stiffness and retains the trajectory + driver only as a diagnostic. +- Video-matching wetting shapes require sigma ~5 N/m (~69x water); physical sigma produces + gravity-flattened drops at the paper's scale. Track B therefore uses calibrated sigma. + +--- + +## Phase 0 - Stabilize the working tree + +**Objective:** one consistent, fully tested code state with both contact-angle mechanisms +selectable. No behavior change for models without a contact angle. +**Estimate:** 2-3 working days. **Status:** complete (2026-08-03). + +### Tasks + +- [x] **0.1 Explicit contact-model selector.** Replace the `contact_angle::Real` overload of + `ColorfieldSurfaceNormal` with explicit model types, e.g. + `ColorfieldSurfaceNormal(contact_model=GeometricContactAngle(60.0))` and + `ColorfieldSurfaceNormal(contact_model=ContactLineForce(60.0))` (final names: D1). + Dispatch points to split: + - `create_cache_surface_normal` (`boundary_normal` needed by both; `contact_line_delta`, + `contact_line_delta_prime` only for CLF) + - `apply_contact_angle!` (geometric: rotate interface normals; CLF: normalize wall normal + only) + - `contact_line_acceleration` (CLF only; returns zero otherwise) + - `store_contact_line_delta_prime!` / `compute_contact_line_delta!` (CLF only) + - The ambiguous, unshipped `contact_angle=theta` keyword was removed rather than retained + as compatibility code (D1). +- [x] **0.2 Restore the geometric rotation implementation** with the exact semantics the + existing test documents: for wall-contact particles with valid interface normal, + `n_new = |n| * (sin(theta) t_hat + cos(theta) w_hat)` where `w_hat` is the unit wall + normal (pointing into the wall) and `t_hat` the in-wall-plane unit projection of the + interface normal. Magnitude preserved so `delta_s` is unaffected. Skip particles with + no wall contact or vanishing tangent. +- [x] **0.3 Unit tests for CLF internals** (new testsets in + `test/schemes/fluid/surface_normal_sph.jl`): + - wall-normal orientation: accumulated `boundary_normal` points into the wall for a fluid + particle above a plate + - `delta' = |g| sin(theta_dyn)` on a constructed two-particle configuration + - `delta_CL` sign on an analytic half-plane configuration; the `max(., 0)` clamp must not + be what makes the test pass (assert the unclamped value is already positive) + - CLF direction: for `theta_dyn > theta_target` the acceleration points out of the liquid + along the wall (spreading); reversed for `theta_dyn < theta_target` + - zero contributions for: no wall contact, zero tangent, `delta_CL = 0` + - wiring parity: identical CLF acceleration through WCSPH and EDAC `interact!` +- [x] **0.4 Flat-pool guard test.** Tank with flat hydrostatic-free surface (zero g or + damped): capillary acceleration must vanish (below tolerance) for interior particles + AND for wall-adjacent bulk particles where `calc_boundary_normal!` completes the + stencil. Guards the `delta_s` x2 one-sided factor against double counting with + wall-completed quadrature. +- [x] **0.5 Geometric-path unit tests.** Port the failing `apply_contact_angle!` block to + the geometric model type; add: magnitude preservation, no-op without wall contact, + 180/0 degree edge cases. +- [x] **0.6 Consistency pass over prose.** `ColorfieldSurfaceNormal` docstring, NEWS.md + entry, `docs/src/systems/fluid.md`, and `compare_akinci/README.md` all state: two + candidate mechanisms, selection pending the Phase 3 benchmark. Remove the premature + "supports geometric contact angles" claim from NEWS.md. +- [x] **0.7 Unit test suite green**, including `test/schemes/fluid/surface_tension.jl` + and `test/schemes/fluid/surface_normal_sph.jl`. Full simulation/example validation is + intentionally deferred to the final validation campaign because it runs for over an hour. + +### Exit gate G0 + +- [x] Unit tests pass; both mechanisms are selectable via one documented API; the no-contact + path retains its original cache layout and behavior. The relevant 2D surface-tension + examples passed before the intentionally stopped long-running examples campaign. + +### Phase 0 evidence + +- Focused surface-tension tests: 227/227 assertions passed. +- Focused surface-normal tests: 393/393 assertions passed, including the new 8-assertion + flat-pool/contact-line integration test and the rigid/wall parity regression. +- Complete unit suite: passed through `Pkg.test` with `TRIXIPARTICLES_TEST=unit`. +- `compare_akinci/css_sessile_drop.jl` loads with the explicit `ContactLineForce` selector. +- JuliaFormatter 2.1.1 applied to all touched Julia files; `git diff --check` clean. +- The rigid-boundary `calc_boundary_normal!` call was updated to forward the boundary state + required by the current boundary-density-aware signature; this fixed the pre-existing + rigid/wall parity test failure exposed by the complete surface-normal test file. + +--- + +## Phase 1 - Reliability: smooth force cutoffs (shared CSS + Morris CSF) + +**Objective:** remove discrete on/off changes from capillary forces and correct the +neighbor-count-dependent Morris CSF force before evaluating its reliability. Fixes B4 for +`SurfaceTensionMomentumMorris` and `SurfaceTensionMorris` while preserving exact pairwise +momentum conservation of CSS. +**Estimate:** 4-6 working days. **Status:** complete (2026-08-03). + +### 1.A Source audit and failure mechanism + +Three independent discontinuities currently occur in `remove_invalid_normals!` and +`calc_curvature!`: + +1. The raw color gradient is either normalized or set to zero at + `h_c * norm(g_a) = interface_threshold`. +2. A particle is either retained or removed when its integer neighbor count crosses + `ideal_density_threshold * ideal_neighbor_count`. A ramp applied to the integer count + would still jump when a neighbor enters the support and is therefore not a sufficient fix. +3. Morris curvature includes a pair only when both unit normals are nonzero. A normal crossing + either hard threshold changes the curvature stencil in one RHS evaluation. + +There is also a separate Morris operator defect that must be corrected before interpreting the +timestep failure. `surface_tension_force!(::SurfaceTensionMorris, ...)` is called inside the +fluid-neighbor loop even though its result does not depend on the current neighbor. For a particle +with neighbor set ``\mathcal N_a``, the current call structure produces + +```math +\bm a_a^\mathrm{current} += -\frac{\sigma\kappa_a\hat{\bm n}_a}{\rho_a} + \sum_{b\in\mathcal N_a} c_{ab}, +``` + +where ``c_{ab}`` is the dimensionless free-surface correction (one when no correction is +selected). The force therefore scales with neighbor count and jumps by an entire local-force +contribution whenever the neighbor list changes. Since ``\hat{\bm n}`` is a unit vector, this +expression also lacks a surface delta and has units of ``m^2/s^2`` instead of acceleration. + +The corrected Morris CSF acceleration is a particle-local source evaluated once per RHS: + +```math +\bm a_a^\mathrm{CSF} += -\frac{\sigma}{\rho_a}\,\kappa_a\,\delta_{s,a}\,\hat{\bm n}_a. +``` + +Here ``[\sigma]=kg/s^2``, ``[\kappa]=1/m``, ``[\delta_s]=1/m``, and +``[\rho]=kg/m^3``, so ``[\bm a]=m/s^2``. The Akinci pairwise free-surface correction is not +applied to this local continuum force; it remains available to the Akinci force and viscosity as +documented. CSS already deliberately ignores that correction. + +### 1.B Continuous interface indicators + +#### Raw color gradient + +For particle ``a``, retain the existing color-gradient operator + +```math +\bm g_a = \sum_b V_b\,\nabla_a W_{ab}, +\qquad V_b=\frac{m_b}{\rho_b}, +``` + +including the existing dummy-boundary quadrature completion. Its magnitude has units ``1/m``. +Define the dimensionless magnitude + +```math +\gamma_a = h_c\lVert\bm g_a\rVert, +``` + +where ``h_c`` is the compact-support radius. This is exactly the quantity implicitly compared +with `interface_threshold` by the current condition +`norm(g_a) > interface_threshold / h_c`. + +#### Continuous support moment + +Replace the integer neighbor-count interior test for Morris/CSS only with the continuous first +kernel moment + +```math +q_a = -\frac{1}{d}\sum_b V_b\, + \bm r_{ab}\mathbin{\cdot}\nabla_a W_{ab}. +``` + +The continuum interior value is one. For a normalized compact kernel, + +```math +0 = \int \nabla\mathbin{\cdot}(\bm r W)\,dV + = d\int W\,dV + \int \bm r\mathbin{\cdot}\nabla W\,dV, +``` + +which gives ``q=1`` when the support is complete and ``q<1`` when it is truncated by a free +surface. Dummy boundary particles contribute to ``q_a`` even when they carry no capillary stress, +so a wall-adjacent bulk stencil remains near one. This moment is already accumulated as +`divergence_correction` for CSS; extend the same scalar accumulation to Morris without another +neighbor traversal. + +Unlike neighbor count, ``q_a`` changes continuously as a particle crosses the support boundary: +for the supported kernels, ``W`` and the relevant derivatives vanish at the compact-support +radius. Keep `neighbor_count` for diagnostics and the existing non-Morris/Akinci normal filter; +do not alter the Akinci path in Phase 1. + +This changes the documented interpretation of `ideal_density_threshold` from a fraction of an +ideal integer neighbor count to a fraction of complete kernel support. Resolve D7 before coding: +either document this as a corrected meaning of the existing keyword, with migration evidence, or +introduce a separately named support-moment threshold and deprecate the old Morris/CSS behavior. +Do not silently change a public keyword's meaning. + +#### C1 transition function + +Use the cubic smoothstep + +```math +S(x) = +\begin{cases} +0, & x\le 0,\\ +3x^2-2x^3, & 0 0`, define a reversed support transition of width +``Delta_q``: + +```math +\lambda_{q,a} = +\begin{cases} +1, & q_a\le\tau,\\ +1-S\!\left(\dfrac{q_a-\tau}{\Delta_q}\right), + & \tau0``. A neighbor entering or leaving the interface +band now contributes continuously through ``\lambda_b``. If the dimensionless denominator is not +larger than ``\sqrt{\epsilon(T)}``, set curvature to zero rather than divide by an underresolved +stencil; do not hide the singularity by adding machine epsilon to the denominator. Use +``\delta_{s,a}^{eff}=2 norm(g_a) lambda_a`` in the local CSF acceleration, which provides the +outer activity factor and the missing physical dimension. Reset the curvature numerator and +denominator once in `compute_curvature!`, not once per fluid-neighbor system, so multiple fluid +systems accumulate consistently. + +Add the resulting local acceleration once when `particle_system === neighbor_system` in both the +WCSPH and EDAC RHS paths, analogous to `contact_line_acceleration`. The Morris specialization of +the pairwise `surface_tension_force!` becomes a no-op. Tests must prove that adding fluid neighbors +without changing the precomputed ``\kappa``, ``\delta_s``, and ``\hat n`` does not multiply the +force. + +### 1.E Timestep diagnostics + +The capillary condition + +```math +\Delta t_\sigma += \sqrt{\frac{\rho h^3}{2\pi\sigma}} +``` + +is an upper stability bound, not a lower bound on the step an adaptive error controller may choose. +Therefore, do **not** use `minimum(dt) >= 0.5 dt_sigma` as a correctness assertion. The final +clipped step, startup transients, or another physical timescale can legitimately violate it. + +For diagnostics define + +```math +\Delta t_\mathrm{ref}(t) += \min(\Delta t_\nu,\Delta t_a,\Delta t_c, + \Delta t_\sigma,\Delta t_\mathrm{max}), +\qquad +\eta_n=\frac{\Delta t_n^\mathrm{accepted}} + {\Delta t_\mathrm{ref}(t_n)}. +``` + +Exclude the first five accepted steps and the final clipped step from efficiency statistics. +Record accepted/rejected counts, the 1st/50th percentiles of ``\eta``, and the ratio between median +``\eta`` in the final and first 20% of the run. A collapse is a sustained loss of efficiency, not +one small step. + +Final Phase 1 regression thresholds: + +- The production three-radius series reaches final time within five minutes per model and fewer + than 2,000 accepted steps per drop; rejected-step fraction is at most 25%. +- The recorded middle-resolution reliability case has 1st-percentile ``\eta\ge0.05`` and + final/initial median-efficiency ratio at least 0.5. Full-radius runs with a mandatory CFL + callback are diagnostics, not the five-minute production gate, because they deliberately cap + every step at the acoustic limit. +- All normals, activities, deltas, curvature values, accelerations, densities, and pressures are + finite. + +### 1.F Implementation tasks + +- [x] **1.0 Record a pre-change baseline.** The default-radius Morris case timed out at 120 s + without a result; CSS completed in 52.96 s with inferred sigma `1.09212 N/m` and RMS speed + `1.91e-2 m/s`. Existing workbench evidence records the Morris three-radius timeout at five + minutes. Post-change diagnostics add accepted/rejected steps, timestep quantiles, + activity/support ranges, runtime, and pressure/speed data. +- [x] **1.1 Correct the Morris CSF operator.** Retain the physical one-phase `delta_s`, move + the local force outside the neighbor loop in WCSPH and EDAC, apply it once per particle, + and stop applying the Akinci pair correction to Morris. Add dimensional and + neighbor-count-independence tests before adding a taper. +- [x] **1.2 Add model-specific activity caches.** CSS: `interface_activity` in addition to + existing `delta_s` and `divergence_correction`. Morris: `delta_s`, + `interface_activity`, and continuous support moment in addition to `curvature`. Reuse + existing normal and boundary traversals; no new neighbor pass. +- [x] **1.3 Implement and unit-test the C1 helpers.** Test endpoint values and zero endpoint + derivatives, monotonicity, bounds, disabled interior filtering, Float32 behavior, and + invalid transition widths. The selected values are exposed as documented normal-method + keywords to make validation reproducible. +- [x] **1.4 Replace Morris/CSS hard masks with combined activity.** Store a unit normal only + above the lower magnitude bound; store `delta_s_eff` and CLF `delta_prime` with + `lambda`. Preserve the generic/Akinci minimum-neighbor path unchanged. +- [x] **1.5 Make Morris curvature activity-weighted.** Weight neighbor numerator and + denominator contributions by `lambda_b`, reset once per curvature update, and verify + smooth limiting behavior as either particle's activity approaches zero. Set curvature + to zero for a denominator at or below `sqrt(eps(ELTYPE))` and test this guard explicitly. +- [x] **1.6 Prove CSS invariants after tapering.** Unit-test exact pairwise linear momentum + for unequal activities/masses/densities, unchanged force when both activities are one, + finite behavior when `q_a + q_b` is small, zero planar stress divergence, and no torque + regression in the static sphere probe. +- [x] **1.7 Add a short adaptive regression.** `compare_akinci/phase1_reliability.jl` runs a + deterministic zero-g drop for both models with `RDPK3SpFSAL35` and records accepted/rejected + steps, timestep efficiency, activity/support ranges, runtime, pressure, and speed. Reduced + dimensional/wiring tests remain in the unit suite. +- [x] **1.8 Run taper sensitivity.** Evaluate `alpha in {0.5, 0.8, 0.9}` and + `Delta_q in {0.025, 0.05, 0.10}` at the middle radius. Select the narrowest transition + that passes timestep criteria without degrading pressure fit or increasing residual + motion. Record the decision as D6; avoid tuning separately per model unless required by + evidence. +- [x] **1.9 Re-run the three-radius and static gates:** + + ```bash + timeout 300 julia +release --project=compare_akinci/simulation \ + compare_akinci/surface_tension_calibration.jl laplace_series morris 1.0 0.02 + timeout 300 julia +release --project=compare_akinci/simulation \ + compare_akinci/surface_tension_calibration.jl laplace_series momentum_morris 1.0 0.02 + julia +release --project=compare_akinci/simulation \ + compare_akinci/css_validation.jl 375 750 1500 3000 6000 + ``` + +- [x] **1.10 Document changed Morris calibration.** Moving the local force and restoring + `delta_s` intentionally changes the numerical meaning of the old Morris coefficient. + Update NEWS, the model docstring, `ColorfieldSurfaceNormal` threshold documentation, + `docs/src/systems/fluid.md`, and the calibration table; do not preserve the dimensionally + incorrect coefficient by an empirical multiplier. Record the D7 API/migration decision. +- [x] **1.11 Run JuliaFormatter and the complete unit suite.** Full examples remain deferred + to final validation, consistent with the project test-time decision. + +### Phase 1 results ledger + +![Phase 1 static CSS preservation and dynamic three-radius diagnostics](phase1_surface_tension_diagnostic.png) + +| Model/configuration | Runtime | Accepted/rejected | eta p01 / median | Tail/head eta | sigma fit | RMS speed | Verdict | +|---|---:|---:|---:|---:|---:|---:|---| +| Morris Phase 0, default radius | `>120 s` | did not finish | - | - | no result | no result | fail baseline | +| Morris selected taper, sensitivity case (`n=389`, `t=0.005`) | `17.37 s` including first compilation | `415/1` | `0.629/1.000` | `1.276` | `1.12884` | `7.26e-3` | pass | +| Morris selected taper, three radii | `137.39 s` | `689-729 / 95-128` | production run not recorded | production run not recorded | slope `1.27036` | `0.94e-3-1.64e-3` | pass G1; accuracy remains G2 | +| CSS Phase 0, default radius | `52.96 s` | not recorded | - | - | single-drop `1.09212` | `1.91e-2` | baseline | +| CSS selected taper, sensitivity case (`n=389`, `t=0.005`) | `16.28 s` including first compilation | `412/2` | `0.658/1.000` | `1.191` | `1.23057` | `9.72e-3` | pass | +| CSS selected taper, three radii | `118.84 s` | `660-700 / 95-125` | production run not recorded | production run not recorded | slope `1.49708` | `1.23e-2-3.03e-2` | pass G1; known dynamic bias remains G2 | + +### Exit gate G1 + +- [x] Corrected Morris force has physical acceleration units, is applied once per particle, + and is independent of neighbor count for fixed local fields. +- [x] CSS still conserves total linear momentum to roundoff and passes the flat-pool guard. +- [x] Both models complete all three production radius cases within the five-minute/model + budget; selected recorded reliability cases satisfy the section 1.E efficiency thresholds. +- [x] Static CSS pressure-fit sigma remains within 5% across 389-6031 particles and differs + from the Phase 0 values by no more than 2 percentage points at any resolution. +- [x] Taper sensitivity and selected `(alpha, Delta_q)` are recorded in D6 with raw diagnostic + output; no hidden per-case tuning. +- [x] Interior-filter API semantics and migration evidence are recorded in D7; no silent public + behavior change. +- [x] JuliaFormatter and the complete unit suite pass; no Akinci-path regression. + +--- + +## Phase 2 - Track A: quantitative validation suite + +**Objective:** objective, repeatable physics gates at physical sigma, following the +`validation/dam_break_2d` pattern. Fixes B3 and B5. Produces the data for the Phase 3 +mechanism decision. +**Estimate:** 5-7 working days. **Status:** complete (2026-08-03). + +### Structure + +`validation/surface_tension_2d/` (V1 2D and V2) and `validation/surface_tension_3d/` +(V1 3D and V3), each with a runnable script, reference values, plot script, and a coarse +CI-budget variant. + +### Tasks + +- [x] **2.1 V1 Young-Laplace.** 2D disc (`dp = sigma/R`) and 3D sphere (`dp = 2 sigma/R`). + Controlled 3D resolutions `R/dx in {4, 6, 8, 10}` (targets + {268, 905, 2145, 4189}) and matched 2D spacings. This avoids lattice-count aliasing + between nominal particle targets. + Port the operator-fit method from `compare_akinci/css_validation.jl` (uniform-pressure + basis on identical particles). Report fitted sigma, virial sigma, total capillary + force, observed convergence order; plot error vs resolution. + *Acceptance:* fitted sigma error <= 5% at mid resolution; `|sum F|` at roundoff; + observed order >= 1. Document the dynamic relaxed-drop overprediction separately + (baseline 1.50/1.36/1.16 N/m at 389/739/1503) with its convergence trend. +- [x] **2.2 V2 Rayleigh linear response (2D, mode 2).** Evaluate the quadrupole stiffness + of a volume-preserving ellipse at zero g. Reference: Rayleigh-Lamb cylinder frequency + `omega_n^2 = (n^3 - n) sigma / (rho R^3)`, n = 2. Three resolutions. + *Acceptance:* frequency error <= 5% at mid resolution; error decreases under + refinement. The attempted free trajectory and peak/spectral fit develop the known CSS + tensile instability after roughly 1-2 periods, so the repeatable primary gate is the + equivalent linear mode stiffness + `omega^2 = -Qddot/Q`, with `Q = `. The peak/spectral trajectory driver is + retained as a documented secondary diagnostic rather than reported as a passing run. +- [x] **2.3 V3 zero-g sessile drop (contact-angle matrix).** Promote + `compare_akinci/css_sessile_drop.jl` into the validation suite. Full matrix: + `theta_target in {30, 60, 90, 120, 150}` x `mechanism in {geometric, CLF}` x + `particles in {750, 1500, 3000}`. Run to a settled state (RMS speed < 5e-3 m/s with + the damping stage removed at the end, or documented damping protocol). Measure the + apparent angle two ways and report both: spherical-cap volume fit (existing + `apparent_spherical_cap_angle`) and a local circle fit to the interface within + `2 h_c` of the contact line. Log density bounds, penetration count, wall-contact + particle count, `delta_CL` statistics, runtime, min dt. Emit a CSV + panel plot. +- [x] **2.4 Sensitivity axes for V3** (mid resolution, 90 degrees only): + `boundary_contact_threshold in {0.0, 0.1}` and damping coefficient x{0.5, 2}. +- [x] **2.5 CI hooks.** Coarse, time-capped versions of V1 and V2 wired into the test suite + (tolerances relaxed accordingly); V3 documented as a manual/cluster job. + +### Exit gate G2 + +- [x] V1 and V2 pass their acceptance criteria (evidence in the results ledger). +- [x] V3 matrix complete with per-cell measurements recorded - pass/fail per cell against + the +-5 degree target, no penetration, density in bounds. + +--- + +## Phase 3 - Mechanism decision gate + +**Objective:** pick ONE documented default contact-angle mechanism from V3 evidence; end the +dual-mechanism state. Resolves D3 and D5 permanently. +**Recovery estimate:** 4-6 working days. **Status:** R0-R7 static recovery is complete; corrected +wetted-area energy is the only candidate admitted to R4 dynamics. + +### 3.A Meaning of "default" and non-goals + +The constructor default remains `ColorfieldSurfaceNormal(contact_model=nothing)`. There is no +physically meaningful contact angle when the user has not supplied a target, so Phase 3 must not +silently add wetting, reintroduce the removed `contact_angle=` keyword, or make either mechanism +implicit. "Default contact-angle mechanism" means the single mechanism recommended in the API +documentation, examples, and workbench whenever a user explicitly requests a static target angle: + +```julia +ColorfieldSurfaceNormal(contact_model=WINNER(theta)) +``` + +Phase 3 chooses the static one-phase CSS recommendation. It does not claim validation for +hysteresis, advancing/receding angles, multiple wall materials, resolved two-phase contact lines, +or undamped dynamic wetting. The full production wetting example remains Phase 5 task 5.1; Phase 3 +only updates the existing workbench default and documentation snippets. + +### 3.B Frozen evidence and preliminary scorecard + +Treat the Phase 2 files as immutable inputs. Do not overwrite them while making the decision: + +- `validation/surface_tension_3d/sessile_drop_matrix.csv`: 30 static cells at + `boundary_contact_threshold=0.0`. +- `validation/surface_tension_3d/sessile_drop_sensitivity.csv`: eight 90-degree cells spanning + threshold `{0.0, 0.1}` and damping `{2000, 8000} s^-1`. +- `validation/surface_tension_3d/plot_surface_tension_3d.jl`: reproducible comparison panel. +- D2: the recommended boundary threshold is the public default `0.1`. +- `validation/surface_tension_3d/contact_angle_*.csv`: Phase 3 scorecard, threshold replay, + perturbation/control, timestep, and repeated-cost evidence. + +The scorer must reproduce this preliminary table directly from the CSV files before any new runs: + +| Metric | Geometric | Contact-line force | +|---|---:|---:| +| Static cells passing hard Phase 2 gates | 15/15 | 15/15 | +| Local-angle MAE, 750 particles | 1.246 deg | 1.327 deg | +| Local-angle MAE, 1500 particles | 1.229 deg | 0.948 deg | +| Local-angle MAE, 3000 particles | 0.748 deg | 0.838 deg | +| Maximum mid-resolution error | 2.307 deg | 1.899 deg | +| Maximum error over all resolutions | 2.862 deg | 3.175 deg | +| 90-degree sensitivity span | 0.154 deg | 0.135 deg | +| Worst RMS speed | `3.313e-3 m/s` | `3.261e-3 m/s` | +| Lowest density | `0.9852 rho_0` | `0.9875 rho_0` | +| Repeated runtime overhead over no contact | 2.4% | 17.3% | +| Contact-specific cache | wall normal | wall normal + two scalar arrays | + +Both mechanisms are eligible from the static matrix. CLF has a 0.28-degree mid-resolution MAE +advantage; geometric is slightly more accurate at the finest resolution, is cheaper, and has less +state. Both have one target (90 degrees) whose endpoint error is larger at 3000 than at 750 +particles, so per-angle monotone convergence must be reported as imperfect for both. These small, +mixed differences are not enough to select a mechanism without the restoring-response control in +section 3.D. + +### 3.C Eligibility gates + +Apply hard eligibility before ranking. A hard failure cannot be averaged away by another metric. + +1. **Data integrity.** The static CSV must contain exactly + `5 targets x 2 mechanisms x 3 resolutions`; the sensitivity CSV must contain exactly + `2 thresholds x 2 damping values x 2 mechanisms`. Required fields must be finite except the + intentionally absent geometric `line_angle` and line-delta values. +2. **Static accuracy.** Every local-circle result must be within 5 degrees of its target. Any row + above 10 degrees is an immediate rejection; a 5-10 degree row blocks selection pending a + documented rerun. At each resolution, measured angles must increase strictly with target angle. +3. **Resolution robustness.** For each mechanism, aggregate MAE and maximum error at 3000 particles + must not exceed their 750-particle values, and every per-target endpoint regression must be + listed. The aggregate criterion is the gate; per-target violations are ranking evidence rather + than silently discarded lattice noise. +4. **Stability.** Every run must have zero penetration, density in `[0.98, 1.02] rho_0`, fewer + than 2,000 accepted steps, rejected-step fraction at most 25%, and finite diagnostics. Static + matrix and threshold-replay rows must also have RMS speed below `5e-3 m/s`; off-target response + rows are intentionally moving and are exempt from that settlement condition. Representative + timestep runs must satisfy section 1.E: `eta_p01 >= 0.05` and final/initial median `eta >= 0.5`. +5. **Sensitivity.** All eight sensitivity cells must pass the static gates. For each mechanism, + the local-angle span over threshold and damping variations must be at most 1 degree. +6. **Restoring response.** Every off-target case in section 3.D must move toward the requested + angle and have a correctly directed contact-induced shape acceleration relative to the + no-contact control. A mechanism that only preserves a cap initialized at the target is not + eligible as the recommended contact-angle model. + +**Recorded outcome (2026-08-03):** both mechanisms pass gates 1-5. Geometric passes the complete +restoring gate in 1/4 cases (correct contact-induced acceleration in 2/4); CLF passes 2/4 (correct +acceleration in 3/4). Their mean error-reduction ratios are `-0.00119` and `0.00313`. Uniformly +longer low-resolution checks preserve the failing direction, so this is not only a short-window +artifact. Both candidates are ineligible and ranking stops before promotion. + +### 3.D Additional decision experiments + +Phase 2 deliberately used analytic caps initialized at their target angle and a strong uniform +damping protocol. That is a valid equilibrium-preservation gate but does not prove that a mechanism +restores an off-target contact line. Run the following small, fixed matrix before D3: + +| Axis | Values | +|---|---| +| Target/initial angle | `(60, 90)`, `(90, 60)`, `(90, 120)`, `(120, 90)` degrees | +| Mechanism | no contact model, geometric, CLF | +| Resolution | 1500 requested particles | +| Boundary threshold | `0.1` (D2 recommendation) | +| Damping/final time | `4000 s^-1`, `0.01 s` | +| Other parameters | identical to the Phase 2 V3 matrix | + +For each run, record the initial and final local-circle angle, signed angle error, cap-shape +acceleration, boundary contribution, density extrema, penetration, RMS speed, accepted/rejected +steps, minimum timestep, and runtime. Define + +```math +e_0 = \theta_\mathrm{local}(0)-\theta_\mathrm{target},\qquad +e_f = \theta_\mathrm{local}(t_f)-\theta_\mathrm{target},\qquad +R = 1-\frac{|e_f|}{|e_0|}. +``` + +Require `R > 0`, `R` greater than the matched no-contact value, motion in the target direction, and +a correctly signed contact-induced shape acceleration after subtracting the no-contact result. If +`0.01 s` is too short to resolve angle motion, extend all cases uniformly or use the initial +acceleration gate; never tune time or damping per mechanism or angle. + +Also run these controls: + +- **Recommended-threshold replay:** all five targets for both mechanisms at 1500 particles with + `boundary_contact_threshold=0.1`. This confirms that D2 generalizes beyond the existing + 90-degree sensitivity rows. +- **Timestep diagnostics:** 90 degrees/1500 particles and the worst recorded 30 degrees/3000 + particles for both mechanisms, collecting the section 1.E `eta` statistics rather than only + minimum timestep. +- **Cost control:** no-contact, geometric, and CLF at 90 degrees/1500 particles. Warm up each path, + then run three timed repeats in rotated order and report median and median absolute deviation. + Report accepted steps separately so solver work is not confused with per-step overhead. Report + contact-specific cache bytes analytically and with `Base.summarysize`. + +Implement these modes in one validation driver, +`validation/surface_tension_3d/contact_angle_decision.jl`, rather than creating separate scripts. +It writes, without modifying Phase 2 evidence: + +- `contact_angle_scorecard.csv` +- `contact_angle_threshold_replay.csv` +- `contact_angle_perturbation.csv` +- `contact_angle_timestep.csv` +- `contact_angle_cost.csv` +- `contact_angle_selected_matrix.csv` (written only after D3, never substituted for Phase 2 data) + +Extend the existing 3D plotting script with a decision panel for MAE versus resolution, +off-target error reduction, and normalized runtime. + +### 3.E Ranking and deterministic tie-break + +Rank only mechanisms that pass every eligibility gate. Use a lexicographic decision, not an opaque +weighted average: + +1. Mid-resolution local-angle MAE and maximum error. +2. Off-target restoring consistency, then mean error-reduction ratio `R`. +3. Finest-resolution MAE/maximum error and the count/magnitude of per-target endpoint regressions. +4. Stability margins: density deviation, RMS speed, rejection fraction, and timestep efficiency. +5. Sensitivity span across threshold and damping. +6. Median runtime overhead and contact-specific cache/code complexity. + +Use a 0.5-degree practical-equivalence band for angle MAE and maximum-error comparisons. A smaller +difference is a tie and moves the decision to the next criterion. If all physical criteria remain +tied, select the mechanism with lower measured runtime and less contact-specific state. Record every +raw metric and the first criterion that separates the candidates in D3. + +### 3.F D3 and D5 decision rules + +- [x] **3.1 Generate the scorecard.** Add assertions for all section 3.C gates; a malformed or + failing CSV must stop the script with a useful error rather than emit a partial ranking. +- [x] **3.2 Run the decision experiments.** Produce the threshold replay, off-target response, + timestep, no-contact, and repeated cost evidence from section 3.D with no per-case tuning. +- [x] **3.3 Evaluate D3.** Write the recommendation outcome, complete scorecard, equivalence calls, + and decisive criterion into this file and `compare_akinci/README.md`. If neither mechanism + passes restoring response, leave D3 open and reopen the relevant validation task; do not pick + one solely because it is cheaper. **Outcome:** no recommendation; D3 remains open because + geometric passes 1/4 and CLF 2/4 complete restoring cases. +- [x] **3.4 Decide D5.** Keep the losing mechanism only if it passes all hard gates and retains a + demonstrated distinct capability. Geometric rotation can qualify as the cheaper direct static + constraint; CLF can qualify as a dynamic restoring force that does not overwrite the measured + interface normal. Otherwise delete the loser completely. Since neither API has shipped, do not + add a deprecation or compatibility alias for deleted behavior. **Outcome:** the production + wetted-area replacement passes all gates; both rejected unshipped candidates are deleted. + +### 3.G Promotion or deletion work + +Executed after the corrected wetted-area model passed validation and production replay. Production +`contact_model=nothing` is unchanged; wetting requires explicit `WettedAreaContactAngle(theta)`. + +Regardless of the winner: + +- Keep `ColorfieldSurfaceNormal(contact_model=nothing)` unchanged and test that it creates no + contact-specific caches or forces. +- Keep the explicit `contact_model=WINNER(theta)` spelling; do not add `contact_angle=` or a new + convenience wrapper solely to encode the recommendation. +- Set the default `mechanism` in `compare_akinci/css_sessile_drop.jl` and all decision/Track B + workbench calls to the winner. Validation files with an explicit mechanism remain explicit. +- Update metadata tests so restart/VTK provenance continues to record the model type and angle. + +If both mechanisms are retained, mark the winner "recommended for validated static contact angles" +and give the loser a narrow opt-in capability statement. If the loser is deleted, remove all of its +implementation and dead branches in one pass: + +- type/export/conversion and docstrings in `src/TrixiParticles.jl` and + `src/schemes/fluid/surface_normal_sph.jl`; +- loser-specific machinery: contact-delta caches/assembly/force kernels for CLF, or normal-rotation + dispatch for geometric; +- WCSPH/EDAC source-term wiring if CLF is deleted; +- model references in metadata tests, unit tests, validation selectors, and documentation. + +Do not retain dormant arrays, no-op dispatch, or tests for a deleted unshipped model. + +### 3.H Tests and documentation audit + +- [x] **3.5 Focused unit tests.** Name a testset for the recommended mechanism and cover contact + orientation, 0/180-degree limits where applicable, flat-pool cancellation, density/activity + tapering, WCSPH and EDAC wiring, metadata, and zero behavior away from the wall. Retained + opt-in behavior keeps its own capability-specific tests. **Outcome:** production quadrature, + exact 90-degree cancellation, gradients, wall/rigid reactions, orientation, and both solvers + are covered. +- [x] **3.6 Validation regression.** Add a CI-cheap scorecard test that parses fixed reference data + and checks D3's decisive metrics. V3 integration remains a manual/cluster job. +- [x] **3.7 Documentation.** Replace candidate/pending language and cite the Phase 2/3 CSV evidence + in: + - `src/schemes/fluid/surface_normal_sph.jl` docstrings; + - `docs/src/systems/fluid.md` selection guidance and cache/cost discussion; + - `NEWS.md` and generated `docs/src/news.md`; + - `compare_akinci/README.md` (replace the obsolete transient-only conclusion); + - D3/D5, phase status, risk register, results ledger, and progress log in this file. +- [x] **3.8 Example/workbench audit.** Update `compare_akinci/css_sessile_drop.jl` and any explicit + CSS contact-angle calls to the recommendation. Do not partially convert the legacy Akinci + wall example here; that remains task 5.1. With no recommendation, the workbench requires an + explicit mechanism and the production example remains deferred. +- [x] **3.9 Grep audit.** Search `src/`, `docs/`, `test/`, `examples/`, `compare_akinci/`, and + `NEWS.md` for `contact_angle`, `contact_model`, `contact line`, `geometric`, `CLF`, + `candidate`, and `pending`. Every remaining hit must agree with D3/D5. + +### 3.I Verification and exit gate G3 + +- [x] The scorecard is reproducible from committed CSV inputs and all hard eligibility assertions + pass for the selected mechanism. +- [x] A selected-mechanism 15-cell replay at threshold `0.1` passes the angle, density, + penetration, settlement, and timestep gates without changing Phase 2 reference files. +- [x] D3 identifies one recommendation and its decisive criterion; D5 explicitly retains or removes + the loser with no dual-default wording. +- [x] `ColorfieldSurfaceNormal()` still means no contact-angle model; explicit winner construction + is documented and unit-tested for WCSPH and EDAC. +- [x] Source, metadata, docs, NEWS, README, workbench defaults, and tests tell one consistent story; + the grep audit has no unresolved candidate/pending prose. +- [x] JuliaFormatter, `git diff --check`, focused tests, the complete unit suite, and the docs build + pass. Relevant changed examples run within their existing CI budget. +- [x] Phase 4 is unblocked only after all items above are checked. If restoring response or the + selected-mechanism replay fails, G3 stays open and the failure is recorded rather than hidden. + +### 3.J Recovery plan + +**Objective:** make one physically motivated mechanism pass all four off-target restoring cases +without weakening the Phase 2 static, stability, or threshold gates. **Estimate:** 4-6 working days +for the CLF path; add 3-5 days only if a third formulation is required. The recovery is sequential: +diagnose CLF first, change one ingredient at a time, and do not spend another full-matrix run until a +fixed-particle sign gate passes. + +#### 3.J.1 Frozen failure signature + +Preserve the existing `contact_angle_*.csv` files as the baseline. The control-subtracted initial +cap-shape accelerations below are in `1e-3 m^2/s^2`; the expected sign is the direction from initial +to target angle. + +| Target <- initial | Expected | Geometric | CLF | Current interpretation | +|---|---:|---:|---:|---| +| 60 <- 90 | - | +1.592 (fail) | -2.429 (sign pass, motion fail) | CLF is too weak to overcome bulk drift at `0.01 s` | +| 90 <- 60 | + | -4.887 (fail) | +15.298 (pass) | geometric response is reversed | +| 90 <- 120 | - | -0.335 (sign pass, motion fail) | +0.439 (fail) | CLF reads approximately 89 degrees from a 118-degree cap | +| 120 <- 90 | + | +2.410 (pass) | +8.717 (pass) | both respond correctly | + +The leading CLF hypothesis follows directly from the current assembly order in +`surface_normal_sph.jl`: + +1. Fluid neighbors accumulate the free-surface color gradient. +2. Dummy-boundary neighbors add the same raw vector to `surface_normal` and `boundary_normal` to + complete the CSS quadrature stencil. +3. CLF later computes `dynamic_cosine = dot(boundary_normal, surface_normal)` from the wall-augmented + normal. + +Before normalization, this gives an exact diagnostic decomposition + +```math +\bm n_\mathrm{fluid}=\bm n_\mathrm{total}-\bm n_\mathrm{wall}. +``` + +The wall-completed total normal is appropriate for the conservative CSS divergence but is not +necessarily the physical free-surface direction required by Young's contact-line force. Geometric +rotation has a different problem: it imposes the requested local normal exactly, yet two of four +control-subtracted stress responses have the wrong sign. Treat that as a formulation issue, not an +angle-estimator issue. + +#### 3.J.2 Stage R0 - diagnostic infrastructure (0.5 day) + +- [x] Add `normal` and `force_sign` modes to the existing + `validation/surface_tension_3d/contact_angle_decision.jl`; do not create another runner. +- [x] Reconstruct pre-normalization `n_fluid`, `n_wall`, and `n_total` with existing internal normal + passes in the validation driver. Do not add a production cache or change a force in this stage. +- [x] Record per-particle vectors only through aggregate diagnostics: line-delta-weighted angle + mean/median, 10/90% quantiles, weighted wrong-sign fraction, active-line particle count, + `sum(V_a * delta_CL,a)`, and local-circle reference angle. +- [x] Write new files rather than overwriting the blocked baseline: + - `contact_angle_normal_components.csv` + - `contact_angle_force_sign.csv` +- [x] Add a `variant` column (`baseline_total`, `fluid_only`, later `corrected_clf`) to every recovery + output so plots and scorecards cannot mix formulations accidentally. + +#### 3.J.3 Stage R1 - select the CLF angle estimator (0.5-1 day) + +Use fixed analytic caps; no ODE solve is needed. Evaluate initial angles +`{30, 60, 90, 120, 150}` at `{750, 1500, 3000}` particles, plus the four off-target target/initial +pairs. Compare, without tuning: + +```math +\theta_\mathrm{total} +=\cos^{-1}(\hat{\bm n}_w\mathbin{\cdot}\hat{\bm n}_\mathrm{total}), +\qquad +\theta_\mathrm{fluid} +=\cos^{-1}(\hat{\bm n}_w\mathbin{\cdot}\hat{\bm n}_\mathrm{fluid}). +``` + +The estimator gate is: + +- [ ] Line-delta-weighted angle error <= 5 degrees for every target-initialized cap at the middle + resolution; maximum error must not increase from 750 to 3000 particles. +- [ ] For every off-target pair, at least 95% of line-delta weight gives the same restoring-force + sign as the local-circle reference. +- [ ] No missing/zero contact direction where the current line delta is active; all values finite. +- [ ] Flat-pool particles remain inactive and produce zero CLF force. + +If neither candidate normal passes, stop the CLF patch path and go to Stage R6. Do not introduce an +angle offset, fitted gain, target-dependent switch, or per-angle threshold. + +**Outcome:** rejected. At 1500 particles, `n_fluid` has up to 58.5-degree static mean error and 85% +wrong-sign line weight; `n_total` has up to 54.1-degree static error. Neither estimator passes R1, +so no CLF `contact_normal` cache or production force change was made. + +#### 3.J.4 Stage R2 - minimal CLF correction (1-2 days) + +Only after `n_fluid` passes R1: + +**Not executed:** the R1 prerequisite failed. + +- [ ] Add one CLF-only `contact_normal` vector cache. In `apply_contact_angle!`, capture and normalize + `surface_normal - boundary_normal` **before** normalizing `boundary_normal`. +- [ ] Use `contact_normal` only for CLF's `dynamic_cosine`, wall tangent, and force direction. + Preserve the existing wall-completed `surface_normal`, `delta_s`, activity, reproducing + correction, and CSS stress path exactly. +- [ ] Keep the current `contact_line_delta_prime` and `contact_line_delta` localization unchanged in + the first variant. This isolates angle correction from force localization. +- [ ] Add unit tests for cache isolation, invalid/zero normals, tangent-only force, target-matched + zero cosine error, 0/180-degree limits, Float32 conversion, WCSPH/EDAC wiring, metadata, and + absence of the cache for `nothing`/geometric models. +- [ ] Add the four real-cap fixed-particle regressions. The hard gate is 4/4 correct signs for + `(A_model - A_none)` before any time integration. + +No empirical multiplier is permitted. A larger force must come only from correcting the measured +cosine or a separately validated line-delta normalization. + +#### 3.J.5 Stage R3 - validate CLF localization only if needed (0.5-1 day) + +Retain the existing localization if R2 passes force-sign and dynamic gates. If the corrected angle +has the right sign but remains too weak, first test its continuum normalization: + +```math +L_h=\sum_a \frac{m_a}{\rho_a}\,\delta_{CL,a}, +\qquad L_\mathrm{analytic}=2\pi r_\mathrm{contact}. +``` + +- [ ] Require `|L_h/L_analytic - 1| <= 20%` at 1500 particles and decreasing endpoint error from + 750 to 3000 particles. +- [ ] If the current line delta fails, evaluate exactly one principled alternative: construct + `delta'_CL` from the tangential magnitude of the raw fluid-only color gradient. Store the + minimum additional raw magnitude needed; do not add a free coefficient. +- [ ] Recheck positivity, flat-pool cancellation, finite values, line-integral convergence, and 4/4 + fixed-particle signs before an ODE run. + +If angle-only correction and the single normalized-localization variant both fail 4/4 signs, reject +CLF for this campaign and proceed to R6. + +**Recorded localization result:** the existing line measure is 24-77% low across the five-angle, +three-resolution matrix. The coarea cross-gradient candidate remains 22-41% low after standard +one-sided factors and is not uniformly convergent. Dividing the coarea measure by the existing +support moment lowers the five middle-resolution errors to 3.4%, 3.2%, 9.9%, 21.0%, and 24.1%, but +the obtuse cases fail and worsen at the fine endpoint. No production localization change is accepted. + +#### 3.J.6 Stage R4 - dynamic and static recovery gates (1-2 days) + +Run gates in this order and stop at the first failure: + +1. `contact_angle_decision.jl perturbation corrected_clf`: the unchanged + `t=0.01 s`, `4000 s^-1`, threshold `0.1`, 1500-particle protocol must pass 4/4 complete response + cases against the no-contact control. +2. If all four acceleration signs pass but total angle motion is below estimator resolution, one + uniform final-time extension is allowed for all cases. No case-specific time/damping change is + allowed. +3. Rerun the five-angle threshold-0.1 replay. All cells must remain within 5 degrees, settled, in + density bounds, and penetration-free. +4. Rerun the two timestep cases and repeated no-contact cost control. Section 1.E gates remain + unchanged; record the extra cache/runtime cost. +5. Only then run the selected-mechanism 15-cell `{750,1500,3000}` replay and sensitivity matrix. + +Write corrected evidence to suffixed files such as `contact_angle_perturbation_corrected_clf.csv`; +never replace the baseline that demonstrated the failure. + +#### 3.J.7 Stage R5 - decision and cleanup after a CLF pass (0.5-1 day) + +If corrected CLF passes every R4 gate: + +- [ ] Close D3 with CLF as the recommended explicit static contact-angle mechanism while preserving + `ColorfieldSurfaceNormal(contact_model=nothing)` as the constructor default. +- [ ] Apply D5 literally: geometric currently fails the hard restoring gate, so delete its unshipped + type, cache/rotation dispatch, export, tests, validation selector, and prose unless it first + passes the same 4/4 gate. Do not retain it merely because it is cheaper. +- [ ] Regenerate the scorecard/decision plot, update README/docs/NEWS, run the grep audit, complete + unit suite, docs build, and then unblock Phase 4. + +#### 3.J.8 Conditional geometric branch + +Do not modify geometric in parallel with R1-R4. If CLF is rejected but its diagnostics confirm that +the test itself is sound, allow one geometric investigation: + +- compare tangent orientation from `n_total` versus `n_fluid`; +- compare rotation at all wall-contact particles versus only the validated contact-line band; +- derive the expected sign from the discrete CSS stress/energy before changing code. + +One principled variant may advance only if it passes 4/4 fixed-particle signs. Do not reverse a sign, +scale stress, or special-case acute/obtuse angles from observed outputs. If it fails, reject +geometric and proceed to R6. + +**Outcome:** rejected. The gradient-consistent ghost variant preserves the tangential gradient and +sets its wall component to `|q| cot(theta)`, but still passes only 2/4 fixed-particle signs, matching +the existing geometric mechanism. + +#### 3.J.9 Stage R6 - fallback if both current models fail + +Keep no contact model as the default and open a separate formulation task. Compare two derived +options on paper before implementation: + +1. boundary color/ghost continuation that imposes Young's boundary condition during normal + reconstruction rather than rotating an already assembled stress; or +2. a discrete wall free-energy/contact-line force with a robust geometric angle estimator and an + equal/opposite wall reaction. + +The design note must state units, discrete energy or momentum balance, line-delta normalization, +required caches, and 0/180-degree behavior. Akinci wall adhesion, fitted angle gains, and +target-dependent coefficients are not acceptable substitutes for a CSS contact model. + +**Current R6 result:** `compare_akinci/contact_angle_recovery.md` derives and compares both paths. +A target-only wall free-energy force gives 3/4 fixed signs with the current line measure and 4/4 +with the expected one-phase factor. The completed ten-kernel planar/oblique study derives the coarea +normalization from the implemented kernel-gradient integral and passes all 50 middle-resolution +cases, but only 40/50 strict endpoint gates. More decisively, the factor does not pass the spherical +cap line-length gate, and the production-style divergence form passes only 9/50 planar middle cases. +The completed R6-D/C/W continuation below also selects no model. No production force or default was +changed. + +#### 3.J.9a Stage R6 continuation - three-way measure comparison (decided 2026-08-03) + +The planar factor is derived and correct, so the open question was transfer to real caps. Three +validation-only candidates were compared with shared gates and one evidence table: + +1. **R6-D cap-transfer diagnostic (first, feeds the others).** Reproduce the cap failure in the + canonical planar study by adding production ingredients one at a time (wedge-restricted + interface gradient without wall-side continuation; colorfield-gated wall completion), and + attribute the cap deficit with analytic-substitution variants on the real caps (analytic wall + profile, analytic interface profile, both). Uniform-lattice volume weighting is identical for + `V_a` and `V_b` and is recorded as excluded by construction. +2. **R6-C compatible indicators.** Continue the fluid indicator into the wall with the + flooded-reference-normalized boundary colorfield and remove the hard wall gate; retry the derived + coarea factor on caps. +3. **R6-W wetted-area wall energy.** Express the Young term as the gradient of wetted solid-liquid + area measured through the boundary colorfield (area integral; no explicit line delta). Gates: + wetted-area error `|A_h/(pi r_c^2) - 1|` at most 20% at 1500 particles with decreasing endpoint + error, exactly zero force at 90 degrees, then 4/4 fixed-particle signs. + +Selection rule: a candidate must pass its measure gate on caps before any force-sign comparison +counts; the first candidate that passes measure + 4/4 signs proceeds to R4 dynamics. If several +pass, prefer the one with the fewest new caches and no angle-dependent factor. Discrete +derivations, units, cache requirements, 0/90/180-degree behavior, and evidence files are +recorded in `compare_akinci/contact_angle_recovery.md`; all three candidates stay in +`validation/surface_tension_3d/contact_angle_decision.jl` as validation-only modes until a gate +passes. + +**Outcome:** R6-D shows that wedge restriction and the production colorfield gate reduce the +ten-kernel planar middle gate from 50/50 to 20/50 and 16/50. R6-C passes 5/5 middle cap errors but +0/5 endpoint-decrease gates. R6-W passes 4/5 middle and endpoint area gates; its 150-degree area is +50.6% high, although its force is exactly zero at 90 degrees and total signs are 4/4. None is +eligible, no dynamics run, and G3 stays open. + +#### 3.J.9b Stage R7 - controlled cap quadrature and remaining formulations (pre-registered 2026-08-04) + +The R6 endpoint rule is not a valid discriminator by itself: even the `analytic_both` control, which +evaluates the exact continuum wall and cap profiles on the particle lattice, passes only one of five +strict endpoint-decrease checks. R7 changes the measurement protocol before evaluating another +candidate; it does not alter the 20% accuracy tolerance or any production force. + +The amended cap protocol is frozen as follows: + +1. Average every cap measure over eight rank-1 horizontal lattice phases, with each coordinate + sampling the centers of eight equal sub-cell bins. The wall-normal lattice phase remains fixed + so the fluid-wall gap is unchanged. An initial four-phase diagonal control was discarded before + any candidate run because square-lattice reflection symmetry made all four samples identical. +2. Retain the production resolution series `{750, 1500, 3000}` at `h/dx=1.4`. The middle error must + remain at most 20%. The fine endpoint must also be within 20% and may not exceed the coarse error + by more than two combined phase standard errors. This uncertainty-aware endpoint rule is applied + identically to every control and candidate. +3. Validate the protocol independently with exact continuum profiles at fixed physical + `h=1.4*cbrt(V/1500)` and `h/dx in {1.4, 2.8, 4.2}`. Refinement approaches the nonzero + curvature-smoothing bias of the exact kernel profiles, so require the fine-to-middle error change + not to exceed the middle-to-coarse change by more than two combined phase standard errors and + require the fine error to remain within 20%. This + Cauchy criterion replaced the incorrect zero-error expectation after the control-only run and + before any candidate run. Candidate results count only if the exact-profile control passes all + five production-series and fixed-`h` checks. +4. Preserve all R6 CSVs. R7 writes new protocol, candidate, force-sign, and comparison files so the + reason for changing the endpoint test remains auditable. + +The remaining candidates are also frozen before their runs: + +- **R7-W:** replace `maximum(colorfield)` by the kernel-derived flooded half-space convolution at + the exposed ghost-layer depth. Derive the leading wetted-edge displacement from the canonical + wedge convolution for each target angle and differentiate the corrected area exactly; no measured + cap radius or fitted gain enters the correction. +- **R7-CG:** pair R6-C's compatible fluid-indicator continuation with the plate's exact geometry + normal and canonical wall-gradient magnitude. This is the missing cell in the R6-D/C attribution + matrix. If its measure passes, evaluate the target-only coarea wall energy with the same derived + normalization. +- **R7-Y:** impose Young's condition on boundary color values before assembling the fluid gradient. + The exposed-layer wetness and its centered tangential derivative define the ghost continuation + `phi_g=clamp(phi_s+d |grad_t phi_s| cot(theta), 0, 1)`. The saturated limits at 0 and 180 degrees + are finite. This differs from the rejected ghost-geometric variant, which rotated an already + assembled gradient. Evaluate its line measure, reconstructed angle, and four fixed-cap signs even + if R7-W or R7-CG passes. + +No candidate enters an ODE recovery run or production source until the amended measure gate and its +formulation-specific fixed-particle checks pass. + +**Outcome (2026-08-04):** the exact-profile protocol control passes `5/5` production middle and +endpoint checks and `5/5` fixed-`h` accuracy/Cauchy checks. The corrected R7-W area passes `5/5` +middle and endpoint gates; its maximum middle error is 5.68%, and the former 150-degree failure is +1.99% at 1500 particles. Its differentiated force retains `4/4` total fixed-cap signs and is exactly +zero in both 90-degree-target cases. R7-W is therefore the sole candidate admitted to R4 dynamics. + +R7-CG passes `5/5` middle line measures but only `2/5` endpoint checks; its reconstructed angles pass +none of the five middle checks. R7-Y passes all five line-measure checks at both gates, but only `2/5` +middle and `1/5` endpoint angle checks and `3/4` total fixed-cap signs (`1/4` contact-induced signs). +Both are rejected. These candidates were run even after R7-W passed, as pre-registered. All R7 code +remains validation-only; no production cache, force, API, or default changed. + +#### 3.J.9c Stage R4-W - corrected wetted-area dynamics (pre-registered 2026-08-04) + +R4-W uses the complete derivative of the validation-only discrete energy +`E_h=-sigma*cos(theta)*A_h`. The exposed-wall colorfield is +`c_b=sum_a (m_a/rho_a) W_ab`; the R7 cubic area map, kernel half-space reference, and canonical +wedge edge displacement are unchanged. The half-space reference is fixed at reference particle +volume during each run so its derivative does not depend on an arbitrary fluid particle. + +The implementation must include both terms derived in `contact_angle_recovery.md`: the explicit +fluid-wall kernel derivative and the `ContinuityDensity`-consistent symmetric pressure-like +fluid-fluid term with `q_a=sigma*cos(theta)*S_a/rho_a^2` and pair coefficient +`q_a*rho_a/rho_b + q_b*rho_b/rho_a`. It must cache the equal/opposite exposed-wall reaction for +every explicit pair. No measured cap radius, fitted gain, target branch, angle offset, or per-case +tuning is permitted. Exact 0/180-degree targets are rejected by this validation model because the +canonical wedge correction has no finite endpoint derivation; 90 degrees must produce bitwise-zero +wetting energy, acceleration, and reaction. + +Run these gates in order and stop at the first failure: + +1. **Algebra/static:** directional energy-gradient relative error at most `1e-5`; relative + explicit reaction residual and density-force resultant at most `1e-12`; finite values; exact + 90-degree zero; and `4/4` established fixed-cap restoring signs at 1500 particles. +2. **Perturbation:** fresh no-contact controls and R4-W candidates at `t=0.01 s`, `4000 s^-1`, + threshold `0.1`, and 1500 particles must pass `4/4` complete restoring responses. One uniform + final-time extension is allowed only under the resolution condition in Stage R4 above. +3. **Threshold replay:** the five target-initialized cases must remain within 5 degrees, settled, + penetration-free, inside `980--1020 kg/m^3`, and at or below 25% rejected steps. +4. **Timestep/cost:** rerun `(90 degrees,1500)` and `(30 degrees,3000)` with the section 1.E + `eta_p01>=0.05` and tail/head `eta>=0.5` gates. Record three interleaved no-contact/R4-W cost + repetitions and all validation-owned cache bytes. +5. **Full replay:** only after gates 1--4 pass, run the 15-cell resolution matrix and existing + threshold/damping sensitivity matrix. + +Write new suffixed evidence (`*_r4_wetted_area.csv`) and preserve all baseline/rejected-model CSVs. +The model stays under `compare_akinci/` until all gates pass; passing R4-W is necessary but not by +itself sufficient for production promotion or a default change. + +**R4-W protocol correction after the initial perturbation run:** the inherited CLF classifier +required a candidate to beat no contact and have nonzero contact-induced acceleration even when the +target is 90 degrees. That is impossible for the frozen energy because `cos(90 degrees)=0`; R4-W +must then be exactly identical to no contact. Preserve the original CSV. For 90-degree targets, +replace only those two contradictory predicates by exact control equivalence and correctly directed +total CSS acceleration. Non-90-degree comparisons and every safety/reaction gate are unchanged. +Under this corrected classifier all four acceleration signs pass, three motions resolve, and the +remaining motion is below 1 degree, so the one permitted uniform extension is frozen at `0.02 s` +for every candidate/control pair. Write it to a separate `*_extended.csv` and do not overwrite the +initial run. + +**Parallel track T1 (completed 2026-08-03):** characterize the CSS tensile instability on the free +Rayleigh drop with existing package options only; record collapse mode (pairing distance, period count) and +timestep-collapse evidence to `validation/surface_tension_2d/rayleigh_tensile_stability.csv`. +Acceptance for a documented (non-default) recommendation: at least five free periods with frequency +error at most 5%, no particle pairing below half spacing, density within Phase 2 bounds, and no +per-case tuning. Baseline collapses after 0.30 periods with minimum spacing `0.262 dx` and minimum +density `842 kg/m^3`. A Laplace-scale EOS background pressure delays collapse to 0.76 periods but +worsens pairing to `0.0068 dx` and minimum density to `87 kg/m^3`. TVF, particle shifting, and TIC +were not run because their API documentation then forbade free-surface use without an unavailable +surface mask. Phase 4 subsequently added opt-in colorfield-based tangential free-surface shifting. +Its unchanged T1 replay delays collapse to 1.48 periods and keeps minimum pair spacing at +`0.763 dx`, but frequency error rises to 33.6%, density falls to `576 kg/m^3`, and the timestep still +collapses. No applicable option passes and no default changes. + +Phase 4 wetting acceptance remained blocked throughout R0-R7. R4-W subsequently passed its complete +validation-only and production replays; production integration, D3/D5 cleanup, and G3 verification +are now complete. Track B calibration may proceed, while any default contact-model claim remains out +of scope because wetting is explicit opt-in. + +#### 3.J.10 Recovery completion gate + +- [x] One mechanism passes 4/4 fixed-particle signs and 4/4 dynamic restoring cases. +- [x] Its corrected wetted-area measure passes the stated multi-resolution gates; no line delta is + used by R4-W. +- [x] Five-angle threshold replay, selected 15-cell matrix, sensitivity, timestep, density, + penetration, settlement, and cost evidence pass without per-case tuning. +- [x] D3 and D5 are closed; no-contact semantics, implementation, tests, metadata, docs, NEWS, + README, examples, and plots are consistent. +- [x] Full unit suite and docs build pass; G3 closes before Phase 4 resumes. + +--- + +## Phase 4 - Track B: Akinci-parity demonstrations with CSS + +**Objective:** CSS rows for all seven Akinci experiments in the `compare_akinci/` +acceptance workflow, matching or beating the accepted Akinci rows, with reliability +criteria on top. Raw particle diagnostics before any ray tracing, consistent with the +existing workbench rules. +**Estimate:** 7-10 working days including cluster time. **Status:** in progress; Figure 2 particle +alignment is corrected, but the CSS row remains unaccepted on strict bulk parity. + +### Tasks + +- [ ] **4.1 Calibration policy table.** One documented sigma (and contact angle) per case. + Wetting sequence: start from sigma ~5 N/m (prior study); others calibrated via their + case metric. The table lives in `compare_akinci/README.md` and states explicitly that + Track B coefficients are calibrated, not physical. The table now exists and records + `sigma=0.012 N/m` as Figure 2's best unaccepted candidate; remaining rows are explicit + `pending` entries. The derived Figure 8 + coefficient mapping is non-monotone and includes unsupported exact 180-degree endpoints, so + its angle policy remains unresolved. +- [ ] **4.2 CSS case runners.** Extend `compare_akinci/cases.jl` / `simulate.jl` with CSS + variants of: water crown (Fig 1/5), cube-to-sphere (Fig 2), droplet on plate (Fig 6), + stream over sphere (Fig 7), wetting sequence (Fig 8, via contact angles instead of + `(gamma, beta)` pairs - use the Young-Dupre mapping already derived in the README), + droplet splitting (Fig 9), rolling droplet (Fig 10). + D4 is resolved before Fig 9/10 implementation: their heterogeneous adhesion is outside the + current single-angle, disk-patch CSS contact contract. `cube_to_sphere_css` is implemented + through the shared runner; the other cases remain. +- [ ] **4.3 Metrics per case vs the accepted Akinci rows:** + | Case | Metric | + |---|---| + | Fig 1/5 crown | center-slice crown-rim height at t = 0.055 s (Akinci baseline: 2.0 vs 1.5 mm spacing agree within 1.3%) | + | Fig 2 cube-to-sphere | radial-moment history to the released sphere; post-impact thin-layer width, height/aspect, x/y symmetry, and top-slice particle isotropy at `t=0.10 s` | + | Fig 6 droplet on plate | spread/rebound diameter time series | + | Fig 7 stream over sphere | attached-film / detachment behavior at matched flow | + | Fig 8 wetting | monotone h/w ladder across contact-angle sequence; no penetration | + | Fig 9 splitting | number and timing of splits in the adhesive box | + | Fig 10 rolling | drop remains attached while rolling; travel distance | +- [ ] **4.4 Reliability criteria per case:** no sustained timestep collapse under section + 1.E; +-20% sigma perturbation preserves qualitative result; two resolutions (iterate at + 2.5/2.0 mm, accept at the comparison resolution); zero penetration; density bounds + recorded. + Figure 2's shifted 6859/3375-particle rows have positive wall clearance, density in + `955.58-1000.40 kg/m^3`, at most 8.7% rejected steps, and timestep tail/head ratios + `0.983-1.152`. Both pass particle-isotropy and reliability gates; the nominal row fails the + original sphere/spread parity limits, so coefficient perturbations are not promoted. Bounded + 3375-particle probes with flow-scaled shifting, `sigma=0.0144 N/m`, and `alpha=0.025` each + retain a decisive shape or isotropy failure; Figure 2 tuning is closed. +- [ ] **4.5 Acceptance and documentation.** Per-case verdict (CSS >= Akinci quality on the + metric, plus reliability) recorded in `compare_akinci/README.md` with diagnostic PNGs + (same style as `figure_08_*_diagnostic.png`). Cases that cannot be accepted get an + explicit cause, not a silent skip. + Figure 2 evidence is recorded in `figure_02_track_b.csv` and + `figure_02_css_akinci_diagnostic.png`. Its alignment-corrected row is explicitly rejected on + pre-release radial-moment, release-shape, and post-release width errors. +- [ ] **4.6 Known-limitations section** consolidated: WCSPH static wall support, Bond-number + argument, coarse-resolution Laplace overprediction. + +### Exit gate G4 + +- [ ] All seven cases accepted or explicitly documented with cause; side-by-side CSS/Akinci + diagnostics committed; reliability table complete. + +--- + +## Phase 5 - Cleanup and handoff + +**Objective:** minimal, consistent final state on the branch. +**Estimate:** 2 working days. **Status:** not started (blocked by G4). + +### Tasks + +- [ ] **5.1 Production wetting example.** New `examples/fluid/css_wetting_2d.jl` (or rework + `sphere_surface_tension_wall_2d.jl`) using CSS + the chosen contact model - replaces + the role of the deleted `akinci_wetting_2d.jl` / `cohesion_force_akinci_2d.jl`. + Registered in `test/examples/examples_fluid.jl`. +- [ ] **5.2 Docs refresh.** `docs/src/systems/fluid.md`: model-selection guidance (CSS as + recommended physical model; Akinci variants retained with their documented caveats), + updated experiment table, validation references. +- [ ] **5.3 NEWS.md final entries** reflecting the actual shipped feature set. +- [ ] **5.4 Stale-comment sweep** (e.g. the commented Morris/CSS alternatives in + `sphere_surface_tension_2d.jl`), JuliaFormatter pass, final full test run + docs build. + +### Exit gate G5 + +- [ ] Full suite green, docs build clean, examples within CI budget, branch ready for review + as a single PR. + +--- + +## Decision log + +| ID | Decision | Options | Status | Outcome / evidence | +|---|---|---|---|---| +| D1 | Contact-model selector API name and deprecation path for `contact_angle=` | explicit `contact_model=...`; error vs deprecation for old keyword | decided | Final production spelling is `contact_model=WettedAreaContactAngle(theta)`. The ambiguous unshipped keyword and rejected candidate types have no compatibility aliases. | +| D2 | Recommended `boundary_contact_threshold` for CSS wetting | 0.0 (sessile script) vs 0.1 (default) | decided | Retain the public default `0.1`. Across both mechanisms and damping values, changing 0.0 to 0.1 moved the 90-degree local fit by at most 0.08 degrees and did not change any pass/fail result. | +| D3 | Default contact-angle mechanism | geometric vs CLF vs recovered wall energy | decided | Recommend explicit `WettedAreaContactAngle(theta)`: production static/extended/threshold/timestep/selected/sensitivity gates pass `9/9`, `4/4`, `5/5`, `2/2`, `15/15`, and `4/4`; active overhead is 16.0%. `contact_model=nothing` remains the constructor default. | +| D4 | Wall-adhesion story for Fig 9/10 under CSS | contact-angle only; pair with `SurfaceTensionAkinciCohesionPhysical` wall term; document as out of CSS scope | decided | Document as outside the current CSS scope. The production contact angle is fluid-global and requires one connected disk-like patch per boundary, so it cannot encode the adhesive/non-adhesive boundary contrasts central to either case. A composite Akinci/CSS wall model does not exist and is not added in Track B. | +| D5 | Fate of the losing mechanism | keep opt-in with documented niche vs delete machinery | decided | Deleted both rejected unshipped geometric and contact-line-force implementations, exports, caches, force wiring, tests, and public prose without aliases. | +| D6 | Phase 1 activity-transition widths and timestep thresholds | `alpha in {0.5, 0.8, 0.9}`, `Delta_q in {0.025, 0.05, 0.10}`; retain or tighten provisional efficiency thresholds | decided | Selected `alpha=0.8`, `Delta_q=0.025`. Alpha had negligible resolved-drop effect; the narrowest support transition gave the best eta p01 and fewest transition particles. Both models passed rejection and tail/head criteria. | +| D7 | Public interior-filter semantics for Morris/CSS | document `ideal_density_threshold` as continuous complete-support fraction with migration evidence; or add an explicit support-moment threshold and deprecate the integer-count behavior | decided | Keep the keyword and document corrected continuous-support semantics. Default `0` remains disabled. Validation configurations migrate explicit `0.9` to `0.95`; this plus `Delta_q=0.025` keeps all five static fits within 0.31 percentage points of Phase 0. | + +## Risk register + +| ID | Risk | Mitigation / contingency | +|---|---|---| +| R1 | Neither mechanism reaches +-5 degrees in V3 | Closed: all 30 cells pass with the local meridional-circle estimator; maximum error is 3.17 degrees. | +| R2 | Coarse-drop Laplace overprediction converges too slowly | document first-order behavior; optional Shepard-normalized color gradient for `delta_s` as follow-up | +| R3 | 3D compute budget (water crown at 1.5 mm = ~272k particles) | iterate at 2.5/2.0 mm; only final acceptance runs at comparison resolution | +| R4 | Fig 9/10 need attraction CSS does not provide | resolve via D4 before building the runners | +| R5 | Tapered activity shifts calibrated static balance | Closed for G1: activity is exactly one above the magnitude threshold and through the support threshold; the selected global taper changes static sigma-fit by at most 0.31 percentage points | +| R6 | New caches/broadcasts not GPU-safe | out of scope; noted for follow-up issue | +| R7 | Correcting Morris changes all previously inferred Morris coefficients | Accepted and documented: calibration rerun with the dimensional force; no empirical legacy multiplier added | +| R8 | Continuous support filtering silently changes a public threshold's behavior | Closed by D7: default remains disabled; changed explicit semantics and the `0.9` to `0.95` validation migration are documented in NEWS and API docs | +| R9 | Target-initialized, strongly damped V3 caps make both mechanisms look correct without proving restoring behavior | Realized: geometric passed 1/4 and CLF 2/4 complete off-target cases. G3 is blocked; neither model was promoted. | +| R10 | Single-run wall-clock noise chooses the mechanism | Warm each path, rotate execution order, use three repeats, report median/MAD and accepted steps; cost is the final tie-break only. | +| R11 | "Default mechanism" is misread as implicit wetting | Keep `ColorfieldSurfaceNormal(contact_model=nothing)` unchanged; recommendation applies only after an explicit target/model request. | +| R12 | Subtracting the wall contribution exposes a noisy/undefined fluid-only normal | Realized: maximum middle-resolution static error is 58.5 degrees and wrong-sign line weight reaches 85%. No production cache was added; recovery moved to R6. | +| R13 | Simultaneous angle and line-delta changes hide the cause of improvement | R2 changes only the CLF direction; R3 permits one separately normalized localization variant only after line-integral evidence. | +| R14 | Recovery drifts into empirical coefficient fitting | No gains, offsets, target branches, or per-case damping/time are allowed; every change must follow a geometric or continuum normalization gate. | +| R15 | A continuum-normalized planar line measure does not transfer to curved particle geometry | Realized: kernel-derived coarea passes every middle planar case, but Wendland C2 spherical-cap errors remain 22-34% before support correction and two obtuse cases still exceed 20% after it. No wall-energy model was added. | +| R16 | Running three measure candidates at once hides which ingredient fixes the transfer | R6-D attribution runs first and each candidate keeps its own suffixed evidence file; candidates are ranked only through the shared measure-first gates and one comparison table. | +| R17 | Parallel tensile-instability work drifts into production changes or steals recovery scope | T1 is restricted to shipped options, writes its own evidence file, cannot change defaults, and carries its own acceptance gates for a documented recommendation only. | +| R18 | A post-hoc endpoint change accepts a favored candidate | The amended rule was calibrated only with the exact-profile control before candidate runs, uses eight predeclared rank-1 phases, and applies the same 20%/two-standard-error rule to every candidate. The discarded four diagonal phases were symmetry-equivalent and produced no independent samples. | +| R19 | A target-imposed scalar boundary condition appears correct only through its line measure | Realized: R7-Y passes `5/5` line-measure gates but only `2/5` middle angle gates and `3/4` total signs. The angle/sign gates reject it despite the line integral. | + +## Results ledger (fill with evidence as gates close) + +### V1 Young-Laplace (physical sigma = 1 N/m unless noted) + +| Case | Particles | Fitted sigma | Virial sigma | \|sum F\| | Order | Pass | +|---|---|---|---|---|---|---| +| 3D static (baseline 2026-08-02) | 389/739/1503/2969/6031 | 1.0333/1.0026/0.9970/0.9557/1.0079 | 0.8323/0.8876/0.9101/0.9385/0.9599 | <= 4e-17 N | - | baseline | +| 3D static (Phase 1 activity) | 389/739/1503/2969/6031 | 1.0309/0.9993/0.9927/0.9526/1.0044 | 0.8287/0.8793/0.8990/0.9298/0.9470 | <= 3e-17 N | - | pass G1 | +| 2D static, matched spacing | 78/118/181/279 | 1.0914/1.0485/1.0209/0.9842 | 1.0376/1.0378/1.0639/1.0933 | <= 8.3e-16 N | 2.652 | pass | +| 3D static, controlled `R/dx=4/6/8/10` | 251/925/2109/4169 | 1.0724/0.9909/0.9887/0.9750 | 0.8230/0.8877/0.9197/0.9360 | <= 3.1e-17 N | 1.250 | pass | +| 3D relaxed dynamic | 389/739/1503 baseline: 1.50/1.36/1.16 | | | | | baseline | + +### V2 Rayleigh oscillation + +| Particles | omega_measured | omega_analytic | Error | Pass | +|---|---|---|---|---| +| 225 | 73.9566 | 70.9106 | 4.30% | pass | +| 435 | 75.6440 | 72.7367 | 4.00% | pass | +| 850 | 76.9183 | 74.0166 | 3.92% | pass | + +The values above are linear stiffness measurements. Free peak/spectral trajectories are not +accepted because the timestep collapses. The fixed five-period T1 protocol in section 3.J.9a +terminates the 4% stretched baseline after 0.30 periods. Tangential free-surface shifting reaches +1.48 periods with acceptable pair spacing but fails the frequency, density, and timestep gates; +the EOS-background alternative also fails. + +### V3 sessile-drop matrix (settled apparent angle, mid resolution) + +| theta_target | Geometric: cap fit / circle fit | CLF: cap fit / circle fit | Notes | +|---|---|---|---| +| 30 | 29.42 / 29.92 | 29.42 / 29.93 | both pass | +| 60 | 65.70 / 59.31 | 65.70 / 59.45 | both pass; volume fit shows lattice bias | +| 90 | 90.59 / 87.69 | 90.59 / 88.10 | both pass | +| 120 | 116.75 / 118.05 | 116.75 / 118.53 | both pass | +| 150 | 144.63 / 148.88 | 144.63 / 149.25 | both pass | + +These are the 1500-particle rows. The primary contact-line measurement is the local +meridional-circle fit; the volume fit is reported as the required independent shape metric. +The full 30-cell matrix uses one documented protocol for every cell: analytic target cap, +`t=0.01 s`, constant damping `4000 s^-1`, and no per-case tuning. All cells settle below +`5e-3 m/s`, have zero penetration, remain in `[0.985, 1.001] rho_0`, and pass the local ++-5-degree gate. The largest local error is 3.17 degrees. This validates overdamped static +equilibrium preservation; it is not evidence of undamped dynamic wetting. + +At 1500 particles and 90 degrees, the full threshold/damping sensitivity matrix also passes. +Local fits span 87.58-88.12 degrees; switching `boundary_contact_threshold` between 0.0 and +0.1 changes a matched result by at most 0.08 degrees. Raw evidence is in +`validation/surface_tension_3d/sessile_drop_matrix.csv` and +`validation/surface_tension_3d/sessile_drop_sensitivity.csv`. + +### Phase 3 contact-mechanism decision + +| Metric | Geometric | Contact-line force | +|---|---:|---:| +| Static eligibility | pass | pass | +| Threshold `0.1` replay | 5/5 pass | 5/5 pass | +| Representative timestep gate | 2/2 pass | 2/2 pass | +| Complete off-target response | 1/4 pass | 2/4 pass | +| Correct contact-induced acceleration | 2/4 | 3/4 | +| Mean error-reduction ratio | -0.00119 | 0.00313 | +| Median runtime overhead over no contact | 2.4% | 17.3% | +| Contact-specific cache at 1508 particles | 36,192 bytes | 60,320 bytes | +| Eligible for recommendation | **no** | **no** | + +The hard restoring gate stops the lexicographic ranking. In particular, CLF's `(target, initial) = +(90, 120)` case estimates a line angle near 89 degrees from the colorfield normal while the local +cap fit is 118 degrees, and drives in the wrong direction. The longer exploratory check preserves +that drift. `contact_model=nothing` remains the production default; both experimental models require +explicit selection. + +### R6 contact-line normalization + +The validation-only kernel study covers ten 3D kernels, five planar intersection angles, +`h/dx = 2, 4, 8`, and four lattice phases. Its coarea measure uses a factor derived from the +implemented gradient's half-space integral rather than a sessile fit. All 50 middle-resolution +cases are below 20% error (maximum 3.37%), while 40/50 satisfy the strict endpoint-decrease rule. +The divergence form passes only the nine non-Laguerre 90-degree middle cases and no oblique case. +On spherical Wendland C2 caps, the same coarea factor remains outside tolerance; the support-moment +variant passes only 3/5 middle cases. Evidence is in +`validation/surface_tension_3d/contact_line_normalization.csv` and +`validation/surface_tension_3d/contact_angle_normal_components.csv`. + +The completed continuation gives the compact comparison below. A method needs 5/5 middle and 5/5 +endpoint measure passes before signs count. + +| R6 method | Middle measure | Endpoint measure | Signs | Eligible | +|---|---:|---:|---:|---:| +| Production discrete attribution | 0/5 | 2/5 | not run | no | +| Analytic wall + interface attribution | 5/5 | 1/5 | not run | no | +| Compatible colorfield continuation | 5/5 | 0/5 | not run | no | +| Wetted-area wall energy | 4/5 | 4/5 | 4/4 (diagnostic only) | no | + +Evidence is in `contact_line_cap_transfer.csv`, `wetted_area_measure.csv`, +`contact_angle_force_sign_wetted_area.csv`, and `contact_angle_recovery_comparison.csv`. + +### R7 controlled-cap recovery + +| R7 method | Middle measure | Endpoint measure | Angle middle / endpoint | Signs | Eligible for R4 | +|---|---:|---:|---:|---:|---:| +| Exact-profile protocol control | 5/5 | 5/5 | 5/5 / 5/5 | control | no | +| Compatible continuation + geometry wall | 5/5 | 2/5 | 0/5 / 0/5 | 4/4 total | no | +| Young color boundary | 5/5 | 5/5 | 2/5 / 1/5 | 3/4 total | no | +| Corrected wetted-area energy | 5/5 | 5/5 | not applicable | 4/4 total; exact zero at 90 deg | **yes** | + +The exact-profile fixed-`h` quadrature control also passes `5/5`. R7-W uses a canonical flooded-wall +kernel integral and an angle-derived wedge edge displacement; no cap measurement is fitted. Its +maximum middle area error is 5.68%, including 1.99% at 150 degrees. Evidence is in +`contact_measure_protocol.csv`, `wetted_area_corrected.csv`, +`contact_angle_recovery_extended.csv`, `contact_angle_force_sign_extended.csv`, and +`contact_angle_recovery_extended_comparison.csv`. Eligibility here means entry to R4 only, not a +production recommendation. + +### R4-W corrected wetted-area dynamics + +| R4-W gate | Result | +|---|---:| +| Algebra/static | 9/9; max gradient error `4.89e-10` | +| Effective acceleration / extended response | 4/4 / 4/4 | +| Threshold replay / timestep | 5/5 / 2/2 | +| Repeated median runtime overhead | 2.0% at zero-force 90 deg; 30.5% at active 60 deg | +| Selected matrix / sensitivity | 15/15 / 4/4 | +| Maximum selected-matrix momentum residual | `4.03e-15` | + +The inherited CLF comparator in the preserved initial perturbation file reports 2/4 because it +requires nonzero contact force at 90 degrees. The formulation-consistent classifier requires exact +no-contact equivalence and total restoring acceleration there, giving 3/4 resolved initial motions; +the one pre-registered uniform `0.02 s` extension passes 4/4. These validation-only files are +preserved beside the production evidence. + +The inherited 90-degree cost case exercises the exactly disabled force path. Its 2.0% overhead is +retained, but the same interleaved protocol at an active 60-degree target gives the representative +30.5% overhead. Cost had no hard R4 acceptance threshold; production integration must account for +this extra traversal cost. + +### Production promotion result + +| Production gate | Result | +|---|---:| +| Algebra/static | 9/9; max gradient error `3.59e-10` | +| Effective acceleration / extended response | 4/4 / 4/4 | +| Threshold replay / timestep | 5/5 / 2/2 | +| Repeated median runtime overhead | 0.4% at zero-force 90 deg; 16.0% at active 60 deg | +| Selected matrix / sensitivity | 15/15 / 4/4; span `0.107 deg` | + +The implementation follows the frozen API/support/cache contract below. Fusing both derivatives +into existing fluid interactions and reducing fixed-wall reactions from thread-local buffers removes +the validation model's extra force traversals and passes the 20% active-cost gate. Production-only +files use the `*_production.csv` suffix; validation-only evidence is unchanged. D3 selects the +explicit model, D5 removes both rejected unshipped candidates, and `contact_model=nothing` remains +the default. + +### Production promotion contract (pre-registered 2026-08-04) + +Promote only as `WettedAreaContactAngle(theta)` under the explicit +`ColorfieldSurfaceNormal(contact_model=...)` API. Preserve `contact_model=nothing` as the default. +The first production implementation supports only 3D `ContinuityDensity`, +`WendlandC2Kernel{3}`, `h/dx=1.4`, one fluid, and dummy-particle wall/rigid systems with +`InitialCondition.normals` plus explicit per-particle `surface_measure`. Multiple boundary systems +are allowed, but each must represent one connected disk-like wetted patch. Exact 0/180-degree +targets are rejected; 90 degrees is exactly zero. Other kernels, ratios, dimensions, and contact +topologies remain unsupported rather than silently extrapolated. + +Fuse the density term into the existing fluid-fluid RHS and the explicit derivative into the +existing fluid-boundary RHS. Boundary caches own surface measures, transient area weights, and +reaction diagnostics; rigid reactions also enter `force_per_particle`. Require production-only +gradient/conservation tests, WCSPH/EDAC and wall/rigid tests, complete R4 replay, and active +60-degree median overhead at most 20%. If all pass, close D3 with this explicit recommendation and +apply D5 literally: remove both rejected unshipped geometric and CLF implementations without +aliases. New evidence uses `*_production.csv` and never replaces validation-only R4 files. + +### Track B acceptance + +| Case | Akinci metric (accepted row) | CSS metric | Reliability | Verdict | +|---|---|---|---|---| +| Fig 1/5 crown | | | | | +| Fig 2 cube-to-sphere | Sphere release asphericity `0.00902`; final thin-layer `h/w=0.1354`, x/y asymmetry `1.95%`; `11459/54` accepted/rejected steps | Release `0.02295`; final `h/w=0.1608`, x/y asymmetry `0.30%`; post-release width error `13.53%`; alignment CV/m8/radial `0.108/0.003/0.047` | No penetration; density `978.33-1000.38 kg/m^3`; `2253/109` accepted/rejected steps; timestep tail/head `0.983` | **rejected: bulk parity** | +| Fig 6 plate | | | | | +| Fig 7 stream | | | | | +| Fig 8 wetting ladder | | | | | +| Fig 9 splitting | | | | | +| Fig 10 rolling | | | | | + +## Command reference + +```bash +# Static CSS balance across resolutions +julia +release --project=compare_akinci/simulation \ + compare_akinci/css_validation.jl 375 750 1500 3000 6000 + +# Radius-series calibration gate (Phase 1) +julia +release --project=compare_akinci/simulation \ + compare_akinci/surface_tension_calibration.jl laplace_series morris 1.0 0.02 +julia +release --project=compare_akinci/simulation \ + compare_akinci/surface_tension_calibration.jl laplace_series momentum_morris 1.0 0.02 + +# Recorded Phase 1 reliability/sensitivity diagnostics +julia +release --project=compare_akinci/simulation \ + compare_akinci/phase1_reliability.jl single morris 0.8 0.025 0.005 375 +julia +release --project=compare_akinci/simulation \ + compare_akinci/phase1_reliability.jl sensitivity momentum_morris 0.005 375 + +# Historical Phase 2/3 candidate CSVs are retained as read-only D3/D5 evidence. +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/contact_angle_decision.jl score + +# Validation-only R4-W replay +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/contact_angle_decision.jl r4_static +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/contact_angle_decision.jl r4_perturbation +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/contact_angle_decision.jl r4_perturbation_extended +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/contact_angle_decision.jl r4_threshold +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/contact_angle_decision.jl r4_timestep +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/contact_angle_decision.jl r4_cost +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/contact_angle_decision.jl r4_cost_active +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/contact_angle_decision.jl r4_selected +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/contact_angle_decision.jl r4_sensitivity + +# Production WettedAreaContactAngle replay +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/contact_angle_decision.jl production_static +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/contact_angle_decision.jl production_perturbation +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/contact_angle_decision.jl production_perturbation_extended +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/contact_angle_decision.jl production_threshold +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/contact_angle_decision.jl production_timestep +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/contact_angle_decision.jl production_cost +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/contact_angle_decision.jl production_cost_active +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/contact_angle_decision.jl production_selected +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/contact_angle_decision.jl production_sensitivity + +# Young-Laplace and Rayleigh validation references +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_2d/validation_surface_tension_2d.jl +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_2d/rayleigh_tensile_stability.jl +julia +release --project=compare_akinci/simulation \ + validation/surface_tension_3d/validation_surface_tension_3d.jl + +# Validation plots (written beside their input data) +julia +release --project=test validation/surface_tension_2d/plot_surface_tension_2d.jl +julia +release --project=test validation/surface_tension_3d/plot_surface_tension_3d.jl + +# Complete unit suite +TRIXIPARTICLES_TEST=unit julia --project=. -e 'using Pkg; Pkg.test()' +``` + +## Progress log + +| Date | Phase | Note | +|---|---|---| +| 2026-08-03 | - | Plan created; baseline diagnosis confirmed (failing `apply_contact_angle!` test reproduced against source) | +| 2026-08-03 | 0 | Added explicit geometric/CLF selectors, restored geometric rotation, isolated CLF caches and dispatch, and aligned API metadata and prose. | +| 2026-08-03 | 0 | Added geometric, CLF sign/orientation, WCSPH/EDAC wiring, flat-pool cancellation, and rigid-boundary regression tests. Focused and complete unit suites pass; G0 closed. | +| 2026-08-03 | 1 planning | Expanded Phase 1 after source audit: identified neighbor-repeated, dimensionally incomplete Morris CSF assembly; specified corrected local force, continuous support activity, CSS conservation invariants, timestep-efficiency diagnostics, sensitivity study, API migration decision, and quantitative G1 gates. | +| 2026-08-03 | 1 | Corrected Morris to apply the dimensionally complete local CSF force once per particle; added model-specific delta/activity/support caches, C1 activation, activity-weighted curvature, CLF tapering, VTK diagnostics, and WCSPH/EDAC tests. | +| 2026-08-03 | 1 | Selected `(alpha, Delta_q)=(0.8, 0.025)` and validation support threshold `0.95`; Morris/CSS radius series finish in 137/119 s, five-resolution static CSS stays within 5%, complete unit suite passes, and G1 is closed. | +| 2026-08-03 | 2 | Added physical-sigma 2D/3D Young-Laplace and Rayleigh stiffness validation, tracked references, plots, and coarse CI gates. V1 orders are 2.652/1.250 and all V2 stiffness errors are below 4.30%. | +| 2026-08-03 | 2 | Completed the 30-cell sessile-drop matrix and eight-cell sensitivity sweep with one overdamped protocol. All cells pass angle, density, penetration, and settlement gates; JuliaFormatter 2.1.1 and all 23047 unit tests pass; G2 is closed. | +| 2026-08-03 | 3 planning | Expanded G3 into reproducible eligibility/ranking rules, an off-target restoring-response matrix with no-contact controls, threshold replay, timestep/cost measurements, explicit D3/D5 branches, and a file-level test/documentation audit. | +| 2026-08-03 | 3 | Static, threshold, sensitivity, timestep, and repeated-cost evidence completed. Geometric passes 1/4 and CLF 2/4 complete off-target response cases; neither is eligible, no default is selected, and G3 remains open. Scorecard tests, all 23047 unit tests, docs, formatting, and prose audit pass. | +| 2026-08-03 | 3 recovery planning | Prioritized a boundary-corrected CLF angle diagnostic (`n_fluid=n_total-n_wall`), then angle-only correction, optional line-integral-based localization, fixed-particle 4/4 signs, and dynamic replay. Geometric and third-model branches are strictly conditional. | +| 2026-08-03 | 3 recovery | Implemented fixed-particle normal/force diagnostics. Total/fluid-only CLF normals fail R1, and the ghost-gradient geometric variant remains 2/4. A target-only wall-energy diagnostic reaches 4/4 with the one-phase factor, but all tested line measures fail normalization; no production model change was made and R6 continues with a kernel-level line-delta study. | +| 2026-08-03 | 3 recovery | Completed the ten-kernel planar/oblique normalization study. Kernel-derived coarea passes 50/50 middle planar cases but 40/50 strict endpoint gates; divergence passes 9/50. The coarea factor and a support-moment correction still fail the spherical-cap line gate, so no wall-energy model was added and G3 remains open. Focused references, all 23052 unit tests, formatting, plots, and docs pass. | +| 2026-08-03 | 3 recovery planning | Decided the R6 continuation: a three-way comparison of the cap-transfer diagnostic (R6-D), compatible Shepard-normalized indicators (R6-C), and a wetted-area wall-energy force (R6-W) under shared measure-first gates, plus parallel tensile-instability track T1 restricted to shipped options. Documentation and design notes only; no new code or evidence. | +| 2026-08-03 | 3 recovery | Completed R6-D/C/W. Wedge/gated planar variants reproduce the transfer loss; compatible continuation passes 5/5 middle but 0/5 endpoint cap gates; wetted area passes 4/5 middle and endpoint gates and has 4/4 diagnostic signs. No candidate is eligible, no dynamics run, and G3 remains open. | +| 2026-08-03 | T1 | The free Rayleigh baseline collapses at 0.30 periods; Laplace-scale EOS background pressure reaches 0.76 periods but catastrophically worsens pairing and density. TVF/PST/TIC were inapplicable without a free-surface mask. The later Phase 4 tangential treatment makes a new PST replay applicable but does not retroactively change this result. | +| 2026-08-03 | R6/T1 verification | All 14 focused evidence checks and all 23052 unit tests pass; JuliaFormatter 2.1.1, both validation plots, documentation, and `git diff --check` pass. | +| 2026-08-04 | R7 protocol | Replaced the invalid single-phase cap endpoint test by an eight-phase estimator and exact-profile fixed-`h` control. The production and quadrature controls both pass 5/5 middle/endpoint checks before candidate evidence is judged. | +| 2026-08-04 | R7 formulations | Completed all pre-registered candidates even after R7-W passed. Corrected wetted area passes 5/5 measure gates and 4/4 total signs; R7-CG and R7-Y are rejected. R7-W is admitted to R4 dynamics, while production and G3 remain unchanged. | +| 2026-08-04 | R7 verification | All 11 focused R7 evidence checks and all 23052 unit tests pass with at most eight Julia threads; JuliaFormatter 2.1.1, the 3D validation plot, documentation, and `git diff --check` pass. | +| 2026-08-04 | R4-W validation | Added the validation-only full energy derivative and equal/opposite wall reaction. Algebra/static passes 9/9, the sole uniform extension passes 4/4 responses, threshold/timestep pass 5/5 and 2/2, the selected matrix passes 15/15, and sensitivity passes 4/4. Median overhead is 2.0% on the zero-force 90-degree path and 30.5% on the active 60-degree path. Production integration and G3 cleanup remain pending. | +| 2026-08-04 | R4-W verification | All 22 focused R4 evidence checks, all 110 validation checks, and all 23052 unit tests pass with at most eight Julia threads. JuliaFormatter 2.1.1, the updated 3D validation plot, documentation, and `git diff --check` pass. | +| 2026-08-04 | Production integration | Added `WettedAreaContactAngle`, explicit boundary `surface_measure` quadrature, strict supported-configuration checks, fused WCSPH/EDAC forces, fixed-wall reaction reduction, and rigid force/torque transfer. Production static, extended perturbation, threshold, timestep, selected, and sensitivity gates pass. | +| 2026-08-04 | Production cost/D5 | Thread-local reaction accumulation gives 0.4% overhead on the zero-force path and 16.0% at active 60 degrees, below the 20% gate. D3 selects explicit wetted-area contact while preserving the no-contact default; D5 deletes both rejected unshipped candidates without aliases. Final full verification remains. | +| 2026-08-04 | G3 verification | Production evidence validation passes 125/125 checks and the complete unit/Aqua suite passes 23063/23063. Documentation, JuliaFormatter 2.1.1, the production-backed 3D validation plot, relevant changed examples, and `git diff --check` pass. Two unchanged 3D open-boundary examples still reject their nonorthogonal face definitions and are outside G3. G3 is closed and Phase 4 is unblocked. | +| 2026-08-04 | Phase 4 Figure 2 | Audited the paper and companion video after rejecting an early-impact mound as the final target. Corrected the Akinci floor to the paper-default `beta=gamma=1`, extended the right panel from `0.085` to `0.10 s`, and required the reference sphere-to-thin-layer sequence. Added the first model-aware CSS runner, radial-moment/thin-layer/symmetry metrics, accepted-timestep diagnostics, and initial four-row evidence. The initial extent-only `sigma=0.012 N/m` acceptance was subsequently retracted after raw top slices exposed coherent axis/diagonal particle rays. D4 documents Figures 9/10 heterogeneous adhesion as outside the current CSS contact contract. | +| 2026-08-04 | Phase 4 Figure 2 alignment | Added `FreeSurfaceTangentialShifting`, which reuses Morris/CSS smooth interface activity to retain full consistent interior shifting and remove the surface-normal component at the interface. Projection/cache tests pass, and shifted 6859/3375-particle rows remove the coherent rays while passing density, wall-clearance, rejection, timestep, and isotropy gates. The nominal row remains explicitly unaccepted: maximum pre-release radial-moment error is `0.03194`, release asphericity is `0.02295`, and post-release width error is `13.53%`, above the frozen `0.025`, `0.01902`, and `10%` limits. | +| 2026-08-04 | Phase 4 T1 replay | Replaced T1's ineligible PST row by the new tangential free-surface treatment under the unchanged five-period gate. It delays collapse from 0.30 to 1.48 periods and keeps minimum spacing at `0.763 dx`, but fails with 33.6% frequency error, `576 kg/m^3` minimum density, and timestep collapse. It is not a Rayleigh-stability recommendation. | +| 2026-08-05 | Phase 4 Figure 2 stop | Completed bounded 3375-particle follow-ups. `v_max_factor=1` improves release asphericity to `0.01820` but fails qualitative isotropy; a physically bridged factor 15 restores isotropy without improving shape. `sigma=0.0144 N/m` worsens release/final shape, while `alpha=0.025` improves spread but worsens release asphericity to `0.03201`. No candidate merits a full-resolution replay; Figure 2 remains rejected and tuning stops. | +| 2026-08-05 | Phase 4 T1 isolation | Added the required update-callback path and replayed Sun-2017 tangential callback shifting without the Sun-2019 transport terms. It collapses at 0.40 periods with minimum spacing `0.651 dx`, `718 kg/m^3` minimum density, and 50% frequency error. The failure survives removal of the Sun-2019 continuity and momentum modifications. | diff --git a/compare_akinci/Project.toml b/compare_akinci/Project.toml new file mode 100644 index 0000000000..0d9381e1ad --- /dev/null +++ b/compare_akinci/Project.toml @@ -0,0 +1,32 @@ +[deps] +CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" +ComputePipeline = "95dc2771-c249-4cd0-9c9f-1f3b4330693c" +FileIO = "5789e2e9-d7fb-5bc7-8068-2c6fae9b9549" +GPUSelect = "203851ad-5791-4cf9-9fcc-5933b5611f42" +GeometryBasics = "5c1252a2-5f33-56bf-86c9-59e7332b4326" +Hikari = "afc56b53-c9a9-482a-a956-d1d800e05558" +Lava = "3a680b1f-cb25-4bee-9cf7-bc880b76dc8c" +LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" +Meshing = "e6723b4c-ebff-59f1-b4b7-d97aa5274f73" +Raycore = "afc56b53-c9a9-482a-a956-d1d800e05559" +RayMakie = "70852e3a-d594-4041-8350-783a02753e02" +Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" +Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" + +[sources] +ComputePipeline = {url = "https://github.com/MakieOrg/Makie.jl.git", rev = "ba325fb994b95c495dd6311d179ee821555f3955", subdir = "ComputePipeline"} +GPUSelect = {url = "https://github.com/SimonDanisch/GPUSelect.jl", rev = "main"} +Hikari = {url = "https://github.com/JuliaGraphics/Hikari.jl", rev = "sd/vk-hw-accel"} +Lava = {url = "https://github.com/SimonDanisch/Lava.jl", rev = "53f8705c22d6718ada239fd141db54aca9db78e9"} +Makie = {url = "https://github.com/MakieOrg/Makie.jl.git", rev = "ba325fb994b95c495dd6311d179ee821555f3955", subdir = "Makie"} +Raycore = {url = "https://github.com/JuliaGeometry/Raycore.jl", rev = "master"} +RayMakie = {url = "https://github.com/MakieOrg/Makie.jl.git", rev = "ba325fb994b95c495dd6311d179ee821555f3955", subdir = "RayMakie"} + +[compat] +CairoMakie = "0.15" +FileIO = "1" +GeometryBasics = "0.5" +Makie = "0.24" +Meshing = "0.7" +julia = "1.12" diff --git a/compare_akinci/README.md b/compare_akinci/README.md new file mode 100644 index 0000000000..64a8ee83f5 --- /dev/null +++ b/compare_akinci/README.md @@ -0,0 +1,919 @@ +# Akinci Comparison Workbench + +This directory validates the Akinci experiments in `examples/fluid` against Akinci, Akinci, and +Teschner (2013), +. + +Simulation accuracy is checked from unsmoothed particle slices before surface reconstruction or +ray tracing. Figure 1 is the first case under this workflow: + +![Figure 1 raw particle diagnostic](figure_01_particle_diagnostic.png) + +The Figure 1 setup now uses the reported `15 x 4 x 15 cm` filled container. At the comparison +resolution of `1.5 mm`, the regular voxel drop is `6.53 cm^3`, 0.5% above the reported `6.5 cm^3`. +The roughly 272,000 fluid particles are still fewer than the paper's one million. The impact speed +is not reported and is calibrated to `2 m/s` for this reduced resolution. The impact, crown, +cavity collapse, and onset of the central rebound are resolved through `0.12 s`; the mature narrow +jet requires a later run and is not yet accepted. Figure 2 has an accepted Akinci baseline and an +alignment-corrected CSS candidate that still fails bulk parity; Figures 6, 7, 9, and 10 remain +pending, and the Figure 8 Akinci failure is described below. + +The Akinci normal uses the kernel's compact-support radius, as defined in Equation 2 of the paper. +The case uses summation density so the free-surface correction in Equation 4 responds to missing +neighbors, and uses the same Monaghan artificial viscosity for fluid-fluid and fluid-solid pairs. + +A spacing study confirmed that the previous `2.5 mm` case preserved the bulk displaced volume but +filtered the thin crown and detached droplets. At `t = 0.055 s`, the center-slice spray heights are +`0.0496 m`, `0.0829 m`, and `0.0792 m` for `2.5 mm`, `2.0 mm`, and `1.5 mm`, respectively. The +`2.0 mm` and `1.5 mm` crown-rim heights agree within 1.3%. The paper's reported particle count +corresponds to approximately `0.97 mm` spacing. + +## Track B CSS Calibration + +Track B coefficients are calibrated demonstration parameters, not physical material properties. +Every accepted row must first match its Akinci metric at the comparison resolution, then preserve +the qualitative result under `+/-20%` sigma and at a second resolution. The common CSS configuration +uses `SurfaceTensionMomentumMorris`, `WendlandC2Kernel{3}` with `h/dx = 1.4`, +`ContinuityDensity`, `DensityDiffusionAntuono(delta=0.1)`, and the validated surface-activity +thresholds. Free-surface cases can explicitly use consistent Sun-2019 shifting with +`FreeSurfaceTangentialShifting`; wetting contact, when used, is always explicit. + +The calibration table is intentionally incomplete while Phase 4 is in progress. A `pending` entry +is not an accepted default. + +| Case | Calibrated sigma | Contact angle | Calibration metric / status | +|:-----|-----------------:|:--------------|:----------------------------| +| Figures 1/5, water crown | pending | none | Crown-rim height at `0.055 s`; accepted Akinci mature-jet row still needed | +| Figure 2, cube-to-sphere | not accepted (`0.012 N/m` candidate) | none | Alignment and reliability pass; strict sphere/spread parity fails | +| Figure 6, plate impact | pending | pending | Spread/rebound diameter history | +| Figure 7, stream over sphere | pending | unsupported on the current closed curved patch | Attached-film/detachment behavior | +| Figure 8, wetting ladder | start at `5 N/m` | pending | Monotone `h/w` ladder and zero penetration | +| Figure 9, splitting | not assigned | out of current CSS scope (D4) | Requires adhesive box/non-adhesive blade contrast | +| Figure 10, rolling | not assigned | out of current CSS scope (D4) | Requires distinct plane/rigid-body wettability | + +The published Figure 8 coefficient pairs cannot be silently relabeled as a monotone contact-angle +ladder. Applying the derived Young-Dupre estimate gives approximately +`180, 171.6, 168.1, 161.1, 168.1, 168.1, 180 degrees`; the sequence is non-monotone and its exact +`180-degree` endpoints are outside the production contact model's open interval. The Track B angle +policy therefore remains unresolved rather than substituting visual labels for that mapping. + +### Figure 2 CSS status + +The paper caption and companion video require two distinct states: a cube first forms a sphere above +the floor, then the released sphere spreads into a thin symmetric layer. The former `0.085 s`, +non-adhesive workbench endpoint stopped during early impact and was not a valid Figure 2 baseline. +The corrected Akinci case follows the paper's unstated-solid default `beta = gamma = 1` and saves the +thin layer at `0.10 s`; its final `h/w` is `0.1354` and its x/y spread asymmetry is `1.95%`. + +The `0.10 s` panel is a transient comparison frame, not a settled wetting state. At that time the +Akinci/alignment-corrected CSS RMS speeds are still `0.259/0.185 m/s` and both widths are increasing. The paper caption +only says the sphere is dropped, and the companion clip keeps changing the pancake before cutting to +the next experiment; it does not identify an equilibrium frame. The current 30 mm floor is already +almost filled by the 28.5 mm layer, so a true settled-state study would require a larger domain and a +separate long-time acceptance target instead of silently extending this run. + +`cube_to_sphere_css` reuses that corrected 6859-particle geometry and timing. Its viscosity remains +fixed at `alpha = 0.05`; lowering it to the example's `0.01` with shifting gives a distorted +3375-particle sphere and density as low as `570 kg/m^3`. `Sigma = 0.012 N/m` remains the best +calibration candidate. The runner now explicitly uses `ConsistentShiftingSun2019` with +`sound_speed_factor = 0.1` and `FreeSurfaceTangentialShifting()`. + +This treatment was added after the original nominal row was found to retain coherent particle rays +along the coordinate axes and 45-degree directions. The unshifted full-resolution row had angular +bin CV/eightfold/radial-neighbor alignment `0.231/0.0388/0.256`, compared with +`0.134/0.00513/0.0218` for Akinci. Free-surface-aware shifting retains full consistent shifting in +the interior and progressively removes its interface-normal component, leaving tangential shifting +at the represented surface. The corrected full-resolution values are +`0.108/0.00255/0.0472`, and the raw top slice no longer contains coherent axis or diagonal rays. + +The pre-release metric compares the second and fourth radial moments with those of an equal-volume +sphere. Impact acceptance additionally requires a final thin layer (`h/w <= 0.2`), x/y asymmetry at +most `5%`, maximum post-release width error at most `10%`, final aspect-ratio error at most `0.05`, +and height agreement within two CSS particle spacings. Reliability requires at most 25% rejected +steps, timestep tail/head ratio at least `0.5`, density in `900-1020 kg/m^3`, and no particle below +the floor. Particle isotropy additionally compares angular-bin variation, eightfold angular order, +and nearest-neighbor orientation relative to the radial direction. Perturbation and +coarse-resolution rows use relaxed qualitative limits of `h/w <= 0.25`, 10% asymmetry, 20% width +error, and correspondingly relaxed isotropy bounds. + +| Run | Release asphericity | Final `h/w` | Max. width error | Alignment CV / m8 / radial | Density range | Accepted / rejected | Timestep tail/head | Required gate | +|:----|---------------------:|------------:|-----------------:|:---------------------------|:--------------|--------------------:|-------------------:|:--------------| +| Akinci baseline | `0.00902` | `0.1354` | - | `0.134 / 0.005 / 0.022` | diagnostic only | `11459 / 54` | not recorded | accepted baseline | +| Shifted CSS, `sigma = 0.012`, `dx = 0.526 mm` | `0.02295` | `0.1608` | `13.53%` | `0.108 / 0.003 / 0.047` | `978.33-1000.38` | `2253 / 109` | `0.983` | **fail bulk parity** | +| Shifted CSS, `sigma = 0.012`, `dx = 0.667 mm` | `0.02106` | `0.1665` | `14.19%` | `0.161 / 0.007 / 0.050` | `955.58-1000.40` | `1751 / 167` | `1.152` | pass qualitative | + +The full-resolution candidate passes alignment, symmetry, height, density, wall-clearance, +rejection, and timestep gates. It remains unaccepted because its maximum pre-release asphericity +error is `0.03194` (limit `0.025`), release asphericity is `0.02295` (matched limit `0.01902`), and +maximum post-release width error is `13.53%` (limit `10%`). The `+/-20%` coefficient replays were +not repeated after this nominal failure. CSS does not use a wall contact model in this comparison; +the Akinci baseline's paper-default wall adhesion and that model difference remain explicit. Raw +values are in `figure_02_track_b.csv`; the diagnostic below is generated directly from serialized +particle states before surface reconstruction. + +A bounded 3375-particle follow-up tested the remaining mechanistic alternatives without replacing +the nominal evidence row: + +| Probe | Release asphericity | Final `h/w` | Alignment CV / m8 / radial | Density range | Result | +|:------|---------------------:|------------:|:---------------------------|:--------------|:-------| +| `v_max_factor = 1` | `0.01820` | `0.1874` | `0.303 / 0.068 / 0.296` | `988.34-1000.25` | release improves, but all qualitative isotropy gates fail | +| `v_max_factor = 15` | `0.02142` | `0.1667` | `0.138 / 0.003 / 0.069` | `905.28-1000.50` | impact isotropy returns, but bulk shape does not improve | +| `sigma = 0.0144 N/m` | `0.02263` | `0.1682` | `0.111 / 0.010 / 0.084` | `965.13-1000.33` | stronger capillarity worsens release and final shape | +| `alpha = 0.025` | `0.03201` | `0.1522` | `0.171 / 0.005 / 0.083` | `947.70-1000.41` | spreading improves, but the release-shape gate fails | + +The factor-15 probe matches the existing `4 m/s` shifting scale near impact while retaining weaker +shifting during pre-release relaxation. Its failure closes the adaptive-scaling branch rather than +motivating a factor sweep. None of these probes merits a full-resolution replay; Figure 2 remains an +explicit CSS bulk-parity failure and parameter tuning stops here. + +The remaining Tier 1 hydrodynamic choices were tested independently on the same unshifted +3375-particle lattice. Plain Antuono diffusion is stabilizing rather than the source of the lattice +imprint: restricting it to well-supported particles or disabling it collapses the final minimum +density without materially improving shape or radial alignment. + +| Density diffusion | Final `h/w` | Final radial alignment | Final minimum density | +|:------------------|------------:|-----------------------:|----------------------:| +| Antuono, `delta = 0.1` | `0.1865` | `0.408` | `987.36 kg/m^3` | +| Free-surface support gated | `0.1858` | `0.459` | `780.12 kg/m^3` | +| None | `0.1831` | `0.417` | `685.63 kg/m^3` | + +Matched-kinematic-viscosity Morris and Adami operators produce effectively identical bulk +trajectories. Both reduce the final width from `24.562 mm` to `22.666 mm` and increase radial +alignment from `0.408` to `0.470`, so the existing Monaghan viscosity is retained. These bounded +ablations close density diffusion and viscosity as explanations for the Figure 2 discrepancy. + +A Tier 2 operator probe implemented the free-surface core of the C-CSF method of Vergnaud et al. +using its minimum-moment-eigenvalue normal, renormalized curvature, thin-jet angular gate, and +Shepard-corrected surface delta. This first probe excluded the paper's BIM boundary and contact-angle +terms, so the floor was excluded from C-CSF geometry exactly as it was from the no-contact CSS +control. The later row-5 probe below adds those terms. The following results use identical unshifted +lattice, Antuono, Monaghan, coefficient, and resolution settings: + +| Surface operator | Pre-release error | Release asphericity | Final `h/w` | Width error | CV / m8 / radial | +|:-----------------|------------------:|----------------------:|------------:|------------:|:-----------------| +| Existing CSS control | `0.01863` | `0.01139` | `0.1865` | `6.75%` | `0.139 / 0.005 / 0.408` | +| C-CSF free-surface core | `0.00357` | `0.01039` | `0.1977` | `12.34%` | `0.204 / 0.006 / 0.437` | + +C-CSF improves the pre-release moments but worsens post-release spread and every reported final +isotropy measure. Both rows pass reliability and fail qualitative radial alignment. This closes the +unshifted C-CSF branch for Figure 2 without a full-resolution replay; the failure is method-level +evidence, not a reason to tune the published operator thresholds. + +The stabilization and shifting-schedule probes were completed against the same coarse baseline: + +| Probe | Pre-release error | Release asphericity | Final `h/w` | Width error | CV / m8 / radial | Result | +|:------|------------------:|--------------------:|------------:|------------:|:-----------------|:-------| +| Interface TIC, strength `1.0`, unshifted | `0.44004` | - | - | - | - | closed before wall contact: full-strength interior TIC destroys sphere formation; post-contact metrics were invalidated by the runner audit | +| TIC `0.25` + Sun-2017 tangential shifting | `0.02705` | - | - | - | - | closed by the pre-release gate; post-contact metrics were not revalidated | +| Consistent shifting stopped at `0.03 s` | `0.03375` | - | - | - | - | closed by the pre-release gate; post-contact metrics were not revalidated | +| TIC `0.25` + consistent tangential shifting | `0.03913` | `0.02308` | `0.1623` | `9.28%` | `0.124 / 0.007 / 0.059` | qualitative and reliability gates pass; nominal pre-release and radial-alignment gates fail | +| C-CSF + TIC `0.25` + consistent tangential shifting | `0.00847` | `0.00685` | `0.1699` | `12.05%` | `0.139 / 0.001 / 0.048` | formation and release beat the baseline, but width fails and the timestep tail/head collapses to `0.11` | + +The base CSS row remains a qualitative pass, but it is not a one-gate near-miss: pre-release error +exceeds `0.025` and final radial alignment exceeds `0.05`. Because the C-CSF operator's one clear +strength is the formation phase (pre-release `0.00357` unshifted), the interface-aware TIC validation +also accepts `CorrectedCSFSurfaceNormal` with `SurfaceTensionMorris`. That combination confirms a +complementarity instead of a resolution: C-CSF fixes formation, but its width error exceeds `10%` +and its timestep tail/head ratio falls to `0.11`. + +Four additional method rows were then tested on the TIC `0.25` + consistent-shifting base. All +reported kernel probes use `h/dx = 1.5`, hence support radius `R/dx = 3` and an ideal 3D stencil of +approximately 113 particles. The earlier `1.8-2.0` estimate was incorrect because every tested +Wendland kernel has compact support `2h`. + +An implementation audit invalidated the original TIC-based results. The runner passed +`clip_negative_pressure=false` for the fluid equation of state, as required by TIC, but +`trixi_include` also replaced the identically named Adami-wall keyword and silently disabled wall +pressure clipping. The runner now overrides `fluid_clip_negative_pressure` independently, with a +regression test pinning fluid clipping off and boundary clipping on. The audit also corrected the +C-CSF boundary moment orientation and analytical wall-overlap factor, and separated smoothed CSS +stress normals from the raw normals used by particle shifting. The following table contains fresh +runs against a regenerated 3375-particle baseline; all earlier TIC-based numbers are superseded. + +| Row | Method | Pre-release error | Release asphericity | Final `h/w` | Width error | Density range | Outcome | +|---:|:-------|------------------:|--------------------:|------------:|------------:|:--------------|:--------| +| 2a | Wendland C4, `h/dx = 1.5` | `0.03140` | `0.02062` | `0.1647` | `9.91%` | `999.41-1000.39` | pre-release and radial alignment (`0.072`) fail; qualitative and reliability gates pass | +| 2b | Wendland C6, `h/dx = 1.5` | `0.03656` | `0.01759` | `0.1669` | `9.92%` | `999.45-1000.41` | every gate except pre-release passes | +| 4 | CSS + neutral `90` degree floor contact | `0.03913` | `0.02308` | `0.1705` | `9.07%` | `999.74-1000.66` | every gate except pre-release passes; contact cannot fix formation | +| 5 | C-CSF geometry BIM + `90` degree contact | `0.00847` | `0.00942` | `0.1856` | `10.12%` | `987.60-1001.85` | width and timestep tail/head (`0.20`) fail; radial alignment passes | +| 6 | One-pass Shepard-smoothed CSS normals | `0.04389` | `0.02110` | `0.1683` | `6.08%` | `998.50-1000.73` | every gate except pre-release passes; smoothing improves spread and radial alignment | + +Rows 2, 4, 5, and 6 are closed without full-resolution replay. C6, neutral contact, and normal +smoothing are each reliable one-gate near-misses, with C6 giving the smallest pre-release miss. +The row-5 implementation contains the paper's planar boundary terms for C-CSF geometry but retains +Adami dummy-particle continuity and momentum coupling; it is not the paper's complete hydrodynamic +BIM formulation. Its corrected result therefore closes this mixed configuration, not full BIM. + +A final staged probe combined the complementary rows: free-surface C-CSF forms the drop, then +one-pass Shepard-smoothed CSS handles impact. Switching at release passes every coarse gate, but its +reference-resolution replay under-spreads by `11.17%`, just outside the `10%` width gate. Delaying +the switch to impact onset (`t = 0.065 s`, when the lowest particle center is approximately `0.86 dx` +above the floor at both resolutions) reduces that width miss but degrades radial alignment. Both +variants remain reliable and pass the qualitative gates: + +| Switch | Particles | Pre-release error | Release asphericity | Final `h/w` | Width error | Final radial alignment | Timestep tail/head | Outcome | +|:-------|----------:|------------------:|--------------------:|------------:|------------:|-----------------------:|-------------------:|:--------| +| Release, `0.050 s` | 3375 | `0.00847` | `0.00685` | `0.1776` | `9.16%` | `0.0476` | `2.85` | all nominal gates pass | +| Release, `0.050 s` | 6859 | `0.01033` | `0.01195` | `0.1841` | `11.17%` | `0.0461` | `2.13` | width fails | +| Impact onset, `0.065 s` | 3375 | `0.00847` | `0.00685` | `0.1783` | `6.05%` | `0.0512` | `2.90` | radial alignment fails | +| Impact onset, `0.065 s` | 6859 | `0.01033` | `0.01195` | `0.1838` | `10.34%` | `0.1198` | `2.00` | width and radial alignment fail | + +The signed release-switch width error changes from `+9.59%` at `0.075 s` to `-11.17%` at +`0.100 s` at reference resolution, so the evidence does not support a uniform increase or decrease +in spreading force. +An exploratory unsaved-time switch at `0.060 s` also failed qualitatively rather than interpolating +between the saved-time results. The phase-switch branch is therefore closed without further timing +or coefficient fitting. + +#### Figure 2 open-method ledger + +Ticking-off protocol: one row at a time against the frozen gates, identical 3375-particle coarse +A/B versus the regenerated Akinci baseline, one mechanism change per row, no coefficient sweeps. +A row is closed by a failed comparison or an explicit scope decision; a row is promoted to a +full-resolution replay only if the coarse comparison passes the nominal gates or isolates a clear +mechanistic improvement. + +| # | Candidate | Targets | Availability | Status | +|--:|:----------|:--------|:-------------|:-------| +| 1 | C-CSF geometry + TIC `0.25` + consistent tangential shifting | pre-release gate of the near-miss row | configuration (TIC validation extended) | closed: fixes formation (`0.00847`) but fails width (`12.05%`) and timestep tail/head (`0.11`) | +| 2 | Larger kernel support (`WendlandC4/C6`, `h/dx = 1.5`, approximately 113 neighbors) | pre-release curvature accuracy, lattice imprint | runner supports C2/C4/C6 and explicit `h/dx` | closed: C4 reaches `0.03140` but also fails radial alignment; C6 reaches `0.03656` and otherwise passes | +| 3 | EDAC pressure evolution instead of the Cole EOS | formation-phase pressure noise | small runner/example change | open | +| 4 | Explicit floor contact for CSS (`WettedAreaContactAngle`, neutral `90` degrees) | post-release spread parity versus the baseline's default wall adhesion | configuration (`contact_angle` knob) | closed: every gate except unchanged pre-release (`0.03913`) passes | +| 5 | C-CSF boundary-integral floor geometry (`lambda_j = 1` faces, contact-angle correction) | operator consistency near the floor | planar geometry BIM with boundary quadrature; Adami hydrodynamics retained | closed for the mixed formulation: width `10.12%` and timestep tail/head `0.20` fail | +| 6 | Shepard-smoothed normals before the CSS stress divergence | width/isotropy noise | one-pass activity-weighted Shepard option | closed: every gate except pre-release (`0.04389`) passes; no integration failure after the runner fix | +| 7 | Reproducing-divergence CSF (Adami et al. 2010) | curvature-free alternative stress | new implementation, moderate | open | +| 8 | Momentum-consistent ALE shifting (Oger et al. 2016) | shape parity under shifting | new implementation, large | open | +| 9 | Riemann-based WCSPH scheme (Parshikov; the C-CSF paper's scheme) | pressure-field regularity | new implementation, large | deferred | +| 10 | Physical pairwise-force cohesion (`SurfaceTensionAkinciCohesionPhysical`) as the CSS replacement | bulk shape via pairwise forces | configuration | low priority: Akinci-family model with published interface-pressure artifacts | +| 11 | C-CSF formation followed by smoothed-CSS impact | combine the formation and spreading strengths of rows 1 and 6 | staged runner | closed: release switch passes coarse but misses reference width (`11.17%`); impact-onset switch misses reference width (`10.34%`) and radial alignment (`0.120`) | +| - | Shifting-scale, `sigma`, and `alpha` parameter probes | - | - | closed, table above | +| - | Jittered/packed initial conditions; Sun-2017 shifting with jitter | lattice imprint | - | closed, resolution-dependent or asymmetric | +| - | Density-diffusion gating or removal | lattice imprint, density floor | - | closed, diffusion is stabilizing | +| - | Morris/Adami viscosity operators | spreading, isotropy | - | closed, no material change | +| - | C-CSF free-surface core, unshifted | pre-release shape | - | closed, spread and isotropy regress | +| - | Interior TIC at full strength; TIC with Sun-2017 shifting; staged shifting stop | stability schedule | - | closed, table above | + +![Figure 2 CSS/Akinci raw-particle comparison](figure_02_css_akinci_diagnostic.png) + +Reproduce the nominal rows and evidence with: + +```bash +JULIA_NUM_THREADS=8 julia +release --project=compare_akinci/simulation \ + compare_akinci/simulate.jl cube_to_sphere /tmp/cube_akinci.jls +JULIA_NUM_THREADS=8 julia +release --project=compare_akinci/simulation \ + compare_akinci/simulate.jl cube_to_sphere_css /tmp/cube_css.jls +JULIA_NUM_THREADS=8 julia +release --project=compare_akinci/simulation \ + compare_akinci/figure_02_metrics.jl compare /tmp/cube_akinci.jls \ + compare_akinci/figure_02_track_b.csv /tmp/cube_css.jls +JULIA_NUM_THREADS=8 julia +release --project=compare_akinci/simulation \ + compare_akinci/figure_02_surface_switch.jl /tmp/cube_surface_switch.jls \ + 0.0006666666666666666 0.05 +JULIA_NUM_THREADS=8 julia +release --project=compare_akinci \ + compare_akinci/figure_02_diagnostics.jl /tmp/cube_akinci.jls /tmp/cube_css.jls \ + compare_akinci/figure_02_css_akinci_diagnostic.png +``` + +### D4: heterogeneous wall adhesion + +Figures 9 and 10 are outside the current CSS contact scope. `WettedAreaContactAngle` stores one +contact angle on the fluid, applies it to every admitted boundary, and accepts only explicit, +connected disk-like surface patches. It cannot represent the adhesive-box/non-adhesive-blade split +in Figure 9 or the distinct plane and rigid-figure wettability in Figure 10. Pairing CSS with +`SurfaceTensionAkinciCohesionPhysical` would require an unimplemented composite surface model and +would no longer be a CSS-only comparison. D4 therefore records an explicit unsupported-mechanism +cause instead of adding a misleading runner or expanding the production API during Track B. + +## Figure 8 Investigation + +The companion video shows one continuously evolving droplet with coefficient sequence +`(gamma, beta) = (1, 0), (1, 0.05), (1, 0.1), (1, 0.25), (0.1, 0.01), (0.01, 0.001), +(0.001, 0)`. It does not report the physical duration of each stage. Independent simulations at an +arbitrary common final time therefore are diagnostics, not accepted reproductions of its panels. + +The per-particle analysis verifies the implemented Akinci forces independently of the production +right-hand side. Reconstructed density and normals agree to roundoff, and the decomposed pressure, +viscosity, cohesion, curvature, adhesion, and gravity terms reproduce the production acceleration +to about `1e-12 m/s^2`. A zero-gravity no-wetting sphere also retains its shape. Adding wall normals, +increasing sound speed, pre-relaxing the sphere, aligning its contact point, and replacing Adami +pressure extrapolation with pressure mirroring do not prevent the gravity-driven collapse. + +The normal formula and support-radius scaling match Equation 2 and the current SPlisHSPlasH +implementation. Both references sum fluid neighbors only, so the Akinci default now disables the +optional wall-normal augmentation. On the initial 739-particle voxel sphere, the 21 wall-contact +normals have a median radial-direction error of `18.8 degrees`; adding wall normals increases it to +`21.2 degrees`. Current-density volume weights also produce small outward normals one layer inside +the free surface because low-density surface neighbors are over-weighted. A diagnostic reconstruction +with initial-density volume weights removes these reversed interior normals and lowers the +contact-normal error to `13.0 degrees`, but a trial using those weights only changes the +no-wetting result at +`t = 0.05 s` from `h/w = 0.162` to `h/w = 0.167`. The alternative is not retained as a production +method because it does not materially improve the result and differs from the published formula. +The normal defect is therefore measurable but is not the primary cause of the collapse. + +Kernel correction and normal smoothing were also tested. Applying the normalized +`KernelCorrection` gradient directly is not valid for this model: by construction it enforces +`sum(V_b * grad(W_corrected_ab)) = 0`, which would erase the constant-color free-surface signal. +A first-order moment correction improves the initial median surface-normal error only from +`17.5 degrees` to `15.3 degrees`, doubles its median magnitude from `1.27` to `2.41`, and leaves +the reversed interior directions unchanged. A full WCSPH run with `GradientCorrection()` gives +`h/w = 0.160` at `t = 0.05 s`, indistinguishable from the `0.162` baseline. + +One-pass Shepard smoothing with the original normal magnitude restored improves the static +surface and wall-contact direction errors to `8.8 degrees` and `9.7 degrees`, respectively. +Despite the cleaner field, the no-wetting simulation becomes slightly flatter (`h/w = 0.151` at +`t = 0.05 s`). Neither correction nor smoothing is retained as a production method; their exact +offline reconstructions remain in `force_analysis.jl`. + +Increasing cubic-spline support from the reported `2 delta_x` to `2.4 delta_x` reduces the static +surface/contact direction errors to `11.5 degrees`/`10.5 degrees`, but only improves the dynamic +shape to `h/w = 0.187` at `t = 0.05 s`. A `3 delta_x` support improves the static directions further +while increasing spurious interior-normal magnitude; both supports change the published model. +Switching to a Wendland C2 kernel at the same `2 delta_x` support does not improve angular error and +changes the discrete rest-density normalization substantially. The reported cubic kernel and support +are therefore retained. + +The failure originates in the WCSPH wall-contact response. Initially, only 21 of 739 fluid particles +have wall neighbors, and their particle-summed upward boundary-pressure acceleration is about +`10 m/s^2`, compared with about `7250 m/s^2` of particle-summed weight. The droplet consequently +spreads until 303 particles contact the wall. At `t = 0.6 s`, the no-wetting case has settled to +`19.619 x 3.660 mm` (`h/w = 0.1866`), and its mean upward boundary-pressure acceleration is +`9.735 m/s^2`, approximately balancing gravity. Pressure mirroring settles to an even wider +`50.605 x 5.007 mm` state and permits wall penetration. + +Scalar pressure support alone does not fix the contact response. An Adami pressure offset of about +`9.6 kPa` balances the initial particle-summed weight, but the no-wetting droplet still reaches +`h/w = 0.163` at `t = 0.05 s`. Increasing the Monaghan artificial-viscosity coefficient from `0.01` +to `1.0` damps the contact transient and gives `h/w = 0.627` at `t = 0.05 s`, but the state continues +to flatten to `h/w = 0.410` at `t = 0.2 s`. Combining the pressure offset with this damping is worse +(`h/w = 0.362` at `t = 0.2 s`). + +Akinci boundary volumes and Adami extensions were investigated in six controlled steps. Number-density +volume correction multiplies the exposed mass by `1.176` for the three-layer plate and by `1.427` for +a single-layer plate. With three layers, it raises initial particle-summed wall support from +`10.1 m/s^2` to `11.9 m/s^2` and contact-particle median support from `0.471 m/s^2` to +`0.554 m/s^2`. The correction is internally consistent but remains far below the particle-summed +weight of about `7250 m/s^2`. + +The corrected three-layer result reaches `h/w = 0.1647` at `t = 0.05 s` and `0.1262` at +`t = 0.2 s`, compared with `0.1616` and `0.1195` for uniform masses. A corrected single-layer plate +gives `0.1578` and `0.1246`, so layer count is not the missing mechanism. Adding the spherical +Laplace pressure (`322 Pa`) as an Adami pressure offset gives `h/w = 0.1643` at `t = 0.05 s`. + +A kernel-weighted affine pressure reconstruction was also prototyped. Weak regularization delays +spreading to `h/w = 0.586` at `t = 0.025 s`, but becomes unstable and ejects particles by +`t = 0.05 s`. Strong regularization is stable but returns to `h/w = 0.1602`, while being much more +expensive than zeroth-order Adami. The experimental implementation was removed. Standard Adami with +three corrected boundary layers remains the best tested dummy-particle boundary, but the improvement +is too small to accept Figure 8. Repulsive Monaghan-Kajtar boundaries and a switch to an +incompressible solver are not planned. + +The fixed-particle WCSPH pressure operator was then assembled explicitly, including the linearized +Adami dependence of every boundary pressure on neighboring fluid pressures. A nonnegative +least-squares solve reduces acceleration-magnitude RMS from `42.30` to `10.51 m/s^2` with summation +density, but leaves mean vertical acceleration at `-9.685 m/s^2`. With continuity density, the +corresponding values are `43.50`, `10.30`, and `-9.634 m/s^2`. The unconstrained solutions are already +entirely positive and give the same residual, so pressure initialization cannot produce a static +compact state with the current wall-force quadrature. + +An mDBC-style pressure prototype moved the pressure reconstruction point from the exposed boundary +layer to its reflection inside the fluid support. It changes the continuity-density mean vertical +residual only from `-9.63413` to `-9.63399 m/s^2`. Reflected pressure interpolation therefore does not +address the limiting geometry: pressure reaction is still applied through the same small set of +fluid-boundary pairs. A dynamic pressure-initialization run was intentionally skipped because the +discrete operator has no corresponding static equilibrium. + +Replacing the discrete dummy-particle pressure sum by an exact planar half-space integral of the +cubic kernel also fails the equilibrium gate. With summation density it changes residual RMS from +`10.507` to `10.440 m/s^2` and mean vertical residual from `-9.685` to `-9.566 m/s^2`. With continuity +density the corresponding changes are `10.301` to `10.243 m/s^2` and `-9.634` to +`-9.466 m/s^2`. This modest improvement does not justify a dynamic semi-analytical implementation. + +Finally, the inter-particle averaged pressure acceleration used by Adami et al. (2012) was tested +with standard Adami extrapolation and corrected boundary volumes. It gives `h/w = 0.1625` at +`t = 0.05 s`, compared with `0.1647` for the default summation-density pressure operator. Thus the +remaining failure is not the choice between the two available pressure-force formulas. + +A sparse continuity-density/semi-analytical operator study then increased fluid resolution by a +factor of eight: + +| Target particles | Actual particles | Wall-supported particles | Residual RMS | Mean vertical residual | +|-----------------:|-----------------:|-------------------------:|-------------:|-----------------------:| +| 750 | 739 | 21 | `10.2428 m/s^2` | `-9.4665 m/s^2` | +| 3000 | 2969 | 49 | `10.1978 m/s^2` | `-9.7728 m/s^2` | +| 6000 | 6031 | 110 | `10.1877 m/s^2` | `-9.6979 m/s^2` | + +The residual plateaus near gravity even though wall support covers over five times as many +particles. None reaches the `1 m/s^2` dynamic-run gate, so no high-resolution integration was +started. Coarse contact sampling is not the primary failure over this range. + +## Modern WCSPH Variants + +Continuity-density and delta-SPH variants improve density quality but remain flat with the reported +cubic kernel. At `t = 0.05 s`, continuity density gives `h/w = 0.1844` while its free-surface density +falls to `655 kg/m^3`. Molteni-Colagrossi, Ferrari, and Antuono diffusion give `0.1793`, `0.1825`, and +`0.1827`, respectively, while keeping density close to `1000 kg/m^3`. + +Using Antuono diffusion with a larger-support Wendland kernel is substantially better: + +| Kernel and WCSPH settings | `h/w` at `0.05 s` | +|---------------------------|-------------------:| +| Wendland C2, `h = 1.3 delta_x`, `c0 = 30 m/s` | 0.2071 | +| Wendland C2, `h = 1.4 delta_x`, `c0 = 30 m/s` | 0.2314 | +| Wendland C4, `h = 1.3 delta_x`, `c0 = 30 m/s` | 0.2003 | +| Wendland C2, `h = 1.3 delta_x`, `c0 = 100 m/s` | 0.2295 | +| Wendland C2, `h = 1.4 delta_x`, `c0 = 100 m/s` | 0.2592 | + +The best combination uses continuity density, Antuono diffusion with `delta = 0.1`, Wendland C2 with +`h = 1.4 delta_x`, `c0 = 100 m/s`, corrected boundary volumes, and standard Adami extrapolation. It +rebounds after its initial compression and settles by `t = 0.6 s` to `16.305 x 7.233 mm`, +`h/w = 0.4436`, with RMS speed `0.0015 m/s` and density in `1000.00-1000.01 kg/m^3`. This is a genuine +compact WCSPH equilibrium, but the companion video's no-wetting droplet remains much closer to a +sphere. + +A diffusion-strength sweep confirms that diffusion is required but is not the cause of the compact +state: + +| Antuono `delta` | `h/w` at `0.2 s` | Minimum density at `0.2 s` | +|----------------:|-----------------:|---------------------------:| +| none | 0.4432 | `890.23 kg/m^3` | +| 0.01 | 0.4700 | `999.99 kg/m^3` | +| 0.03 | 0.4607 | `999.98 kg/m^3` | +| 0.05 | 0.4569 | `1000.00 kg/m^3` | +| 0.1 | 0.4752 | `1000.00 kg/m^3` | + +The apparent differences at `0.2 s` are transient. At `0.6 s`, `delta = 0.01` and `0.1` converge to +`h/w = 0.4449` and `0.4436`, respectively. The larger diffusion coefficient is retained for the full +wetting sequence because it better controls density when low surface tension stretches the drop into +a thin film. + +Three free-surface and Akinci-specific modifications were rejected. Tapering Antuono diffusion to +zero as the kernel-summation density falls from `0.9 rho_0` to `0.6 rho_0` lowers `h/w` from `0.4700` +to `0.4415`. Using the paper's cubic `2 delta_x` support for Akinci normals, cohesion, and adhesion +while retaining the `2.8 delta_x` Wendland WCSPH support lowers it to `0.1748`; combining both changes +gives `0.1797`. The planar dimensionless normal is `1.4` for the cubic kernel and `1.5` for Wendland +C2, so the non-fitted curvature correction is `14/15`. It also worsens the result to `0.4586`. +Sensitivity factors of `0.5` and `1.5` give `0.3381` and `0.4818`, showing that an arbitrary curvature +retuning cannot explain the missing near-spherical state. + +All seven companion-video coefficient pairs were then run independently to `0.2 s` with the selected +WCSPH configuration: + +| Regime | `gamma` | `beta` | Width | Height | `h/w` | Minimum / median density | +|:-------|--------:|-------:|------:|-------:|------:|-------------------------:| +| No wetting | 1 | 0 | `16.091 mm` | `7.646 mm` | 0.4752 | `1000.0 / 1000.0` | +| Weak wetting | 1 | 0.05 | `16.196 mm` | `7.605 mm` | 0.4696 | `1000.0 / 1000.0` | +| Moderate wetting | 1 | 0.1 | `16.247 mm` | `7.561 mm` | 0.4654 | `1000.0 / 1000.0` | +| Intermediate wetting | 1 | 0.25 | `16.297 mm` | `7.479 mm` | 0.4589 | `1000.0 / 1000.0` | +| Strong wetting | 0.1 | 0.01 | `31.627 mm` | `3.555 mm` | 0.1124 | `1000.0 / 1000.0` | +| Near-perfect wetting | 0.01 | 0.001 | `42.592 mm` | `2.715 mm` | 0.0637 | `697.9 / 969.4` | +| Perfect wetting | 0.001 | 0 | `45.236 mm` | `1.821 mm` | 0.0402 | `669.9 / 942.4` | + +No fluid particle crosses the wall in any of these runs. The ordering is monotone apart from small +transient variations, but the first four regimes differ by only 3.4% in `h/w`, and the no-wetting +state is still too flat. The final two films are only about one particle layer thick, so their low +density is an under-resolution limit that additional density diffusion cannot remove. These are +therefore useful WCSPH diagnostics, not accepted Figure 8 reproductions. + +![Seven-stage WCSPH wetting diagnostic](figure_08_wcsph_seven_stage_diagnostic.png) + +![Classic and delta-SPH no-wetting equilibria](figure_08_wcsph_method_diagnostic.png) + +## Akinci Modification Study + +The continuum work integrals clarify why the published coefficients provide little separation +between the first four regimes. For compact-support radius `H`, the three-dimensional cohesion +kernel satisfies + +```text +integral(r^4 C(r, H), r=0..H) = 21 H^2 / (880 pi), +sigma_coh = (21 / 7040) gamma rho_0^2 H^2. +``` + +The corresponding planar work integral of the published adhesion kernel is only +`I_A / I_C = 0.107437`. If both forces were the only sources of interfacial energy, the +Young-Dupre estimate would be `cos(theta) = 2 (beta/gamma) I_A/I_C - 1`. The paper's first four +ratios then correspond to `180.0`, `171.6`, `168.1`, and `161.1 degrees`, rather than a sequence +spanning no wetting to `90 degrees`. With these distinct kernels, even a cohesion-only +`90-degree` state would require `beta/gamma = 4.654`. + +These continuum expressions do not directly calibrate the 739-particle discretization. At the +selected `H = 2.8 delta_x`, the virial predicts `0.02833 N/m` for cohesion coefficient one. A +three-radius zero-gravity fit to `delta p = p_bulk + 2 sigma/R` instead measures: + +| Model and nominal coefficient | Fitted `sigma` | Bulk prestress | Fit RMS | +|:------------------------------|---------------:|---------------:|--------:| +| Full Akinci, `gamma = 1` | `0.19048 N/m` | `53.58 Pa` | `2.81 Pa` | +| Cohesion only, `gamma = 1` | `0.01669 N/m` | `74.21 Pa` | `0.37 Pa` | +| Distributed Morris, nominal `sigma = 1` | `0.00119 N/m` | `0.35 Pa` | `0.01 Pa` | +| Corrected Morris CSF, `sigma = 1 N/m` | `1.27036 N/m` | `-48.79 Pa` | `0.76 Pa` | +| CSS, `sigma = 1 N/m` | `1.49708 N/m` | `-132.25 Pa` | `0.20 Pa` | + +Thus the normal-difference term supplies about 91% of the measured full-Akinci tension in this +configuration. The cohesion virial overpredicts the discrete cohesion result by 70%, and a single +drop's pressure jump cannot be used because it includes substantial lattice bulk prestress. Before +the Phase 1 correction, production Morris could not finish this radius-series gate in five minutes: +its dimensionally incomplete local force was repeated once per fluid neighbor and discontinuous +curvature masks drove the adaptive time step toward zero. Corrected Morris now evaluates +`-sigma kappa delta_s n/rho` once per particle and finishes the three radii in 137 s; CSS finishes +in 119 s. Both dynamic slopes still overpredict the static coefficient and remain Phase 2 +convergence targets, not calibration guarantees. + +An instantaneous volume-preserving ellipsoid probe independently checks linear response and +conservation, but is not used as the physical calibration because the initial lattice is not a +prestressed spherical equilibrium. The previously recorded production-Morris value (`0.114640` +per nominal coefficient) used the invalid neighbor-repeated force and is retained only as a +historical baseline. The large difference between transient stiffness and radius-series slopes is +another reason to require the Phase 2 convergence tests before recommending a model. + +Direct full-Akinci scaling is effective for the video target: + +| `gamma` | `h/w` at `0.05 s` | `h/w` at `0.2 s` | Steps to `0.2 s` | Status | +|--------:|--------------------:|-------------------:|-------------------:|:-------| +| `2.6` | 0.4788 | not run | - | insufficient | +| `5.3` | 0.7451 | 0.7533 | 10,421 | stable, no penetration | +| `8.8` | 0.8837 | 0.8191 | 21,605 | closest video match, no penetration | + +At `0.2 s`, the `gamma = 5.3` and `8.8` density ranges are `1000.01-1000.07` and +`1000.02-1000.12 kg/m^3`, with RMS speeds `0.0070` and `0.0051 m/s`. The higher coefficient is +expensive: its accepted time step falls to about `3.5e-6 s`. It must therefore receive an explicit +surface-force time-step bound before promotion to a fixed-step workflow. + +The other modifications do not improve on this result. At a matched transient restoring stiffness +and `t = 0.02 s`, continuum-normalized cohesion, production Morris, and distributed Morris give +`h/w = 0.7581`, `0.8416`, and `0.8292`, but have RMS speeds `0.1437-0.1629 m/s` and Morris time-step +spikes down to `1e-8 s`. Momentum Morris spreads to `42.1 mm`, sends 31 particles through the wall, +and lowers density to `356 kg/m^3`. + +Replacing adhesion by the cohesion kernel gives a stable monotone response only at modest wall +coefficients. Preliminary energy-mapped coefficients `0.654`, `2.440`, and `4.881` produce +`h/w = 0.8728`, `0.8620`, and `0.7981` at `0.05 s`, with no penetration. However, the calibrated +normal-difference energy raises the estimated `90-degree` wall coefficient to about `50`. Direct +coefficients `10`, `25`, and `50` give `h/w = 0.8588`, `0.6286`, and `0.5606` at `0.02 s`; the latter +two already send 4 and 48 particles through the plate. Young-Dupre wall attraction is therefore not +compatible with the current dummy-particle pressure support at the required scale. + +A tangential-only CSF contact-line prototype avoids penetration. At `0.02 s`, regularized strengths +`0.0168`, `0.1676`, and `0.5 N/m` give `h/w = 0.8132`, `0.8115`, and `0.8270`, compared with the +`0.8119` no-contact-force result. The measured full-scale value `1.676 N/m` requires steps as small +as `1e-7 s`. It is therefore ineffective when regularized and prohibitively stiff at physical scale. + +A common `1/H^2` rescaling of both terms in the complete Akinci model improves its high-resolution +behavior but does not make it resolution-independent. With the 739-particle result as reference, +three-radius fits at 739, 1503, approximately 3000, and approximately 6000 particles give +`0.19048`, `0.14718`, `0.12784`, and `0.10053 N/m`, respectively. The normal-difference term has a +large finite-curvature contribution that cannot be corrected by the cohesion kernel's planar +scaling. + +The viable alternative is a dimensionally normalized, central-force-only model. For a requested +physical surface tension `sigma`, rest density `rho_0`, and compact-support radius `H`, it evaluates +the original cohesion kernel with + +```text +gamma(H) = sigma / ((21 / 7040) rho_0^2 H^2). +``` + +`SurfaceTensionAkinciCohesionPhysical` implements this conversion in production as an opt-in 3D +model. It needs no surface normals and retains the pair force's exact linear- and angular-momentum +conservation. A wall's existing `adhesion_coefficient` is interpreted as a dimensionless multiplier +of the same cohesion kernel. Young-Dupre gives +`adhesion_coefficient = (1 + cosd(theta)) / 2`, allowing a contact angle to be specified without a +resolution-dependent wall coefficient. The standard capillary stability limit is included in +automatic time-step selection. + +An exact lattice-bond energy calculation checks the normalization without pressure or curvature +fitting: + +| Target particles | Actual particles | Surface-energy moment | +|-----------------:|-----------------:|----------------------:| +| 375 | 389 | `0.00294859` | +| 750 | 739 | `0.00287728` | +| 1500 | 1503 | `0.00296035` | +| 3000 | 2969 | `0.00292161` | +| 6000 | 6031 | `0.00293720` | +| 12000 | 11981 | `0.00295482` | + +The moments span only 2.8% over a 31-fold particle-count range and all lie within 3.6% of the +continuum value `21 / 7040 = 0.00298295`. The exact infinite planar cubic-lattice moment at +`H/delta_x = 2.8` is `0.00264264`; the finite spherical samples converge near the continuum value +as `R/H` increases. + +With `sigma = 5 N/m`, the dynamic no-wetting shape is also stable across the practical resolution +range: + +| Target / actual particles | `h/w` at `0.05 s` | `h/w` at `0.2 s` | Density at `0.2 s` | RMS speed at `0.2 s` | +|--------------------------:|-------------------:|------------------:|---------------------:|---------------------:| +| 375 / 389 | 0.8303 | 0.8650 | `1001.12-1001.32 kg/m^3` | `0.0141 m/s` | +| 750 / 739 | 0.8884 | 0.8696 | `1001.38-1001.60 kg/m^3` | `0.0085 m/s` | +| 1500 / 1503 | 0.8504 | 0.8410 | `1001.69-1002.00 kg/m^3` | `0.0067 m/s` | + +The settled `h/w` spread is 3.4%, and no particle crosses the wall. At 739 particles, the +same-kernel wall model gives a monotone response by `t = 0.1 s`: + +| Target angle | Wall ratio | Width | Height | `h/w` | Minimum `z` | Near-wall particles | RMS speed | +|-------------:|-----------:|------:|-------:|------:|------------:|--------------------:|----------:| +| 150 degrees | 0.06699 | `12.740 mm` | `11.339 mm` | 0.8900 | `1.914 mm` | 0 | `0.0142 m/s` | +| 120 degrees | 0.25 | `12.925 mm` | `11.080 mm` | 0.8573 | `1.823 mm` | 0 | `0.0076 m/s` | +| 90 degrees | 0.5 | `12.940 mm` | `11.053 mm` | 0.8542 | `1.520 mm` | 15 | `0.0096 m/s` | +| 60 degrees | 0.75 | `12.968 mm` | `10.729 mm` | 0.8273 | `0.988 mm` | 54 | `0.0193 m/s` | + +All four density ranges remain within `1001.25-1001.70 kg/m^3`, and none penetrates the plate. +The angle labels specify the continuum wall-energy target; these short, coarse runs do not yet +constitute measured equilibrium contact-angle validation. Only the 90- and 60-degree cases have +reached the near-wall threshold `z < 1.5 delta_x` by `0.1 s`. + +![Physical-cohesion wetting diagnostic](figure_08_physical_wetting_diagnostic.png) + +Targets below 60 degrees are not resolved safely with 739 particles. The 30- and 0-degree runs +spread into one- to three-particle-thick films and cross the nominal plate plane: + +| Target angle | Wall ratio | Width | Height | `h/w` | Minimum `z` | Particles below plane | RMS speed | +|-------------:|-----------:|------:|-------:|------:|------------:|----------------------:|----------:| +| 30 degrees | 0.93301 | `28.645 mm` | `3.302 mm` | 0.1153 | `-0.092 mm` | 5 | `0.1048 m/s` | +| 0 degrees | 1.0 | `31.762 mm` | `2.794 mm` | 0.0880 | `-0.197 mm` | 15 | `0.0159 m/s` | + +These are failure-limit diagnostics, not accepted wetting results. Particles below the plane are +highlighted in red. + +![Near-complete wetting resolution limit](figure_08_physical_near_complete_wetting_diagnostic.png) + +The video-matching value `5 N/m` is deliberately nonphysical for water: it is about 69 times the +room-temperature value. These runs also retain `AkinciFreeSurfaceCorrection`, which multiplies the +central pair force by the local symmetric density correction. The input `sigma` is therefore the +continuum normalization of the underlying cohesion potential, not a claim that a coarse corrected +drop has already reproduced that value in an independent Laplace-pressure fit. + +### Balanced continuum surface stress + +The scientific follow-up replaces Akinci cohesion with the conservative stress divergence in +`SurfaceTensionMomentumMorris`. The corrected implementation preserves the unnormalized +color-gradient magnitude as the surface delta, applies the one-sided free-surface factor, and uses +a symmetric scalar reproducing correction accumulated during the existing normal pass. It stores no +stress tensor and performs no global reduction or additional neighbor traversal. + +A static discrete balance compares the CSS acceleration directly with the WCSPH acceleration from +a unit uniform pressure on exactly the same particles. For an input `sigma = 1 N/m`: + +| Particles | Pressure-fit `sigma` | Virial `sigma` | Estimated/analytic area | Total force | +|----------:|---------------------:|----------------:|------------------------:|------------:| +| 389 | `1.0333 N/m` | `0.8323 N/m` | 0.8397 | `4.9e-18 N` | +| 739 | `1.0026 N/m` | `0.8876 N/m` | 0.8797 | `1.7e-18 N` | +| 1503 | `0.9970 N/m` | `0.9101 N/m` | 0.9064 | `3.8e-17 N` | +| 2969 | `0.9557 N/m` | `0.9385 N/m` | 0.9253 | `2.6e-17 N` | +| 6031 | `1.0079 N/m` | `0.9599 N/m` | 0.9452 | `2.1e-17 N` | + +The pressure-fit value stays within 4.5% across the range, the energy virial converges toward the +requested coefficient, and total capillary force remains at roundoff. A dynamically relaxed coarse +drop still overpredicts the Laplace pressure: the inferred values at 389, 739, and 1503 particles are +`1.50`, `1.36`, and `1.16 N/m`, respectively. This error decreases under refinement and is now a +documented resolution error rather than a coefficient-unit ambiguity. + +Two explicit experimental contact-angle models were evaluated without an attractive wall force: +geometric normal rotation and a colorfield-localized tangential contact-line force. Phase 2 tested +target-initialized zero-gravity caps at five +angles, three resolutions, and both mechanisms. All 30 cells passed the 5-degree local-angle, +density, settlement, and penetration gates. Aggregate evidence was close: + +| Metric | Geometric | Contact-line force | +|---|---:|---:| +| Local-angle MAE at 750 / 1500 / 3000 particles | `1.246 / 1.229 / 0.748 deg` | `1.327 / 0.948 / 0.838 deg` | +| Maximum error over all cells | `2.862 deg` | `3.175 deg` | +| 90-degree threshold/damping span | `0.154 deg` | `0.135 deg` | +| Median runtime overhead over no contact model | `2.4%` | `17.3%` | + +Those runs establish overdamped equilibrium preservation, not restoring behavior. Phase 3 therefore +started caps away from their requested angle at `(target, initial) = (60, 90)`, `(90, 60)`, +`(90, 120)`, and `(120, 90)` degrees and subtracted a matched no-contact control. Geometric rotation +passed the complete restoring gate in one of four cases; CLF passed two of four. Their correctly +directed initial contact-induced accelerations covered two and three cases, respectively. In the CLF +high-angle case, the colorfield normal reported a contact-line angle near 89 degrees for a cap whose +local-circle angle was 118 degrees, so the force was misdirected. Uniformly longer exploratory runs +confirmed that this was not just a 10 ms observation-window artifact. + +Neither mechanism was promoted. Both unshipped implementations were removed after the replacement +passed production replay. Their raw evidence and the deterministic scorecard remain under +`validation/surface_tension_3d/contact_angle_*.csv`. + +Recovery diagnostics reject the obvious local fixes. Subtracting the dummy-wall gradient produces +up to 58.5-degree angle error, while a gradient-consistent geometric ghost variant still gives only +two of four correct fixed-particle signs. A target-only Young wall-energy force is the leading +fallback and reaches four of four signs with the expected one-phase factor, but the current and +coarea contact-line measures miss physical line length by more than the accepted tolerance and do +not converge uniformly on spherical caps. A ten-kernel planar study derives the coarea factor from +the implemented gradient integral and passes all 50 middle-resolution cases, but only 40 strict +endpoint gates; the production-style divergence form passes 9/50. The planar Wendland C2 factor and +an existing support-moment correction still fail the spherical-cap gate. No production force was +changed. The completed three-way follow-up identifies fluid-wedge restriction and wall-colorfield +gating as the planar-to-cap discrepancy. Compatible normalized colorfield continuation passes all +five middle cap errors but no original endpoint-decrease gate, while the initial wetted-area measure +fails only its small 150-degree disk. + +The next recovery first validates an eight-phase cap protocol against exact continuum fields, then +runs all remaining formulations. A kernel-derived flooded-wall reference and canonical wedge edge +correction reduce the wetted-area errors below 5.68% in all five middle cases (the former 150-degree +error becomes 1.99%), pass all endpoint checks, retain four of four total signs, and give exactly +zero force at 90 degrees. This corrected wetted-area energy is the sole candidate admitted to the +dynamic recovery gate. Its complete density derivative passes all nine algebra/static checks with a +worst energy-gradient error of `4.89e-10` and momentum residuals below `4.1e-15`. The sole uniform +`0.02 s` extension passes `4/4` restoring responses; the threshold, timestep, selected 15-cell, and +sensitivity gates pass `5/5`, `2/2`, `15/15`, and `4/4`. The sensitivity span is `0.107 deg`. +Median validation-only runtime overhead is 2.0% in the inherited, exactly disabled 90-degree path, +while an added active 60-degree benchmark records 30.5%. + +Production promotion as `WettedAreaContactAngle(theta)` reproduces the complete replay through only +production caches and RHS paths. Static algebra passes `9/9` with maximum gradient error +`3.59e-10`; extended perturbation, threshold, timestep, selected, and sensitivity gates pass +`4/4`, `5/5`, `2/2`, `15/15`, and `4/4`. Fusing both force terms and accumulating fixed-wall +reactions thread-locally yields 16.0% active 60-degree median overhead, below the pre-registered +20% gate; the exactly disabled 90-degree path has 0.4% overhead. `ColorfieldSurfaceNormal()` still +defaults to no contact model. Final validation, unit/Aqua, documentation, formatting, and changed +example checks pass, closing G3. The compatible geometry-normal +variant fails endpoint/angle checks, while a true Young scalar ghost condition passes its line +integral but only two of five middle angle checks and three of four total signs. In parallel, free +Rayleigh CSS collapses after 0.30 periods; an EOS background pressure reaches 0.76 periods but causes +severe pairing and density loss. The later consistent `FreeSurfaceTangentialShifting` replay reaches +1.48 periods and keeps minimum pair spacing at `0.763 dx`, but still collapses after density falls to +`576 kg/m^3`; its frequency error is 33.6%. A Sun-2017 callback replay removes the Sun-2019 +continuity and momentum modifications, but collapses earlier at 0.40 periods with `718 kg/m^3` +minimum density and 50% frequency error. Neither shifting formulation is a Rayleigh-stability +recommendation. +Detailed evidence and gates are in +`compare_akinci/contact_angle_recovery.md` and `compare_akinci/CSS_plan.md`. + +Run the static validation with: + +```bash +julia +release --project=compare_akinci/simulation \ + compare_akinci/css_validation.jl 375 750 1500 3000 6000 +``` + +A full mirrored ghost-force operator does not improve static support: its residual RMS is +`10.4126 m/s^2` and mean vertical residual is `-9.81 m/s^2`. In the separate fixed-particle +operator study, imposing geometric contact angles of +180, 150, 120, and 90 degrees changes residual RMS to `10.2478`, `10.2599`, `10.3816`, and +`10.5870 m/s^2`; the no-wetting correction is therefore too small to explain the shape. No +Riemann/Godunov WCSPH solver is implemented. At zero relative velocity its acoustic pressure flux +reduces to the already-tested continuity-density pressure operator, including for mirrored ghosts, +so it cannot alter the static equilibrium gate. + +![Intermediate Figure 8 force diagnostic](figure_08_intermediate_force_diagnostic.png) + +![Settled no-wetting force diagnostic](figure_08_no_wetting_settled_force_diagnostic.png) + +Ray-traced plates are intentionally on hold until every raw simulation has been validated. Existing +ray-traced PNGs in this directory are development artifacts, not accepted comparison results. + +## Particle Diagnostics + +Generate a multi-frame snapshot without invoking the renderer: + +```bash +JULIA_NUM_THREADS=24 julia +release --project=compare_akinci/simulation \ + compare_akinci/simulate.jl water_crown /tmp/water_crown.jls +``` + +Plot an unsmoothed center slice: + +```bash +julia +release --project=compare_akinci compare_akinci/particle_diagnostics.jl \ + /tmp/water_crown.jls compare_akinci/figure_01_particle_diagnostic.png +``` + +Analyze and plot the exact per-particle Figure 8 force decomposition: + +```bash +julia +release --project=compare_akinci/simulation compare_akinci/simulate.jl \ + wetting_intermediate /tmp/wetting_intermediate_paper_final.jls +julia +release --project=compare_akinci/simulation compare_akinci/force_analysis.jl \ + wetting_intermediate /tmp/wetting_intermediate_paper_final.jls \ + /tmp/wetting_intermediate_force_all.jls 0.0,0.05 +julia +release --project=compare_akinci compare_akinci/force_diagnostics.jl \ + /tmp/wetting_intermediate_force_all.jls \ + compare_akinci/figure_08_intermediate_force_diagnostic.png 0.0,0.05 +``` + +Assemble the fixed-particle pressure operators and reproduce the Adami/mDBC equilibrium solves: + +```bash +julia +release --project=compare_akinci/simulation compare_akinci/simulate.jl \ + wetting_no /tmp/wetting_no_paper_final.jls +julia +release --project=compare_akinci/simulation \ + compare_akinci/pressure_equilibrium.jl /tmp/wetting_no_paper_final.jls \ + /tmp/wetting_pressure_equilibrium.jls + +julia +release --project=compare_akinci/simulation \ + compare_akinci/pressure_resolution_study.jl \ + /tmp/wetting_pressure_resolution_study.jls 750 3000 6000 + +julia +release --project=compare_akinci/simulation \ + compare_akinci/simulate_delta_sph_wetting.jl \ + wetting_no /tmp/wetting_no_delta_sph.jls 0.6 +``` + +The wetting runner accepts `wetting_no`, `wetting_weak`, `wetting_moderate`, +`wetting_intermediate`, `wetting_strong`, `wetting_near_perfect`, and `wetting_perfect`. Its optional +experimental arguments are `DELTA FREE_SURFACE SUPPORT_FACTOR CURVATURE_FACTOR`; for example, the +rejected interface-taper and separate-support combination can be reproduced with: + +```bash +julia +release --project=compare_akinci/simulation \ + compare_akinci/simulate_delta_sph_wetting.jl \ + wetting_no /tmp/wetting_no_experimental.jls 0.2 0.01 true 2.0 1.0 +``` + +Run the instantaneous and three-radius surface-tension calibrations with: + +```bash +julia +release --project=compare_akinci/simulation \ + compare_akinci/surface_tension_calibration.jl + +julia +release --project=compare_akinci/simulation \ + compare_akinci/surface_tension_calibration.jl \ + laplace_series akinci 1.0 0.02 +``` + +The modification runner supports `akinci`, `akinci_invariant`, `cohesion`, +`cohesion_physical`, `akinci_wall`, `akinci_wall_direct`, `akinci_contact`, `hybrid`, `morris`, +and `momentum_morris`. For example: + +```bash +julia +release --project=compare_akinci/simulation \ + compare_akinci/investigate_wetting_models.jl \ + akinci 8.8 /tmp/wetting_akinci_gamma88.jls 0.2 + +julia +release --project=compare_akinci/simulation \ + compare_akinci/investigate_wetting_models.jl \ + akinci_wall_direct 8.8 /tmp/wetting_akinci_wall10.jls 0.02 90 10 + +julia +release --project=compare_akinci/simulation \ + compare_akinci/investigate_wetting_models.jl \ + cohesion_physical 5 /tmp/wetting_physical_theta90.jls 0.1 90 +``` + +Reproduce the physical-cohesion energy and resolution studies with: + +```bash +julia +release --project=compare_akinci/simulation \ + compare_akinci/resolution_invariant_study.jl cohesion_energy 12000 + +julia +release --project=compare_akinci/simulation \ + compare_akinci/investigate_wetting_models.jl \ + cohesion_physical 5 /tmp/wetting_physical_n1500.jls 0.2 180 0 1500 +``` + +## Ray Tracing + +The renderer reconstructs a smooth implicit surface from the SPH particles with Meshing.jl and +uses Makie's experimental [RayMakie ray-tracing backend](https://makie.org/website/blogposts/raytracing/). +Fluid surfaces use Hikari's dielectric water material with an index of refraction of 1.33, rendered +by its volumetric path tracer with hardware-accelerated Vulkan ray tracing through Lava. Figure 2c +uses particle rendering, matching the visual convention of the paper. RayMakie and its dependencies +are not released yet, so Makie and Lava are pinned to revisions from the +[RayDemo](https://github.com/SimonDanisch/RayDemo) tested manifest. + +## Reproduce + +From the repository root, prepare the isolated simulation and rendering environments: + +```bash +julia +release --project=compare_akinci/simulation -e 'using Pkg; Pkg.instantiate()' +julia +release --project=compare_akinci -e 'using Pkg; Pkg.instantiate()' +``` + +The environments are separate because Hikari currently declares `StructArrays` 0.6 while the +simulation's SciML stack requires 0.7. The simulation runs in its own process and transfers selected +particle frames through a temporary serialized snapshot. + +After the raw cases have been accepted, render every panel and rebuild the plates and overview: + +```bash +julia +release --project=compare_akinci compare_akinci/render_all.jl +``` + +Render one simulation job by passing one of the names in `CASES` from `cases.jl`: + +```bash +julia +release --project=compare_akinci compare_akinci/render.jl water_crown +``` + +Recompose the plates without rerunning simulations or ray tracing: + +```bash +julia +release --project=compare_akinci compare_akinci/make_overview.jl +``` + +The rendering environment requires Julia 1.12 and a Vulkan-capable GPU. Set +`TRIXIPARTICLES_RAY_SAMPLES` to trade rendering time for lower noise, or +`TRIXIPARTICLES_RAY_MAX_DEPTH` to change the maximum path depth. The defaults are 128 samples and +12 path segments. + +## Scope + +These are reproducible visual checks of the modeled mechanisms, not pixel-level reproductions of +the paper's large production scenes. Particle counts, dimensions, and rigid-body complexity are +reduced. The repository implements the Akinci surface-tension model but not the Tartakovsky-Meakin +or Becker-Teschner models. Figure 2 therefore reproduces only row (c), and Figure 5 leaves the +unavailable comparison panel explicitly marked instead of substituting a different model. + +Figure 7 is rendered from four independent final-time simulations. `OpenBoundarySystem` mutates its +active-particle mask during integration, so extracting earlier saved ODE states using the final mask +would produce invalid panels. diff --git a/compare_akinci/akinci_comparison.png b/compare_akinci/akinci_comparison.png new file mode 100644 index 0000000000..5add2656ca Binary files /dev/null and b/compare_akinci/akinci_comparison.png differ diff --git a/compare_akinci/boundary_volume.jl b/compare_akinci/boundary_volume.jl new file mode 100644 index 0000000000..f92222755e --- /dev/null +++ b/compare_akinci/boundary_volume.jl @@ -0,0 +1,82 @@ +using LinearAlgebra +using Printf +using Statistics +using TrixiParticles + +function akinci_boundary_hydrodynamic_mass(initial_condition, smoothing_kernel, + smoothing_length, reference_density) + coordinates = initial_condition.coordinates + particle_spacing = initial_condition.particle_spacing + dimensions = size(coordinates, 1) + support = TrixiParticles.compact_support(smoothing_kernel, smoothing_length) + search_radius = ceil(Int, support / particle_spacing) + origin = coordinates[:, 1] + + particle_at = Dict{NTuple{dimensions, Int}, Int}() + for particle in axes(coordinates, 2) + key = ntuple(dimensions) do dimension + round(Int, + (coordinates[dimension, particle] - origin[dimension]) / + particle_spacing) + end + particle_at[key] = particle + end + + offset_range = (-search_radius):search_radius + offsets = Iterators.product(ntuple(_ -> offset_range, dimensions)...) + mass = similar(initial_condition.mass) + for particle in axes(coordinates, 2) + key = ntuple(dimensions) do dimension + round(Int, + (coordinates[dimension, particle] - origin[dimension]) / + particle_spacing) + end + number_density = zero(eltype(mass)) + for offset in offsets + neighbor_key = ntuple(dimension -> key[dimension] + offset[dimension], + dimensions) + neighbor = get(particle_at, neighbor_key, 0) + iszero(neighbor) && continue + distance = norm(coordinates[:, particle] - coordinates[:, neighbor]) + distance < support || continue + number_density += TrixiParticles.kernel(smoothing_kernel, distance, + smoothing_length) + end + mass[particle] = reference_density / number_density + end + + return mass +end + +function print_boundary_volume_summary(initial_condition, hydrodynamic_mass, + reference_density) + coordinates = initial_condition.coordinates + particle_spacing = initial_condition.particle_spacing + dimensions = size(coordinates, 1) + nominal_mass = reference_density * particle_spacing^dimensions + ratio = hydrodynamic_mass ./ nominal_mass + z_values = sort(unique(coordinates[end, :]); rev=true) + + @printf("boundary particles: %d, nominal mass %.6g\n", length(ratio), nominal_mass) + for z in z_values + mask = isapprox.(coordinates[end, :], z; + atol=10eps(abs(z) + particle_spacing)) + layer_ratio = ratio[mask] + @printf(" z=%9.6f: n=%5d ratio median=%7.4f range=[%7.4f, %7.4f]\n", + z, count(mask), median(layer_ratio), minimum(layer_ratio), + maximum(layer_ratio)) + end + + center_mask = trues(length(ratio)) + for dimension in 1:(dimensions - 1) + center_mask .&= abs.(coordinates[dimension, :]) .< 2particle_spacing + end + top = maximum(coordinates[end, :]) + center_mask .&= isapprox.(coordinates[end, :], top; + atol=10eps(abs(top) + particle_spacing)) + @printf(" exposed center ratio: median=%7.4f range=[%7.4f, %7.4f]\n", + median(ratio[center_mask]), minimum(ratio[center_mask]), + maximum(ratio[center_mask])) + + return ratio +end diff --git a/compare_akinci/cases.jl b/compare_akinci/cases.jl new file mode 100644 index 0000000000..e2f9b074e8 --- /dev/null +++ b/compare_akinci/cases.jl @@ -0,0 +1,362 @@ +const PANEL_DIRECTORY = "panels" + +function panel_output(figure, panel) + joinpath(PANEL_DIRECTORY, + "figure_$(lpad(figure, 2, '0'))_$(panel).png") +end + +const BLUE_SOLID = (0.02f0, 0.18f0, 0.55f0) +const PALE_SOLID = (0.72f0, 0.75f0, 0.78f0) +const BOX_SOLID = (0.74f0, 0.73f0, 0.64f0) + +const WATER_CROWN_PROPS = ((; kind=:box, origin=(-0.005, -0.008, -0.005), + widths=(0.16, 0.01, 0.05), color=BLUE_SOLID), + (; kind=:box, origin=(-0.005, 0.148, -0.005), + widths=(0.16, 0.01, 0.05), color=BLUE_SOLID), + (; kind=:box, origin=(-0.008, 0.0, -0.005), + widths=(0.01, 0.15, 0.05), color=BLUE_SOLID), + (; kind=:box, origin=(0.148, 0.0, -0.005), + widths=(0.01, 0.15, 0.05), color=BLUE_SOLID)) + +const PLATE_PROPS = ((; kind=:box, origin=(-0.2, -0.2, -0.025), + widths=(0.4, 0.4, 0.025), color=PALE_SOLID), + (; kind=:box, origin=(-0.025, -0.025, -0.2), + widths=(0.05, 0.05, 0.175), color=PALE_SOLID)) + +const WETTING_PROPS = ((; kind=:box, origin=(-0.03, -0.03, -0.002), + widths=(0.06, 0.06, 0.002), color=PALE_SOLID),) + +const SPLITTING_PROPS = ((; kind=:box, origin=(0.0, 0.0, -0.02), + widths=(0.24, 0.18, 0.02), color=BOX_SOLID), + (; kind=:box, origin=(-0.02, 0.0, -0.02), + widths=(0.02, 0.18, 0.16), color=BOX_SOLID), + (; kind=:box, origin=(0.24, 0.0, -0.02), + widths=(0.02, 0.18, 0.16), color=BOX_SOLID), + (; kind=:box, origin=(0.0, 0.18, -0.02), + widths=(0.24, 0.02, 0.16), color=BOX_SOLID)) + +const CASES = ((name="water_crown", + title="Figures 1 and 5: water crown", + example="akinci_water_crown_3d.jl", + kwargs=(; particle_spacing=0.0015, tspan=(0.0, 0.12), + solution_saveat=(0.0, 0.025, 0.045, 0.055, 0.075, 0.095, 0.12), + saving_callback=nothing), + system_indices=(1,), focus_indices=(1,), fluid_iso_fraction=0.12f0, + view_center=(0.075, 0.075, 0.04), view_extent=0.18f0, + camera=(0.15, -2.0, 0.32), camera_scale=1.85f0, fov=28.0f0, + props=WATER_CROWN_PROPS, show_ground=false, + panels=((; time=0.0, output=panel_output(1, 1)), + (; time=0.025, output=panel_output(1, 2)), + (; time=0.055, output=panel_output(1, 3)), + (; time=0.12, output=panel_output(5, 2)))), + (name="cube_to_sphere", + title="Figure 2c: Akinci surface-tension model", + example="akinci_cube_to_sphere_3d.jl", + kwargs=(; release_time=0.05, tspan=(0.0, 0.1), + solution_saveat=(0.0, 0.03, 0.05, 0.065, 0.075, 0.08, 0.085, + 0.09, 0.095, 0.1), + saving_callback=nothing), + paper=(; fluid_volume=1.0e-6, particle_count=7_000, + source=:companion_video), + system_indices=(1,), focus_indices=(1,), render_style=:particles, + particle_color=:pressure, + particle_scale=0.82f0, view_center=(0.0, 0.0, 0.006), view_extent=0.03f0, + camera=(1.1, -2.0, 0.72), camera_scale=2.0f0, fov=30.0f0, + props=((; kind=:box, origin=(-0.015, -0.015, -0.002), + widths=(0.03, 0.03, 0.002), color=PALE_SOLID),), + show_ground=false, + panels=((; time=0.05, output=panel_output(2, 1)), + (; time=0.1, output=panel_output(2, 2)))), + (name="droplet_on_plate", + title="Figure 6: droplet impact on a plate", + example="akinci_droplet_on_plate_3d.jl", + kwargs=(; particle_spacing=0.02, drop_radius=0.12, + drop_center=(0.0, 0.0, 0.28), plate_size=(0.4, 0.4), + tspan=(0.0, 0.25), solution_saveat=(0.0, 0.15, 0.2, 0.25), + saving_callback=nothing), + system_indices=(1,), focus_indices=(1,), fluid_iso_fraction=0.15f0, + view_center=(0.0, 0.0, 0.08), view_extent=0.56f0, + camera=(1.0, -2.0, 0.85), camera_scale=1.9f0, fov=30.0f0, + props=PLATE_PROPS, show_ground=false, + panels=((; time=0.0, output=panel_output(6, 1), camera=(0.2, -2.0, 0.35)), + (; time=0.15, output=panel_output(6, 2), camera=(0.4, -2.0, 0.55)), + (; time=0.2, output=panel_output(6, 3)), + (; time=0.25, output=panel_output(6, 4)))), + (name="stream_over_sphere_1", + title="Figure 7: stream flowing over a sphere", + example="akinci_stream_over_sphere_3d.jl", + kwargs=(; particle_spacing=0.015, tspan=(0.0, 0.08), + solution_saveat=(0.08,), saving_callback=nothing), + system_indices=(1, 3), focus_indices=(1, 3), + view_center=(0.0, 0.0, -0.12), view_extent=0.32f0, + camera=(0.4, -2.0, 0.25), camera_scale=2.0f0, fov=28.0f0, + system_colors=(3 => PALE_SOLID,), show_ground=false, + panels=((; time=0.08, output=panel_output(7, 1)),)), + (name="stream_over_sphere_2", + title="Figure 7: stream flowing over a sphere", + example="akinci_stream_over_sphere_3d.jl", + kwargs=(; particle_spacing=0.015, tspan=(0.0, 0.18), + solution_saveat=(0.18,), saving_callback=nothing), + system_indices=(1, 3), focus_indices=(1, 3), + view_center=(0.0, 0.0, -0.12), view_extent=0.32f0, + camera=(0.4, -2.0, 0.25), camera_scale=2.0f0, fov=28.0f0, + system_colors=(3 => PALE_SOLID,), show_ground=false, + panels=((; time=0.18, output=panel_output(7, 2)),)), + (name="stream_over_sphere_3", + title="Figure 7: stream flowing over a sphere", + example="akinci_stream_over_sphere_3d.jl", + kwargs=(; particle_spacing=0.015, tspan=(0.0, 0.3), + solution_saveat=(0.3,), saving_callback=nothing), + system_indices=(1, 3), focus_indices=(1, 3), + view_center=(0.0, 0.0, -0.12), view_extent=0.32f0, + camera=(0.4, -2.0, 0.25), camera_scale=2.0f0, fov=28.0f0, + system_colors=(3 => PALE_SOLID,), show_ground=false, + panels=((; time=0.3, output=panel_output(7, 3)),)), + (name="stream_over_sphere_4", + title="Figure 7: stream flowing over a sphere", + example="akinci_stream_over_sphere_3d.jl", + kwargs=(; particle_spacing=0.015, tspan=(0.0, 0.45), + solution_saveat=(0.45,), saving_callback=nothing), + system_indices=(1, 3), focus_indices=(1, 3), + view_center=(0.0, 0.0, -0.12), view_extent=0.32f0, + camera=(0.4, -2.0, 0.25), camera_scale=2.0f0, fov=28.0f0, + system_colors=(3 => PALE_SOLID,), show_ground=false, + panels=((; time=0.45, output=panel_output(7, 4)),)), + (name="wetting_no", + title="Figure 8: no wetting", + example="akinci_wetting_3d.jl", + kwargs=(; wetting_case="no_wetting", plate_size=(0.06, 0.06), + tspan=(0.0, 0.2), solution_saveat=(0.0, 0.05, 0.1, 0.15, 0.2), + saving_callback=nothing), + paper=(; fluid_volume=1.0e-6, particle_count=750, + surface_tension_coefficient=1.0, adhesion_coefficient=0.0, + source=:companion_video), + system_indices=(1,), focus_indices=(1,), fluid_iso_fraction=0.15f0, + view_center=(0.0, 0.0, 0.004), view_extent=0.07f0, + camera=(0.8, -2.0, 0.38), camera_scale=1.9f0, fov=29.0f0, + props=WETTING_PROPS, show_ground=false, + panels=((; time=0.2, output=panel_output(8, 1)),)), + (name="wetting_weak", + title="Figure 8 video: weak wetting", + example="akinci_wetting_3d.jl", + kwargs=(; wetting_case="weak_wetting", plate_size=(0.06, 0.06), + tspan=(0.0, 0.2), solution_saveat=(0.0, 0.05, 0.1, 0.15, 0.2), + saving_callback=nothing), + paper=(; fluid_volume=1.0e-6, particle_count=750, + surface_tension_coefficient=1.0, adhesion_coefficient=0.05, + source=:companion_video), + system_indices=(1,), focus_indices=(1,), fluid_iso_fraction=0.15f0, + view_center=(0.0, 0.0, 0.004), view_extent=0.07f0, + camera=(0.8, -2.0, 0.38), camera_scale=1.9f0, fov=29.0f0, + props=WETTING_PROPS, show_ground=false, panels=()), + (name="wetting_moderate", + title="Figure 8: moderate wetting", + example="akinci_wetting_3d.jl", + kwargs=(; wetting_case="moderate_wetting", plate_size=(0.06, 0.06), + tspan=(0.0, 0.2), solution_saveat=(0.0, 0.05, 0.1, 0.15, 0.2), + saving_callback=nothing), + paper=(; fluid_volume=1.0e-6, particle_count=750, + surface_tension_coefficient=1.0, adhesion_coefficient=0.1, + source=:companion_video), + system_indices=(1,), focus_indices=(1,), fluid_iso_fraction=0.15f0, + view_center=(0.0, 0.0, 0.004), view_extent=0.07f0, + camera=(0.8, -2.0, 0.38), camera_scale=1.9f0, fov=29.0f0, + props=WETTING_PROPS, show_ground=false, + panels=((; time=0.2, output=panel_output(8, 2)),)), + (name="wetting_intermediate", + title="Figure 8: intermediate wetting", + example="akinci_wetting_3d.jl", + kwargs=(; wetting_case="intermediate_wetting", plate_size=(0.06, 0.06), + tspan=(0.0, 0.2), solution_saveat=(0.0, 0.05, 0.1, 0.15, 0.2), + saving_callback=nothing), + paper=(; fluid_volume=1.0e-6, particle_count=750, + surface_tension_coefficient=1.0, adhesion_coefficient=0.25, + source=:companion_video), + system_indices=(1,), focus_indices=(1,), fluid_iso_fraction=0.15f0, + view_center=(0.0, 0.0, 0.004), view_extent=0.07f0, + camera=(0.8, -2.0, 0.38), camera_scale=1.9f0, fov=29.0f0, + props=WETTING_PROPS, show_ground=false, + panels=((; time=0.2, output=panel_output(8, 3)),)), + (name="wetting_strong", + title="Figure 8 video: strong wetting", + example="akinci_wetting_3d.jl", + kwargs=(; wetting_case="strong_wetting", plate_size=(0.06, 0.06), + tspan=(0.0, 0.2), solution_saveat=(0.0, 0.05, 0.1, 0.15, 0.2), + saving_callback=nothing), + paper=(; fluid_volume=1.0e-6, particle_count=750, + surface_tension_coefficient=0.1, adhesion_coefficient=0.01, + source=:companion_video), + system_indices=(1,), focus_indices=(1,), fluid_iso_fraction=0.15f0, + view_center=(0.0, 0.0, 0.004), view_extent=0.07f0, + camera=(0.8, -2.0, 0.38), camera_scale=1.9f0, fov=29.0f0, + props=WETTING_PROPS, show_ground=false, panels=()), + (name="wetting_near_perfect", + title="Figure 8 video: near-perfect wetting", + example="akinci_wetting_3d.jl", + kwargs=(; wetting_case="near_perfect_wetting", plate_size=(0.06, 0.06), + tspan=(0.0, 0.2), solution_saveat=(0.0, 0.05, 0.1, 0.15, 0.2), + saving_callback=nothing), + paper=(; fluid_volume=1.0e-6, particle_count=750, + surface_tension_coefficient=0.01, adhesion_coefficient=0.001, + source=:companion_video), + system_indices=(1,), focus_indices=(1,), fluid_iso_fraction=0.15f0, + view_center=(0.0, 0.0, 0.004), view_extent=0.07f0, + camera=(0.8, -2.0, 0.38), camera_scale=1.9f0, fov=29.0f0, + props=WETTING_PROPS, show_ground=false, panels=()), + (name="wetting_perfect", + title="Figure 8: perfect wetting", + example="akinci_wetting_3d.jl", + kwargs=(; wetting_case="perfect_wetting", plate_size=(0.06, 0.06), + tspan=(0.0, 0.2), solution_saveat=(0.0, 0.05, 0.1, 0.15, 0.2), + saving_callback=nothing), + paper=(; fluid_volume=1.0e-6, particle_count=750, + surface_tension_coefficient=0.001, adhesion_coefficient=0.0, + source=:companion_video), + system_indices=(1,), focus_indices=(1,), fluid_iso_fraction=0.15f0, + view_center=(0.0, 0.0, 0.004), view_extent=0.07f0, + camera=(0.8, -2.0, 0.38), camera_scale=1.9f0, fov=29.0f0, + props=WETTING_PROPS, show_ground=false, + panels=((; time=0.2, output=panel_output(8, 4)),)), + (name="droplet_splitting", + title="Figure 9: droplet splitting in an adhesive box", + example="akinci_droplet_splitting_3d.jl", + kwargs=(; particle_spacing=0.01, tspan=(0.0, 0.35), + solution_saveat=(0.0, 0.12, 0.22, 0.35), saving_callback=nothing), + system_indices=(1, 3), focus_indices=(1, 3), fluid_iso_fraction=0.15f0, + view_center=(0.12, 0.09, 0.07), view_extent=0.29f0, + camera=(0.7, -2.0, 0.7), camera_scale=2.1f0, fov=30.0f0, + system_colors=(3 => (1.0f0, 0.65f0, 0.0f0),), + props=SPLITTING_PROPS, show_ground=false, + panels=((; time=0.0, output=panel_output(9, 1)), + (; time=0.12, output=panel_output(9, 2)), + (; time=0.22, output=panel_output(9, 3)), + (; time=0.35, output=panel_output(9, 4)))), + (name="rolling_droplet", + title="Figure 10: rolling droplet", + example="akinci_rolling_droplet_3d.jl", + kwargs=(; particle_spacing=0.01, tspan=(0.0, 0.4), + solution_saveat=(0.0, 0.12, 0.26, 0.4), saving_callback=nothing), + system_indices=(1, 2, 3, 4), focus_indices=(1, 3, 4), + fluid_iso_fraction=0.2f0, + view_center=(0.0, 0.0, 0.09), view_extent=0.58f0, + camera=(0.0, -2.0, 0.35), camera_scale=1.9f0, fov=29.0f0, + system_colors=(2 => BLUE_SOLID, + 3 => (0.15f0, 0.8f0, 0.25f0), + 4 => (0.95f0, 0.15f0, 0.65f0)), + show_ground=false, + panels=((; time=0.0, output=panel_output(10, 1)), + (; time=0.12, output=panel_output(10, 2)), + (; time=0.26, output=panel_output(10, 3)), + (; time=0.4, output=panel_output(10, 4))))) + +# CSS variants are admitted one case at a time after their raw-particle metric exists. The +# coefficient is a Track B calibration candidate, not a physical material property. +const CSS_CASES = ((name="cube_to_sphere_css", + base_name="cube_to_sphere", + title="Figure 2c: CSS surface-tension model", + surface_tension_coefficient=0.012, + surface_tension_mode=:css, + smoothing_kernel_mode=:wendland_c2, + smoothing_length_ratio=1.4, + normal_smoothing=false, + ccsf_contact_angle=nothing, + artificial_viscosity_alpha=0.05, + viscosity_mode=:artificial_monaghan, + density_diffusion_mode=:antuono, + density_diffusion_delta=0.1, + initial_particle_distribution=:lattice, + packing_relative_jitter=0.1, + packing_seed=20260805, + packing_maxiters=1000, + pressure_stabilization=:none, + tic_strength=1.0, + shifting_mode=:consistent_sun2019, + shifting_v_max_factor=0.0f0, + shifting_sound_speed_factor=0.1f0, + particle_spacing=0.01 / 19, + contact_angle=nothing, + calibration_status=:alignment_corrected_candidate),) + +const PLATES = ((output="figure_01_water_crown.png", + title="Figure 1 | Water-crown sequence", + caption="The reported pool and drop dimensions produce the initial impact and crown at reduced resolution.", + layout=(1, 3), + cells=((; file=panel_output(1, 1), label="before impact"), + (; file=panel_output(1, 2), label="first impact"), + (; file=panel_output(1, 3), label="crown and spray"))), + (output="figure_02_surface_tension.png", + title="Figure 2c | Implemented surface-tension model", + caption="Only the Akinci model represented by row (c) of the paper is available in this repository.", + layout=(1, 2), + cells=((; file=panel_output(2, 1), label="relaxed droplet"), + (; file=panel_output(2, 2), label="post-impact"))), + (output="figure_05_model_comparison.png", + title="Figure 5 | Water-crown model comparison", + caption="The unavailable Becker-Teschner comparison is left explicit instead of being approximated by another model.", + layout=(1, 2), + cells=((; placeholder="Becker and Teschner (2007)\nnot implemented", + label="comparison model"), + (; file=panel_output(5, 2), label="Akinci model"))), + (output="figure_06_droplet_on_plate.png", + title="Figure 6 | Droplet impact on a finite plate", + caption="The scaled sequence follows approach, impact, sheet formation, and dripping.", + layout=(1, 4), + cells=((; file=panel_output(6, 1), label="approach"), + (; file=panel_output(6, 2), label="impact"), + (; file=panel_output(6, 3), label="sheet"), + (; file=panel_output(6, 4), label="dripping"))), + (output="figure_07_stream_over_sphere.png", + title="Figure 7 | Stream flowing over a sphere", + caption="Each panel is an independent final-time run so the mutable open-boundary mask remains valid.", + layout=(1, 4), + cells=((; file=panel_output(7, 1), label="contact"), + (; file=panel_output(7, 2), label="coating"), + (; file=panel_output(7, 3), label="through-flow"), + (; file=panel_output(7, 4), label="late flow"))), + (output="figure_08_wetting.png", + title="Figure 8 | Wetting regimes", + caption="The reported coefficient pairs are retained, but raw WCSPH diagnostics do not reproduce the reference regimes.", + layout=(1, 4), + cells=((; file=panel_output(8, 1), label="gamma=1, beta=0"), + (; file=panel_output(8, 2), label="gamma=1, beta=0.1"), + (; file=panel_output(8, 3), label="gamma=1, beta=0.25"), + (; file=panel_output(8, 4), label="gamma=0.001, beta=0"))), + (output="figure_09_droplet_splitting.png", + title="Figure 9 | Splitting in an adhesive box", + caption="The box is adhesive while the descending yellow blade has zero adhesion.", + layout=(1, 4), + cells=((; file=panel_output(9, 1), label="initial state"), + (; file=panel_output(9, 2), label="first contact"), + (; file=panel_output(9, 3), label="separation"), + (; file=panel_output(9, 4), label="split droplet"))), + (output="figure_10_rolling_droplet.png", + title="Figure 10 | Rolling droplet and rigid figures", + caption="Green and pink identify the adhesive and non-adhesive rigid figures, respectively.", + layout=(1, 4), + cells=((; file=panel_output(10, 1), label="release"), + (; file=panel_output(10, 2), label="rolling"), + (; file=panel_output(10, 3), label="interaction"), + (; file=panel_output(10, 4), label="late state")))) + +function akinci_case_config(name) + index = findfirst(config -> config.name == name, CASES) + isnothing(index) && return nothing + return CASES[index] +end + +function case_config(name) + base = akinci_case_config(name) + !isnothing(base) && return base + + index = findfirst(config -> config.name == name, CSS_CASES) + if !isnothing(index) + css = CSS_CASES[index] + base = akinci_case_config(css.base_name) + isnothing(base) && + error("CSS case '$name' has unknown base case '$(css.base_name)'") + return merge(base, (; name=css.name, title=css.title, css)) + end + + available = (getproperty.(CASES, :name)..., getproperty.(CSS_CASES, :name)...) + error("unknown comparison case '$name'. Available cases: " * join(available, ", ")) +end diff --git a/compare_akinci/contact_angle_recovery.md b/compare_akinci/contact_angle_recovery.md new file mode 100644 index 0000000000..2f67e256cd --- /dev/null +++ b/compare_akinci/contact_angle_recovery.md @@ -0,0 +1,564 @@ +# Contact-Angle Recovery Design + +This note records the Phase 3 recovery diagnostics after both implemented contact-angle candidates +failed the off-target restoring gate. It is intentionally separate from the accepted Phase 2 data. +No formulation below is a production recommendation until it passes the gates in `CSS_plan.md`. + +## Requirements + +A replacement must: + +- preserve `ColorfieldSurfaceNormal(contact_model=nothing)` behavior; +- use physical surface tension in N/m without a fitted angle-dependent coefficient; +- give the correct control-subtracted restoring sign for all four Phase 3 cap pairs; +- retain pairwise-conservative CSS fluid stress and zero wall-normal contact force; +- define a contact-line measure whose volume integral converges to physical line length; +- remain finite at 0 and 180 degrees and give exactly zero wall-energy force at 90 degrees; +- pass the five-angle, three-resolution static matrix and timestep/stability gates. + +## Rejected Direct Corrections + +### Boundary-subtracted CLF normal + +Before normalization, dummy-boundary quadrature contributes the same vector to the total surface +normal and cached wall normal, so the fluid-only candidate can be reconstructed as + +```math +\bm n_\mathrm{fluid}=\bm n_\mathrm{total}-\bm n_\mathrm{wall}. +``` + +This is not a usable dynamic-angle estimator. At 1500 particles its maximum target-initialized mean +angle error is 58.5 degrees and as much as 85% of contact-line weight has the wrong restoring sign. +The wall-completed normal is also invalid as a shape-angle estimator: its maximum static error is +54.1 degrees. Adding a CLF contact-normal cache or blending these vectors is therefore rejected. + +Evidence: `validation/surface_tension_3d/contact_angle_normal_components.csv`. + +### Ghost-gradient geometric rotation + +A reconstruction variant retained the raw tangential gradient ``\bm q`` and imposed the target wall +component, + +```math +\bm n_\theta=\bm q+\lVert\bm q\rVert\cot\theta\,\hat{\bm n}_w, +``` + +then recomputed the one-sided surface delta from ``\lVert\bm n_\theta\rVert``. This is more +consistent than rotating a fixed-magnitude normal, but it reproduces the same two-of-four +fixed-particle sign result as the existing geometric model. The issue is not only normal magnitude +or tangent selection; no geometric production patch is justified. + +Evidence: `validation/surface_tension_3d/contact_angle_force_sign_ghost_geometric.csv`. + +## Wall Free-Energy Formulation + +The remaining physically motivated path is a discrete wall-energy force. The CSS surface-stress +divergence already supplies the free-surface line tension. Varying the solid-liquid and solid-gas +wall energies adds the Young contribution + +```math +\bm a_a^w +=\frac{\sigma}{\rho_a}\cos\theta_e\,\delta_{CL,a}\,\hat{\bm t}_{w,a}. +``` + +This force needs the equilibrium target only; it does not subtract a noisy particle estimate of the +dynamic angle. Consequently it is exactly zero at 90 degrees. On the four fixed Phase 3 caps, the +target-only force with the current line delta gives three correct total acceleration signs. Applying +the one-phase factor of two gives four of four. This factor is theoretically plausible because the +free surface is sampled from only one side, but force-sign success is not sufficient evidence for +normalization. + +Evidence: `validation/surface_tension_3d/contact_angle_force_sign_wall_energy.csv`. + +## Contact-Line Measure Blocker + +For a spherical cap with contact radius ``r_c``, the line measure must satisfy + +```math +L_h=\sum_a \frac{m_a}{\rho_a}\delta_{CL,a} +\longrightarrow 2\pi r_c. +``` + +The current divergence-based line delta is 24-77% low over the five-angle, three-resolution matrix +and becomes worse under refinement for several obtuse cases. A coarea candidate based on +``\lVert\nabla c_s\times\nabla c_w\rVert`` also fails the 20% gate after the standard one-sided +factors; errors are 22-41%. Omitting interface activity does not restore convergence. A numerical +factor close to ``2\pi`` reduces these errors, but it has not been derived for arbitrary kernels and +would currently be an empirical multiplier, so it is not admissible. + +## Kernel-Level Normalization Study + +For a radial kernel with compact-support radius ``R``, let ``g_W(d)`` be the magnitude of the +half-space color gradient represented by the implemented `kernel_grad` at distance ``d`` from a +plane. Direct integration gives + +```math +g_W(d)=2\pi\int_d^R W(r)r\,\mathrm{d}r +-\pi(R^2-d^2)W(R^-), +\qquad +J_W=\int_0^R g_W(d)\,\mathrm{d}d. +``` + +The cutoff term matters because `kernel_grad` differentiates only inside the strict support. It is +zero for kernels that vanish at ``R``. It is not zero for the truncated Laguerre-Gauss kernel, whose +implemented gradient half-integral is 24.9% below one half of its nominal kernel mass. + +For two planes intersecting at angle ``\theta``, the cross-gradient contributes +``\sin\theta\,g_W(s)g_W(w)`` while the coordinate Jacobian contributes +``1/\sin\theta``. Integrating only the represented fluid quadrant therefore gives ``J_W^2`` per +unit contact-line length, independent of angle. The kernel-derived coarea normalization is + +```math +C_W=\frac{1}{J_W^2}. +``` + +For normalized kernels that vanish at their support boundary, ``J_W=1/2`` and ``C_W=4``. No +sessile-drop angle or measured line length enters this factor. + +The `line_normalization` mode evaluates this continuous profile and matching three-dimensional +kernel-gradient lattice sums for all ten smoothing kernels, five angles, and +``h/\Delta x\in\{2,4,8\}``. Translational invariance collapses the periodic contact-line direction +to unit length; four interface/lattice phases are averaged. The resulting 150 rows show: + +- coarea passes the 20% middle-resolution gate in 50/50 kernel/angle cases, with a maximum error of + 3.37%; +- the strict endpoint-decrease gate passes 40/50 cases; the ten non-monotone fine-grid errors are all + at most 0.105%, but the frozen gate is not relaxed after observing them; +- the production-style divergence form, normalized by its orthogonal kernel integral, passes only + 9/50 middle-resolution cases and no non-orthogonal case; +- for the production Wendland C2 kernel, coarea passes all five planar middle and endpoint gates. + +Evidence: `validation/surface_tension_3d/contact_line_normalization.csv`. + +## Spherical-Cap Transfer And Decision + +The successful planar Wendland C2 factor is ``C_W=4``, which is exactly the previously tested raw +coarea factor. On the actual 1500-particle spherical caps its five line-length errors are 25.8%, +22.4%, 28.5%, 34.3%, and 34.2%. Dividing by the existing local reproducing/support moment reduces +them to 3.4%, 3.2%, 9.9%, 21.0%, and 24.1%, but the two obtuse cases still fail and worsen under +refinement. Thus the continuum normalization is derived, but the available discrete wall/interface +gradients do not transfer it to curved sessile geometry with the required convergence. + +This initial line-measure gate did not close, so no wall-free-energy force entered production. +`contact_model=nothing` remained the default while the validation-only continuation below tested +compatible indicators and a wetted-area discretization under the same measure-first rule. Akinci +adhesion and per-angle force gains remained excluded. + +## R6 Continuation: Three-Way Comparison + +Three candidates share one comparison table and measure-first gates. All live as validation-only +modes of `validation/surface_tension_3d/contact_angle_decision.jl`; no production cache or force +changes before a measure gate passes on caps. Uniform-lattice volume weighting is excluded as a +cause by construction (`V_a = V_b` on the initial lattice). + +### R6-D: Cap-transfer attribution diagnostic (runs first) + +A derived fact fixes what the cap coarea measure actually sees: `calc_boundary_normal!` adds the +identical threshold-gated, `V_a`-weighted completion vector to `surface_normal` and +`boundary_normal`, so + +```math +\bm n_\mathrm{total}\times\bm n_\mathrm{wall} +=\bm n_\mathrm{fluid}\times\bm n_\mathrm{wall}. +``` + +The cross product therefore pairs a wedge-restricted fluid gradient with a gated wall proxy, while +the passing canonical study paired full half-space indicator gradients. The diagnostic closes this +gap from both sides, one ingredient at a time: + +- planar variants added to `line_normalization`: (a) wedge-restricted interface gradient + (neighbors require both `wall_distance > 0` and the fluid side of the interface); (b) wall + gradient gated by the lattice colorfield at `0.1` of its maximum, mirroring + `boundary_contact_threshold`; +- cap variants in a new `cap_transfer` mode: substitute the canonical profile ``g_W`` for (a) the + discrete wall gradient at each particle's wall distance, (b) the discrete interface gradient at + each particle's distance to the analytic cap surface, (c) both, isolating lattice quadrature and + contact-line curvature. + +The planar study now reproduces the transfer loss. Across ten kernels and five angles, the ideal +coarea middle gate remains 50/50; fluid-wedge restriction reduces it to 20/50, and adding the 0.1 +wall-colorfield gate reduces it to 16/50. For Wendland C2 specifically, only the 120- and 150-degree +middle cases pass after either production ingredient is applied. The gated middle errors are 47.0%, +38.2%, 29.0%, 19.9%, and 12.3%. + +The cap substitutions produce: + +| Variant | Middle passes | Endpoint passes | Maximum middle error | +|---|---:|---:|---:| +| production discrete | 0/5 | 2/5 | 34.3% | +| analytic wall only | 3/5 | 2/5 | 26.2% | +| analytic interface only | 4/5 | 1/5 | 30.0% | +| analytic wall and interface | 5/5 | 1/5 | 16.1% | +| support-moment diagnostic | 3/5 | 1/5 | 24.1% | + +Thus both discrete fields contribute, with the missing compatible interface continuation explaining +the larger share of middle-resolution failures. Even replacing both fields leaves non-monotone +curved-cap quadrature in four angles, so no scalar normalization can satisfy the frozen endpoint +gate. Evidence: `contact_line_normalization.csv` and `contact_line_cap_transfer.csv`. + +### R6-C: Compatible colorfield continuation + +The implemented validation reconstruction removes the hard gate from the wall field and continues +the fluid indicator into wall particles with their flooded-reference-normalized wetness +``\phi_b=c_b/\max(c)``: + +```math +\nabla c_w=\sum_{b\in wall}V_a\nabla W_{ab},\qquad +\nabla c_s=\nabla c_\mathrm{fluid}+\sum_{b\in wall}V_a\phi_b\nabla W_{ab}. +``` + +This is a compatible reference-normalized colorfield continuation, not a local Shepard quotient; +the name was corrected after implementation. It uses the existing boundary colorfield and one +validation vector accumulation. The derived factor ``C_W`` is unchanged and no fitted constant is +introduced. + +All five middle cap errors pass, with maximum 8.85%, but all five strict endpoint-decrease gates +fail: fine errors are 4.9-17.3% after exceptionally small and non-monotone coarse errors. R6-C is +therefore ineligible and no force-sign or ODE run counts for it. + +### R6-W: Wetted-area wall free energy + +Young-Dupre gives the wall energy without any contact-line delta: + +```math +E_w=(\sigma_{sl}-\sigma_{sg})A_{sl}=-\sigma\cos\theta_e\,A_\mathrm{wetted}. +``` + +Discretize the wetted area through the boundary colorfield that production already computes, +restricted to the exposed wall layer with per-particle area ``A_b=\Delta x^2``: + +```math +A_\mathrm{wetted}=\sum_b A_b\,H\!\left(c_b/c_\mathrm{ref}\right), +\qquad +c_b=\sum_a\frac{m_a}{\rho_a}W_{ab}, +``` + +with ``H`` the cubic smoothstep already used for activity transitions over the full normalized +range (no free width parameter). The validation uses the maximum current boundary colorfield as +``c_\mathrm{ref}`` and explicitly restricts the sum to the exposed wall layer. A production version +would need a local or precomputed flooded-wall reference instead of this global reduction. +Differentiating the energy at frozen densities yields the fluid acceleration + +```math +\bm a_a=\frac{\sigma\cos\theta_e}{\rho_a} +\sum_b\frac{A_b}{c_\mathrm{ref}}\,H'\!\left(c_b/c_\mathrm{ref}\right)\nabla_a W_{ab}. +``` + +Units are `m/s^2` (``\sigma A_b\nabla W/(\rho_a c_\mathrm{ref})`` carries N per kg); the force is +exactly zero at 90 degrees and finite at 0/180, while ``H'`` localizes it at the contact line. The +validation computes only the fluid-side force and neglects the density-gradient term of +``\partial c_b/\partial\bm x_a``. A production version would also need an equal/opposite wall +reaction. Measure gate before any force test: +``|A_h/(\pi r_c^2)-1|`` at most 20% at 1500 particles with decreasing endpoint error over all five +angles; then exact zero at 90 degrees, then the four fixed-particle signs. Evidence: +`wetted_area_measure.csv` and `contact_angle_force_sign_wetted_area.csv`. + +R6-W passes four of five middle and endpoint area gates. The unresolved 150-degree area is 50.6% +high at 1500 particles and remains 39.4% high at 3000 particles because its small physical contact +disk is comparable to the smoothing transition. The wall acceleration is exactly zero for both +90-degree-target cases and total fixed-particle signs are 4/4, but those signs do not make the model +eligible after the area gate fails. + +### Selection + +No candidate passes all five middle and endpoint measure gates. Consequently no R6 candidate +advances to dynamics, `contact_model=nothing` remains the default, G3 stays open, and Phase 4 wetting +work remains blocked. The compact decision table is +`validation/surface_tension_3d/contact_angle_recovery_comparison.csv`. + +## R7 Pre-Registered Continuation + +The strict R6 endpoint rule rejects the exact-profile cap control in four of five angles. R7 first +tests whether this is lattice-phase quadrature rather than formulation error. Every new cap measure +is averaged over eight rank-1 horizontal phases, while the wall-normal phase and the production +`h/dx=1.4` remain fixed. A discarded four-phase diagonal control had zero useful variance because +all samples were related by square-lattice reflection symmetry; no candidate had run when this was +corrected. The 20% middle gate is unchanged. A fine endpoint passes only when it is within 20% and +is no more than two combined phase standard errors worse than the coarse endpoint. + +An independent exact-profile series holds physical `h` fixed and increases `h/dx` through +`{1.4, 2.8, 4.2}`. This converges to the exact profiles' nonzero curvature-smoothing bias, not zero; +the control therefore requires the fine-to-middle change not to exceed the middle-to-coarse change +by more than two combined phase standard errors and a fine error below 20%. This control-only +correction and the final phase set were recorded before +generating any R7 candidate evidence. + +Three validation-only formulations then run regardless of earlier outcomes: + +1. The wetted-area energy uses the kernel half-space convolution as its flooded reference. Its edge + correction is the integral displacement of the canonical wedge profile after applying the same + cubic smoothstep as the discrete area. Differentiating the corrected radius supplies the force + chain-rule factor; no cap result is fitted. +2. The compatible continuation is paired with the exact plate normal and canonical wall profile, + completing the missing R6-C geometry-normal combination. +3. A Young color boundary condition extrapolates exposed-wall wetness into ghost layers with the + prescribed normal derivative before the SPH gradient is assembled. Its clamped 0/180-degree + limits remain finite. This is not the rejected post-assembly normal rotation. + +R7 writes new evidence and leaves every production contact model and default unchanged. + +## R7 Results + +The exact-profile control validates the amended protocol. Its production series passes all five +middle and uncertainty-aware endpoint checks. At fixed physical smoothing length, all five +`h/dx={1.4,2.8,4.2}` series remain within 20% and satisfy the uncertainty-aware Cauchy check. The +eight phases expose real lattice variance while preserving the fluid-wall gap. + +### Corrected wetted-area energy + +The kernel half-space reference is about 7.69% above the maximum colorfield found on these finite +caps. The canonical wedge calculation gives edge displacements in units of `h` of approximately +`{-0.5072,-0.1747,0,0.1747,0.5072}` at `{30,60,90,120,150}` degrees. Applying the displacement to +the effective wetted radius and differentiating that correction gives: + +| Target | Middle corrected area error | +|---:|---:| +| 30 deg | 5.68% | +| 60 deg | 4.14% | +| 90 deg | 3.66% | +| 120 deg | 2.77% | +| 150 deg | 1.99% | + +All five middle and endpoint gates pass. The corrected energy force gives `4/4` correctly directed +total fixed-cap accelerations and is exactly zero for both 90-degree-target cases. R7-W is therefore +the only candidate eligible to enter R4 dynamics. This does not yet justify a production +implementation: the dynamic replay, full static matrix, equal/opposite wall reaction, and treatment +of the density dependence in the colorfield derivative remain unresolved. + +### Compatible geometry wall + +Pairing compatible continuation with the exact plate normal and canonical wall profile passes all +five middle line-length errors but only two endpoint gates. Its line-weighted reconstructed angles +pass none of the five middle or endpoint angle checks. Its target-only wall energy has `4/4` total +fixed-cap signs, but measure-first eligibility fails, so R7-CG is rejected. + +### Young color boundary + +The scalar ghost continuation is finite and bounded in `[0,1]`. It passes all five middle and +endpoint line-measure checks, showing that it produces a coherent contact-line integral. It does not +impose the requested normal accurately enough: only two middle and one endpoint angle checks pass. +It also gives only `3/4` total and `1/4` contact-induced fixed-cap signs. R7-Y is rejected; passing a +line integral alone is not sufficient evidence for a wetting boundary condition. + +Evidence: + +- `validation/surface_tension_3d/contact_measure_protocol.csv` +- `validation/surface_tension_3d/wetted_area_corrected.csv` +- `validation/surface_tension_3d/contact_angle_recovery_extended.csv` +- `validation/surface_tension_3d/contact_angle_force_sign_extended.csv` +- `validation/surface_tension_3d/contact_angle_recovery_extended_comparison.csv` + +No production source, cache, API, or default changed in R7. `contact_model=nothing` remained the +constructor default while R7-W advanced to the R4 work below. + +## R4-W Pre-registration + +R4-W keeps the corrected wetted-area model validation-only. The implementation is loaded by the +sessile-drop workbench and extends the existing contact hooks without changing `src/`, exported +types, or constructor defaults. The following discrete energy and gates are frozen before any R4-W +dynamic result is generated. + +For exposed top-layer wall particles `b`, define + +```math +c_b=\sum_a \frac{m_a}{\rho_a}W_{ab},\qquad +\xi_b=\operatorname{clamp}(c_b/c_\infty,0,1),\qquad +A_0=\Delta A\sum_b s(\xi_b), +``` + +where `s(x)=x^2(3-2x)`, `Delta A=dx^2`, and `c_inf` is the kernel half-space convolution at the +exposed-layer depth for the reference particle volume. `c_inf` is fixed during a run; only the +actual particle volumes in `c_b` carry density dependence. With the precomputed canonical R7 edge +displacement `d_theta`, + +```math +r_0=\sqrt{A_0/\pi},\qquad +r=\max(r_0-hd_\theta,0),\qquad +A_h=\pi r^2,\qquad +E_h=-\sigma\cos(\theta)A_h. +``` + +No measured cap radius, fitted gain, angle offset, or case-dependent coefficient enters this +energy. The canonical wedge correction is singular at exactly 0 and 180 degrees, so this +validation model deliberately accepts only open-interval targets; those limits require a separate +derivation before any production proposal. + +For `r_0>0` and `r>0`, let `g=dA_h/dA_0=r/r_0` and + +```math +\lambda_b=\frac{g\Delta A}{c_\infty}s'(\xi_b),\qquad +S_a=\sum_b\lambda_bW_{ab},\qquad +q_a=\frac{\sigma\cos(\theta)}{\rho_a^2}S_a. +``` + +The derivative of the clamp is zero outside its open interval. The complete acceleration is the +sum of the explicit fluid-wall derivative and the density derivative, + +```math +\boldsymbol a_a^{wall} +=\frac{\sigma\cos(\theta)}{\rho_a} + \sum_b\lambda_b\nabla_a W_{ab}, +\qquad +\boldsymbol a_a^{rho} +=-\sum_c m_c\left(q_a\frac{\rho_a}{\rho_c} + +q_c\frac{\rho_c}{\rho_a}\right)\nabla_a W_{ac}. +``` + +Each explicit pair contribution is accumulated with an equal and opposite wall force. The +fluid-fluid density term uses the symmetric pressure operator paired with the workbench's +`ContinuityDensity` equation and must have zero resultant by itself. Equivalently, its auxiliary +pressure is `p_a^*=rho_a^2 q_a=sigma*cos(theta)*S_a`. At 90 degrees the energy, both acceleration +terms, and wall reaction are exactly zero. + +The staged gates are: + +1. Algebra/static gate: finite energy and forces; directional energy-gradient agreement within + `1e-5` relative error; explicit fluid-wall reaction residual and density-term resultant at most + `1e-12` relative to their force scales; exact zero at 90 degrees; and `4/4` restoring total-force + signs for the established 1500-particle off-target caps. +2. Perturbation gate: the unchanged `t=0.01 s`, damping `4000 s^-1`, threshold `0.1`, and + 1500-particle protocol must pass all four complete responses against fresh no-contact controls. + If all acceleration signs pass but angle motion is below estimator resolution, exactly one + uniform final-time extension may be applied to all four cases. +3. Static replay gate: all five target-initialized threshold-0.1 cases must remain within 5 degrees, + settled, penetration-free, within `980--1020 kg/m^3`, and below 25% rejected steps. +4. Efficiency gate: rerun the existing `(90 degrees,1500)` and `(30 degrees,3000)` timestep cases, + requiring `eta_p01>=0.05` and tail/head median `eta>=0.5`, then record three interleaved + no-contact/model cost repetitions and the validation-cache bytes. +5. Full replay gate: only after gates 1--4 pass, run the 15-cell `{750,1500,3000}` matrix and the + existing threshold/damping sensitivity matrix. G3 remains open until every Phase 3 completion + condition passes. + +Each stage writes a new `*_r4_wetted_area.csv` file and never replaces evidence for the rejected +geometric or contact-line-force models. Execution stops at the first failed stage. + +### R4-W perturbation protocol correction + +The first `0.01 s` run exposed a logical error in the inherited CLF response classifier, not a +force-formulation choice. It required every candidate to beat the no-contact control and to have a +nonzero contact-induced acceleration. For `theta=90 degrees`, however, the pre-registered energy is +identically zero, so R4-W and no contact must be exactly equivalent. Those two requirements were +therefore impossible by construction and contradicted the exact-zero gate above. + +The original CSV is preserved. Before any longer run, the classifier is corrected as follows: + +- non-90-degree targets retain the strict contact-induced acceleration and beats-control tests; +- 90-degree targets require zero contact-induced acceleration, exact equality with the no-contact + trajectory, and a correctly directed **total** CSS acceleration; +- all safety and reaction gates remain unchanged. + +With this formulation-consistent classifier, the initial run has four correctly directed effective +accelerations and three resolved motions; the unresolved motion is below 1 degree. This satisfies +the pre-registered condition for one uniform extension. The sole extension is frozen at `0.02 s` +for all four candidate/control pairs and writes a separate `*_extended.csv`; no initial evidence is +overwritten. + +## R4-W Results + +The complete density-dependent energy derivative passes every validation-only R4-W gate. The five +target-initialized algebra cases and four off-target force cases pass `9/9`; the worst directional +energy-gradient error is `4.89e-10`, versus the pre-registered `1e-5` tolerance. The largest initial +reaction/resultant residual is `1.22e-15`, and the 90-degree energy, acceleration, and reaction are +exactly zero. + +The preserved initial perturbation CSV reports `2/4` under the contradictory inherited CLF +classifier. The formulation-consistent reclassification gives `4/4` effective acceleration signs +and `3/4` resolved motions. The single uniform `0.02 s` extension then passes all `4/4` complete +responses. Both 90-degree candidate trajectories are exactly equal to their no-contact controls, as +required by the zero Young wall energy. + +The remaining staged results are: + +| Gate | Result | +|---|---:| +| Threshold-0.1 target replay | `5/5` | +| Representative timestep cases | `2/2` | +| Repeated median runtime overhead | `2.0%` at zero-force 90 deg; `30.5%` at active 60 deg | +| Selected `{750,1500,3000}` matrix | `15/15` | +| Threshold/damping sensitivity | `4/4`; `0.107 deg` span | + +The inherited repeated-cost case uses a 90-degree target, where this formulation is exactly +disabled, so its 2.0% overhead is only a zero-force-path measurement. Before production promotion, +the same interleaved protocol was repeated at an active 60-degree target and records 30.5% median +overhead. Cost was pre-registered as a measurement rather than a hard acceptance threshold; both +files are retained. + +Across the selected matrix, the largest final circle-angle error is `4.33 deg`, density remains in +`[997.07,1000.02] kg/m^3`, maximum RMS speed is `4.32e-3 m/s`, and maximum rejected-step fraction is +15.2%. The largest recorded total momentum residual is `4.03e-15`. No target-dependent gain, +measured cap radius, angle offset, or case-specific damping was introduced. + +Evidence: + +- `validation/surface_tension_3d/contact_angle_static_r4_wetted_area.csv` +- `validation/surface_tension_3d/contact_angle_perturbation_r4_wetted_area.csv` +- `validation/surface_tension_3d/contact_angle_perturbation_r4_wetted_area_classified.csv` +- `validation/surface_tension_3d/contact_angle_perturbation_r4_wetted_area_extended.csv` +- `validation/surface_tension_3d/contact_angle_threshold_r4_wetted_area.csv` +- `validation/surface_tension_3d/contact_angle_timestep_r4_wetted_area.csv` +- `validation/surface_tension_3d/contact_angle_cost_r4_wetted_area.csv` +- `validation/surface_tension_3d/contact_angle_cost_r4_wetted_area_active.csv` +- `validation/surface_tension_3d/contact_angle_selected_matrix_r4_wetted_area.csv` +- `validation/surface_tension_3d/contact_angle_sensitivity_r4_wetted_area.csv` + +R4-W is now eligible for a separate production-integration decision. It remains validation-only in +`compare_akinci/corrected_wetted_area_contact.jl`; no `src/` cache, API, export, or default changed, +and G3 remains open until that integration, D3/D5 cleanup, complete tests, and documentation pass. + +## Production Integration Pre-registration + +The production candidate is frozen as `WettedAreaContactAngle(theta)` and remains an explicit +`ColorfieldSurfaceNormal(contact_model=...)` choice. `ColorfieldSurfaceNormal()` continues to mean +no wetting model. Initial production support is deliberately limited to the configuration validated +by R4-W: + +- three dimensions, `ContinuityDensity`, `WendlandC2Kernel{3}`, and `h/dx=1.4`; +- one fluid and one or more dummy-particle wall or rigid-body systems; +- explicit nonnegative `surface_measure` values on each contact boundary and + `InitialCondition.normals` whose magnitudes give the particle-to-physical-wall offsets; +- one connected disk-like wetted patch per boundary system for the canonical edge correction; +- target angles strictly inside `(0,180)` degrees, with exact zero energy and force at 90 degrees. + +The surface measure makes quadrature independent of global wall orientation and permits curved, +moving, and rigid surfaces when the caller supplies appropriate particle areas. It does not claim +support for disconnected contact patches on one boundary, changing topology, arbitrary kernels, or +unvalidated smoothing-length ratios. + +Production cache ownership is also frozen. Fluid caches hold only the density conjugate, edge shift, +energy, and aggregate diagnostics. Each dummy boundary owns immutable surface measures plus transient +area weights and reaction forces. The colorfield/area pass computes the density conjugate once; +the density force is fused into the existing fluid-fluid RHS and the explicit derivative into the +existing fluid-boundary RHS. Fixed walls retain reaction diagnostics, while rigid bodies accumulate +the same reaction in `force_per_particle`. + +Promotion gates are unchanged R4 physics plus: + +1. constructor/configuration tests for dimensions, kernel, ratio, density formulation, quadrature, + endpoints, Float32, multiple walls, moving orientation, and rigid reaction force/torque; +2. finite-difference energy gradients, exact 90-degree cancellation, and `1e-12` momentum/reaction + residuals using only production caches and RHS paths; +3. active 60-degree median runtime overhead at most 20% over no contact, measured with the same three + interleaved repetitions; +4. complete production-only static, perturbation, threshold, timestep, selected, and sensitivity + replay in new `*_production.csv` files; +5. only after those pass, delete the unshipped rejected `GeometricContactAngle` and + `ContactLineForce` implementations under D5, update all public prose/examples/metadata, and close + G3 after the full verification suite. + +## Production Integration Result + +`WettedAreaContactAngle(theta)` now implements the frozen energy and complete derivative in +production. Boundary models accept explicit `surface_measure` quadrature, and configuration checks +enforce the validated dimension, kernel, smoothing ratio, density formulation, colors, wall offsets, +and connected-patch contract. The density term is fused into fluid-fluid WCSPH/EDAC interactions; +the explicit term and thread-local fixed-wall reaction are fused into fluid-boundary interactions. +Rigid reactions use the existing reverse rigid-fluid interaction and enter `force_per_particle`. + +Production-only evidence passes static algebra `9/9` with maximum directional-gradient error +`3.59e-10`, the frozen extended perturbation `4/4`, threshold `5/5`, timestep `2/2`, selected matrix +`15/15`, and sensitivity `4/4` with `0.107` degree span. The exactly disabled 90-degree path has +0.4% median overhead; active 60-degree median overhead is 16.0%, passing the pre-registered 20% +gate. The rejected unshipped geometric and contact-line-force implementations were then deleted; +`contact_model=nothing` remains unchanged. Final verification passes 125/125 validation checks, +23063/23063 unit and Aqua checks, the documentation build, formatting, and relevant changed examples; +G3 is closed. diff --git a/compare_akinci/corrected_wetted_area_contact.jl b/compare_akinci/corrected_wetted_area_contact.jl new file mode 100644 index 0000000000..c3054c9908 --- /dev/null +++ b/compare_akinci/corrected_wetted_area_contact.jl @@ -0,0 +1,309 @@ +""" +Validation-only corrected wetted-area contact energy used by the R4-W workbench. + +The force cache lives in this mutable model so validation does not change production caches or the +public API. +""" +mutable struct CorrectedWettedAreaContact{T <: AbstractFloat} <: + TrixiParticles.AbstractContactAngleModel + contact_angle::T + normalized_edge_shift::T + flooded_reference::T + explicit_force::Matrix{T} + density_force::Matrix{T} + wall_reaction::Matrix{T} + wall_weight::Vector{T} + density_conjugate::Vector{T} + exposed::BitVector + raw_area::T + corrected_area::T + area_derivative::T + energy::T + explicit_reaction_residual::T + density_resultant::T + total_momentum_residual::T + max_explicit_reaction_residual::T + max_density_resultant::T + max_total_momentum_residual::T + evaluations::Int +end + +function CorrectedWettedAreaContact(contact_angle, normalized_edge_shift, + flooded_reference) + angle = TrixiParticles.validate_contact_angle(contact_angle) + 0 < angle < 180 || + throw(ArgumentError("R4-W requires `contact_angle` strictly inside (0, 180) degrees")) + isfinite(normalized_edge_shift) || + throw(ArgumentError("`normalized_edge_shift` must be finite")) + isfinite(flooded_reference) && flooded_reference > 0 || + throw(ArgumentError("`flooded_reference` must be finite and positive")) + + angle_, shift_, + reference_ = promote(float(angle), float(normalized_edge_shift), + float(flooded_reference)) + T = typeof(angle_) + return CorrectedWettedAreaContact{T}(angle_, shift_, reference_, zeros(T, 0, 0), + zeros(T, 0, 0), zeros(T, 0, 0), T[], T[], + BitVector(), zero(T), zero(T), zero(T), zero(T), + zero(T), zero(T), zero(T), zero(T), zero(T), + zero(T), 0) +end + +@inline function r4_wetted_area_smoothstep(value) + return value^2 * (3 - 2value) +end + +@inline function r4_wetted_area_smoothstep_derivative(value) + return 6value * (1 - value) +end + +@inline function r4_contact_cosine(model::CorrectedWettedAreaContact) + model.contact_angle == 90 && return zero(model.contact_angle) + return cosd(model.contact_angle) +end + +function TrixiParticles.convert_contact_model(model::CorrectedWettedAreaContact, ELTYPE) + return CorrectedWettedAreaContact(convert(ELTYPE, model.contact_angle), + convert(ELTYPE, model.normalized_edge_shift), + convert(ELTYPE, model.flooded_reference)) +end + +function TrixiParticles.create_cache_surface_normal(surface_normal_method::TrixiParticles.ColorfieldSurfaceNormal{<:Any, + <:CorrectedWettedAreaContact}, + ELTYPE, NDIMS, nparticles) + cache = TrixiParticles.create_cache_surface_normal(TrixiParticles.ColorfieldSurfaceNormal(), + ELTYPE, NDIMS, nparticles) + boundary_normal = Array{ELTYPE, 2}(undef, NDIMS, nparticles) + return (; cache..., boundary_normal) +end + +function resize_r4_wetted_area_cache!(model, ndims, nfluid, nwall) + if size(model.explicit_force) != (ndims, nfluid) + model.explicit_force = zeros(eltype(model.explicit_force), ndims, nfluid) + model.density_force = zeros(eltype(model.density_force), ndims, nfluid) + model.density_conjugate = zeros(eltype(model.density_conjugate), nfluid) + else + fill!(model.explicit_force, 0) + fill!(model.density_force, 0) + fill!(model.density_conjugate, 0) + end + + if size(model.wall_reaction) != (ndims, nwall) + model.wall_reaction = zeros(eltype(model.wall_reaction), ndims, nwall) + model.wall_weight = zeros(eltype(model.wall_weight), nwall) + model.exposed = falses(nwall) + else + fill!(model.wall_reaction, 0) + fill!(model.wall_weight, 0) + fill!(model.exposed, false) + end + return model +end + +function r4_force_resultant(forces) + resultant = zeros(eltype(forces), size(forces, 1)) + scale = zero(eltype(forces)) + for particle in axes(forces, 2) + force_norm2 = zero(eltype(forces)) + for dim in axes(forces, 1) + value = forces[dim, particle] + resultant[dim] += value + force_norm2 += value^2 + end + scale += sqrt(force_norm2) + end + return resultant, scale +end + +@inline function r4_relative_residual(residual, scale) + residual_norm = sqrt(sum(abs2, residual)) + iszero(scale) && return iszero(residual_norm) ? zero(scale) : oftype(scale, Inf) + return residual_norm / scale +end + +function r4_wetted_area_boundary_system(semi) + boundary_system = nothing + for candidate in semi.systems + candidate isa TrixiParticles.AbstractBoundarySystem || continue + hasproperty(candidate, :boundary_model) || continue + haskey(candidate.boundary_model.cache, :colorfield) || continue + isnothing(boundary_system) || + throw(ArgumentError("R4-W validation currently requires exactly one colorfield boundary")) + boundary_system = candidate + end + isnothing(boundary_system) && + throw(ArgumentError("R4-W validation requires one colorfield boundary")) + return boundary_system +end + +function TrixiParticles.compute_contact_angle_cache!(system::TrixiParticles.AbstractFluidSystem, + surface_normal_method::TrixiParticles.ColorfieldSurfaceNormal{<:Any, + <:CorrectedWettedAreaContact}, + v, u, v_ode, u_ode, semi) + TrixiParticles.ndims(system) == 3 || + throw(ArgumentError("R4-W corrected wetted area is currently defined only in 3D")) + + model = surface_normal_method.contact_model + boundary_system = r4_wetted_area_boundary_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) + nfluid = TrixiParticles.nparticles(system) + nwall = TrixiParticles.nparticles(boundary_system) + ndims = TrixiParticles.ndims(system) + resize_r4_wetted_area_cache!(model, ndims, nfluid, nwall) + + particle_spacing = system.cache.reference_particle_spacing + particle_area = particle_spacing^2 + smoothing_length = TrixiParticles.initial_smoothing_length(system) + exposed_height = maximum(@view boundary_coordinates[3, :]) + height_tolerance = 10eps(abs(exposed_height) + particle_spacing) + colorfield = boundary_system.boundary_model.cache.colorfield + + raw_area = zero(eltype(system)) + for boundary_particle in TrixiParticles.eachparticle(boundary_system) + exposed = isapprox(boundary_coordinates[3, boundary_particle], exposed_height; + atol=height_tolerance) + model.exposed[boundary_particle] = exposed + exposed || continue + fraction = clamp(colorfield[boundary_particle] / model.flooded_reference, 0, 1) + raw_area += particle_area * r4_wetted_area_smoothstep(fraction) + end + + raw_radius = sqrt(raw_area / pi) + edge_shift = model.normalized_edge_shift * smoothing_length + 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) + for boundary_particle in TrixiParticles.eachparticle(boundary_system) + model.exposed[boundary_particle] || continue + fraction = colorfield[boundary_particle] / model.flooded_reference + 0 < fraction < 1 || continue + model.wall_weight[boundary_particle] = area_derivative * particle_area / + model.flooded_reference * + r4_wetted_area_smoothstep_derivative(fraction) + end + + model.raw_area = raw_area + model.corrected_area = corrected_area + model.area_derivative = area_derivative + contact_cosine = r4_contact_cosine(model) + surface_tension_coefficient = system.surface_tension.surface_tension_coefficient + coefficient = surface_tension_coefficient * contact_cosine + model.energy = iszero(contact_cosine) ? zero(contact_cosine) : + -coefficient * corrected_area + + if !iszero(coefficient) + # Differentiate each exposed-wall color sample explicitly and retain its opposite + # reaction. The kernel-value sum becomes the density conjugate q_a. + 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 + wall_weight = model.wall_weight[neighbor] + iszero(wall_weight) && return + density = TrixiParticles.current_density(v, system, particle) + mass = TrixiParticles.hydrodynamic_mass(system, particle) + kernel_value = TrixiParticles.smoothing_kernel(system, distance, particle) + gradient = TrixiParticles.smoothing_kernel_grad(system, pos_diff, distance, + particle) + pair_force = coefficient * mass / density * wall_weight * gradient + for dim in eachindex(pair_force) + model.explicit_force[dim, particle] += pair_force[dim] + model.wall_reaction[dim, neighbor] -= pair_force[dim] + end + model.density_conjugate[particle] += wall_weight * kernel_value + end + + for particle in TrixiParticles.eachparticle(system) + density = TrixiParticles.current_density(v, system, particle) + model.density_conjugate[particle] *= coefficient / density^2 + end + + # Pair q_a with the ContinuityDensity pressure operator. Accumulating one force per + # unordered pair makes the fluid-fluid resultant vanish by construction. + 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 + neighbor > particle || return + mass_a = TrixiParticles.hydrodynamic_mass(system, particle) + mass_b = TrixiParticles.hydrodynamic_mass(system, neighbor) + density_a = TrixiParticles.current_density(v, system, particle) + density_b = TrixiParticles.current_density(v, system, neighbor) + gradient = TrixiParticles.smoothing_kernel_grad(system, pos_diff, distance, + particle) + pair_coefficient = model.density_conjugate[particle] * density_a / density_b + + model.density_conjugate[neighbor] * density_b / density_a + pair_force = -mass_a * mass_b * pair_coefficient * gradient + for dim in eachindex(pair_force) + model.density_force[dim, particle] += pair_force[dim] + model.density_force[dim, neighbor] -= pair_force[dim] + end + end + end + + explicit_resultant, explicit_scale = r4_force_resultant(model.explicit_force) + density_resultant, density_scale = r4_force_resultant(model.density_force) + wall_resultant, wall_scale = r4_force_resultant(model.wall_reaction) + model.explicit_reaction_residual = r4_relative_residual(explicit_resultant + + wall_resultant, + explicit_scale + wall_scale) + model.density_resultant = r4_relative_residual(density_resultant, density_scale) + model.total_momentum_residual = r4_relative_residual(explicit_resultant + + density_resultant + + wall_resultant, + explicit_scale + density_scale + + wall_scale) + model.max_explicit_reaction_residual = max(model.max_explicit_reaction_residual, + model.explicit_reaction_residual) + model.max_density_resultant = max(model.max_density_resultant, + model.density_resultant) + model.max_total_momentum_residual = max(model.max_total_momentum_residual, + model.total_momentum_residual) + model.evaluations += 1 + return system +end + +@inline function TrixiParticles.contact_angle_acceleration(surface_tension::Union{TrixiParticles.SurfaceTensionMorris, + TrixiParticles.SurfaceTensionMomentumMorris}, + particle_system, + surface_normal_method::TrixiParticles.ColorfieldSurfaceNormal{<:Any, + <:CorrectedWettedAreaContact}, + particle, rho_a, + vector_template) + model = surface_normal_method.contact_model + explicit_force = TrixiParticles.extract_svector(model.explicit_force, particle_system, + particle) + density_force = TrixiParticles.extract_svector(model.density_force, particle_system, + particle) + mass = TrixiParticles.hydrodynamic_mass(particle_system, particle) + return (explicit_force + density_force) / mass +end + +corrected_wetted_area_contact_diagnostics(model) = nothing + +function corrected_wetted_area_contact_diagnostics(model::CorrectedWettedAreaContact) + explicit_resultant, explicit_scale = r4_force_resultant(model.explicit_force) + density_resultant, density_scale = r4_force_resultant(model.density_force) + wall_resultant, wall_scale = r4_force_resultant(model.wall_reaction) + return (; energy=model.energy, raw_area=model.raw_area, + corrected_area=model.corrected_area, area_derivative=model.area_derivative, + explicit_resultant, density_resultant, wall_resultant, + explicit_force_scale=explicit_scale, density_force_scale=density_scale, + wall_force_scale=wall_scale, + explicit_reaction_residual=model.explicit_reaction_residual, + density_resultant_residual=model.density_resultant, + total_momentum_residual=model.total_momentum_residual, + max_explicit_reaction_residual=model.max_explicit_reaction_residual, + max_density_resultant_residual=model.max_density_resultant, + max_total_momentum_residual=model.max_total_momentum_residual, + evaluations=model.evaluations, cache_bytes=Base.summarysize(model)) +end diff --git a/compare_akinci/css_sessile_drop.jl b/compare_akinci/css_sessile_drop.jl new file mode 100644 index 0000000000..76102b3da1 --- /dev/null +++ b/compare_akinci/css_sessile_drop.jl @@ -0,0 +1,421 @@ +using LinearAlgebra +using OrdinaryDiffEqLowStorageRK +using Printf +using Serialization +using Statistics +using TrixiParticles + +include(joinpath(@__DIR__, "boundary_volume.jl")) +include(joinpath(@__DIR__, "simulate.jl")) +include(joinpath(@__DIR__, "corrected_wetted_area_contact.jl")) + +function wetted_area_contact_diagnostics(model, system, boundary_system) + return corrected_wetted_area_contact_diagnostics(model) +end + +function wetted_area_contact_diagnostics(::WettedAreaContactAngle, system, + boundary_system) + cache = system.cache + reaction = boundary_system.boundary_model.cache.wetted_area_reaction + wall_resultant = vec(sum(reaction; dims=2)) + wall_force_scale = sum(particle -> norm(view(reaction, :, particle)), + eachparticle(boundary_system)) + return (; energy=cache.wetted_area_energy[], raw_area=cache.wetted_area_raw_area[], + corrected_area=cache.wetted_area[], wall_resultant, wall_force_scale, + normalized_edge_shift=cache.wetted_area_normalized_edge_shift[], + evaluations=cache.wetted_area_evaluations[], + cache_bytes=Base.summarysize((cache.wetted_area_density_conjugate, + cache.wetted_area_energy, + cache.wetted_area_raw_area, + cache.wetted_area, + cache.wetted_area_normalized_edge_shift, + reaction, + boundary_system.boundary_model.cache.wetted_area_surface_measure, + boundary_system.boundary_model.cache.wetted_area_weight, + boundary_system.boundary_model.cache.wetted_area_flooded_reference))) +end + +function spherical_cap_initial_condition(contact_angle; drop_volume=1.0e-6, + target_particle_count=750, + reference_density=1000.0, + surface_tension_coefficient=0.072, + lattice_phase=(0.0, 0.0)) + cosine = cosd(contact_angle) + volume_factor = (1 - cosine)^2 * (2 + cosine) + sphere_radius = cbrt(3drop_volume / (pi * volume_factor)) + sphere_center_z = -sphere_radius * cosine + particle_spacing = cbrt(drop_volume / target_particle_count) + + cap_radius = sphere_radius * sind(contact_angle) + horizontal_radius = contact_angle <= 90 ? cap_radius : sphere_radius + cap_height = sphere_radius * (1 - cosine) + n_horizontal = 2ceil(Int, horizontal_radius / particle_spacing) + n_vertical = ceil(Int, cap_height / particle_spacing) + horizontal_x = ((1:n_horizontal) .- (n_horizontal + 1) / 2 .+ + lattice_phase[1]) .* particle_spacing + horizontal_y = ((1:n_horizontal) .- (n_horizontal + 1) / 2 .+ + lattice_phase[2]) .* particle_spacing + vertical = ((1:n_vertical) .- 0.5) .* particle_spacing + coordinates = [SVector(x, y, z) + for z in vertical, y in horizontal_y, x in horizontal_x + if x^2 + y^2 + (z - sphere_center_z)^2 <= sphere_radius^2] + coordinates = reduce(hcat, coordinates) + mass = fill(reference_density * particle_spacing^3, size(coordinates, 2)) + + pressure_jump = 2surface_tension_coefficient / sphere_radius + state_equation = StateEquationCole(; sound_speed=100.0, reference_density, + exponent=7, clip_negative_pressure=true) + initial_density = TrixiParticles.inverse_state_equation(state_equation, pressure_jump) + initial_condition = InitialCondition(; + coordinates, + velocity=zeros(3, size(coordinates, 2)), mass, + density=fill(initial_density, + size(coordinates, 2)), + particle_spacing) + + return (; initial_condition, state_equation, sphere_radius, sphere_center_z, + cap_radius, horizontal_radius, pressure_jump, lattice_phase) +end + +function apparent_spherical_cap_angle(coordinates, volume, particle_spacing) + height = maximum(coordinates[3, :]) + particle_spacing / 2 + radius_squared = max(2volume / (pi * height) - height^2 / 3, zero(height)) + base_radius = sqrt(radius_squared) + angle = 2atand(height / base_radius) + return (; angle, height, base_radius) +end + +function local_circle_contact_angle(coordinates, interface, support_radius, + particle_spacing) + radial = sqrt.(coordinates[1, :] .^ 2 .+ coordinates[2, :] .^ 2) + near_wall_particles = findall(interface .& + (coordinates[3, :] .>= -particle_spacing) .& + (coordinates[3, :] .<= 2support_radius)) + length(near_wall_particles) >= 3 || + return (; angle=NaN, radius=NaN, center_z=NaN, residual=Inf, particles=0) + + # Reduce each horizontal particle layer to its outer meridional envelope. + layers = Dict{Int, Vector{Int}}() + for particle in near_wall_particles + # Initial particles are centered at half-integer heights. Floor-based bins keep + # mildly deformed rows together instead of splitting them at rounding ties. + layer = floor(Int, coordinates[3, particle] / particle_spacing) + push!(get!(layers, layer, Int[]), particle) + end + length(layers) >= 3 || + return (; angle=NaN, radius=NaN, center_z=NaN, residual=Inf, + particles=length(layers)) + + z = [mean(coordinates[3, particles]) for particles in values(layers)] + r = [quantile(radial[particles], 0.9) for particles in values(layers)] + design = hcat(2z, ones(length(z))) + rhs = r .^ 2 .+ z .^ 2 + center_z, intercept = design \ rhs + radius_squared = intercept + center_z^2 + radius_squared > 0 || + return (; angle=NaN, radius=NaN, center_z, residual=Inf, + particles=length(layers)) + radius = sqrt(radius_squared) + angle = acosd(clamp(-center_z / radius, -1, 1)) + residual = sqrt(mean(abs2, design * [center_z, intercept] - rhs)) / radius^2 + return (; angle, radius, center_z, residual, particles=length(layers)) +end + +function cap_shape_acceleration(acceleration, coordinates) + return 2mean(coordinates[3, :] .* acceleration[3, :] .- + (coordinates[1, :] .* acceleration[1, :] .+ + coordinates[2, :] .* acceleration[2, :]) ./ 2) +end + +function initial_acceleration_diagnostics(semi, fluid_system, boundary_system, ode) + v_ode, u_ode = ode.u0.x + dv_ode = zero(v_ode) + # The first pass initializes all surface and boundary interaction caches. + TrixiParticles.kick!(dv_ode, v_ode, u_ode, ode.p, 0.0) + TrixiParticles.kick!(dv_ode, v_ode, u_ode, ode.p, 0.0) + 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 = TrixiParticles.wrap_v(dv_ode, fluid_system, semi) + coordinates = TrixiParticles.current_coordinates(u, fluid_system) + boundary_coordinates = TrixiParticles.current_coordinates(u_boundary, boundary_system) + boundary_acceleration = zeros(eltype(fluid_system), 3, + size(coordinates, 2)) + + TrixiParticles.foreach_point_neighbor(fluid_system, boundary_system, coordinates, + boundary_coordinates, + semi) do particle, neighbor, pos_diff, + distance + rho_a = TrixiParticles.current_density(v, fluid_system, particle) + rho_b = TrixiParticles.current_density(v_boundary, boundary_system, neighbor) + grad_kernel = TrixiParticles.smoothing_kernel_grad(fluid_system, pos_diff, + distance, particle) + acceleration = Ref(zero(pos_diff)) + TrixiParticles.surface_tension_force!(acceleration, fluid_system.surface_tension, + nothing, fluid_system, boundary_system, + particle, neighbor, pos_diff, distance, + rho_a, rho_b, grad_kernel, 1) + boundary_acceleration[:, particle] .+= acceleration[] + end + + total_acceleration = Array(dv[1:3, :]) + return (; total=cap_shape_acceleration(total_acceleration, coordinates), + boundary=cap_shape_acceleration(boundary_acceleration, coordinates)) +end + +function css_sessile_drop(contact_angle, final_time, output_path=nothing; + drop_volume=1.0e-6, target_particle_count=750, + reference_density=1000.0, + surface_tension_coefficient=0.072, + damping_coefficient=10.0, + mechanism=:wetted_area, + initial_contact_angle=contact_angle, + boundary_contact_threshold=0.0, + lattice_phase=(0.0, 0.0), + smoothing_length_ratio=1.4, + validation_contact_model=nothing, + parallelization_backend=SerialBackend()) + setup = spherical_cap_initial_condition(initial_contact_angle; drop_volume, + target_particle_count, + reference_density, + surface_tension_coefficient, + lattice_phase) + (; initial_condition, state_equation) = setup + particle_spacing = initial_condition.particle_spacing + smoothing_kernel = WendlandC2Kernel{3}() + smoothing_length = smoothing_length_ratio * particle_spacing + surface_tension = SurfaceTensionMomentumMorris(; surface_tension_coefficient) + contact_model = if mechanism == :corrected_wetted_area + isnothing(validation_contact_model) && + throw(ArgumentError("`:corrected_wetted_area` requires `validation_contact_model`")) + validation_contact_model + elseif mechanism == :wetted_area + WettedAreaContactAngle(contact_angle) + elseif mechanism == :none + nothing + else + throw(ArgumentError("unknown contact-angle `mechanism`: $mechanism")) + end + surface_normal_method = ColorfieldSurfaceNormal(; + boundary_contact_threshold, + interface_threshold=0.01, + ideal_density_threshold=0.95, + contact_model) + active_contact_model = surface_normal_method.contact_model + viscosity = ArtificialViscosityMonaghan(; alpha=0.2, beta=0.0) + source_terms = SourceTermDamping(; damping_coefficient) + + fluid_system = WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel, + smoothing_length, + density_calculator=ContinuityDensity(), + density_diffusion=DensityDiffusionAntuono(; + delta=0.1), + state_equation, + viscosity, surface_tension, + surface_normal_method, + reference_particle_spacing=particle_spacing, + source_terms) + + target_setup = spherical_cap_initial_condition(contact_angle; drop_volume, + target_particle_count, + reference_density, + surface_tension_coefficient) + horizontal_radius = max(setup.horizontal_radius, target_setup.horizontal_radius) + plate_size = max(4horizontal_radius, 12particle_spacing) + n_plate = round.(Int, (plate_size, plate_size) ./ particle_spacing) + plate_raw = RectangularShape(particle_spacing, (n_plate..., 3), + (-plate_size / 2, -plate_size / 2, + -3particle_spacing); + density=reference_density) + surface_measure = nothing + plate = plate_raw + if active_contact_model isa WettedAreaContactAngle + exposed_height = maximum(plate_raw.coordinates[3, :]) + exposed = isapprox.(plate_raw.coordinates[3, :], exposed_height; + atol=10eps(abs(exposed_height) + particle_spacing)) + normals = zeros(eltype(plate_raw), size(plate_raw.coordinates)) + normals[3, exposed] .= -particle_spacing / 2 + surface_measure = zeros(eltype(plate_raw), nparticles(plate_raw)) + surface_measure[exposed] .= particle_spacing^2 + plate = InitialCondition(; coordinates=plate_raw.coordinates, + velocity=plate_raw.velocity, mass=plate_raw.mass, + density=plate_raw.density, pressure=plate_raw.pressure, + particle_spacing, normals) + end + boundary_mass = akinci_boundary_hydrodynamic_mass(plate, smoothing_kernel, + smoothing_length, + reference_density) + boundary_model = if isnothing(surface_measure) + BoundaryModelDummyParticles(plate; fluid_system, + hydrodynamic_mass=boundary_mass, + boundary_density_calculator=AdamiPressureExtrapolation(), + viscosity, clip_negative_pressure=true) + else + BoundaryModelDummyParticles(plate; fluid_system, + hydrodynamic_mass=boundary_mass, + boundary_density_calculator=AdamiPressureExtrapolation(), + viscosity, clip_negative_pressure=true, + surface_measure) + end + boundary_system = WallBoundarySystem(plate, boundary_model) + + semi = Semidiscretization(fluid_system, boundary_system; parallelization_backend) + ode = semidiscretize(semi, (0.0, final_time)) + acceleration = initial_acceleration_diagnostics(semi, fluid_system, boundary_system, + ode) + initial_contact_diagnostics = wetted_area_contact_diagnostics(active_contact_model, + fluid_system, + boundary_system) + initial_active_interface = fluid_system.cache.delta_s .> 0 + initial_circle = local_circle_contact_angle(initial_condition.coordinates, + initial_active_interface, + TrixiParticles.compact_support(smoothing_kernel, + smoothing_length), + particle_spacing) + saveat = unique([collect(0.0:0.05:final_time); final_time]) + minimum_dt = Ref(Inf) + accepted_dt = Float64[] + record_dt = DiscreteCallback((_, time, _) -> time > 0, + integrator -> begin + dt = abs(integrator.t - integrator.tprev) + minimum_dt[] = min(minimum_dt[], dt) + push!(accepted_dt, dt) + u_modified!(integrator, false) + end; + save_positions=(false, false)) + dtmax = 5.0e-4 + cfl_number = 1.0 + initial_v_ode, initial_u_ode = ode.u0.x + dt_reference = min(dtmax, + TrixiParticles.calculate_dt(initial_v_ode, initial_u_ode, cfl_number, + semi)) + solution = nothing + runtime = @elapsed solution = solve(ode, RDPK3SpFSAL35(); abstol=1.0e-7, + reltol=1.0e-4, dtmax, + save_everystep=false, saveat, + callback=record_dt) + + frames = map(snapshot_frame, solution.u, Iterators.repeated(semi), solution.t) + snapshot = (; times=collect(solution.t), frames) + if !isnothing(output_path) + open(output_path, "w") do io + serialize(io, snapshot) + end + end + + initial_system = frames[1].systems[1] + final_system = frames[end].systems[1] + v_ode, u_ode = last(solution.u).x + TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, final_time) + final_contact_diagnostics = wetted_area_contact_diagnostics(active_contact_model, + fluid_system, + boundary_system) + has_boundary_normal = haskey(fluid_system.cache, :boundary_normal) + boundary_normal = has_boundary_normal ? fluid_system.cache.boundary_normal : + zeros(eltype(fluid_system), 3, nparticles(fluid_system)) + wall_contact = vec(sum(abs2, boundary_normal; dims=1)) .> eps() + active_interface = fluid_system.cache.delta_s .> 0 + active_contact = count(wall_contact .& active_interface) + contact_correction = has_boundary_normal ? + fluid_system.cache.divergence_correction[wall_contact .& active_interface] : + Float64[] + contact_particles = findall(wall_contact .& active_interface) + dynamic_angles = [acosd(clamp(dot(boundary_normal[:, particle], + fluid_system.cache.surface_normal[:, particle]), + -1, 1)) for particle in contact_particles] + normal_weights = fluid_system.cache.delta_s[contact_particles] + measured_contact_angle = isempty(contact_particles) ? NaN : + sum(normal_weights .* dynamic_angles) / sum(normal_weights) + contact_delta = zeros(eltype(fluid_system), nparticles(fluid_system)) + line_particles = findall(>(0), contact_delta) + contact_line_delta_range = isempty(line_particles) ? (NaN, NaN) : + extrema(contact_delta[line_particles]) + line_contact_angle = if isempty(line_particles) + NaN + else + line_angles = [acosd(clamp(dot(boundary_normal[:, particle], + fluid_system.cache.surface_normal[:, particle]), + -1, 1)) for particle in line_particles] + sum(contact_delta[line_particles] .* line_angles) / + sum(contact_delta[line_particles]) + end + initial_volume = sum(initial_condition.mass ./ initial_system.density) + final_volume = sum(initial_condition.mass ./ final_system.density) + initial = apparent_spherical_cap_angle(initial_system.coordinates, initial_volume, + particle_spacing) + final = apparent_spherical_cap_angle(final_system.coordinates, final_volume, + particle_spacing) + circle = local_circle_contact_angle(final_system.coordinates, active_interface, + TrixiParticles.compact_support(smoothing_kernel, + smoothing_length), + particle_spacing) + angle_history = map(frames) do frame + system = frame.systems[1] + volume = sum(initial_condition.mass ./ system.density) + apparent_spherical_cap_angle(system.coordinates, volume, + particle_spacing).angle + end + speed = sqrt.(vec(sum(abs2, final_system.velocity; dims=1))) + below_wall = count(<(0), final_system.coordinates[3, :]) + rms_speed = sqrt(mean(abs2, speed)) + density_range = extrema(final_system.density) + settled = rms_speed < 5.0e-3 + rejected_fraction = solution.stats.nreject / + max(solution.stats.naccept + solution.stats.nreject, 1) + eta_p01 = NaN + eta_median = NaN + eta_tail_head = NaN + if length(accepted_dt) > 7 + eta = accepted_dt[6:(end - 1)] ./ dt_reference + eta_p01 = quantile(eta, 0.01) + eta_median = median(eta) + segment_length = max(1, floor(Int, 0.2length(eta))) + eta_tail_head = median(last(eta, segment_length)) / + median(first(eta, segment_length)) + end + @printf("CSS cap target=%6.1f deg mechanism=%s particles=%d initial=%7.2f deg cap=%7.2f deg circle=%7.2f deg normal=%7.2f deg below=%d contact=%d/%d rho=[%.3f, %.3f] vrms=%.4e m/s runtime=%.2f s\n", + contact_angle, String(mechanism), size(final_system.coordinates, 2), + initial.angle, final.angle, circle.angle, measured_contact_angle, + below_wall, active_contact, count(wall_contact), + density_range..., rms_speed, runtime) + @printf(" angle history: %s\n", + join((@sprintf("%.2f", angle) for angle in angle_history), ", ")) + @printf(" initial circle: %.2f deg shape acceleration total/boundary: %.4e / %.4e m^2/s^2\n", + initial_circle.angle, acceleration.total, acceleration.boundary) + if !isempty(contact_correction) + @printf(" contact q: [%.4f, %.4f], median %.4f\n", + extrema(contact_correction)..., median(contact_correction)) + end + if !isempty(line_particles) + @printf(" CLF angle: %.2f deg over %d particles, delta=[%.3e, %.3e]\n", + line_contact_angle, length(line_particles), + contact_line_delta_range...) + end + return (; solution, snapshot, initial, initial_circle, final, circle, + measured_contact_angle, + line_contact_angle, below_wall, active_contact, + wall_contact_particles=count(wall_contact), contact_line_delta_range, + density_range, rms_speed, settled, runtime, minimum_dt=minimum_dt[], + dt_reference, eta_p01, eta_median, eta_tail_head, + accepted_steps=solution.stats.naccept, + rejected_steps=solution.stats.nreject, rejected_fraction, + shape_acceleration=acceleration.total, + boundary_shape_acceleration=acceleration.boundary, + initial_contact_diagnostics, final_contact_diagnostics, + mechanism, contact_angle, + initial_contact_angle, particle_count=size(final_system.coordinates, 2)) +end + +if abspath(PROGRAM_FILE) == @__FILE__ + length(ARGS) in (4, 5, 6) || + error("usage: css_sessile_drop.jl CONTACT_ANGLE FINAL_TIME OUTPUT.jls MECHANISM " * + "[TARGET_PARTICLE_COUNT] [DAMPING_COEFFICIENT]") + contact_angle = parse(Float64, ARGS[1]) + final_time = parse(Float64, ARGS[2]) + mechanism = Symbol(ARGS[4]) + target_particle_count = length(ARGS) >= 5 ? parse(Int, ARGS[5]) : 750 + damping_coefficient = length(ARGS) == 6 ? parse(Float64, ARGS[6]) : 10.0 + css_sessile_drop(contact_angle, final_time, ARGS[3]; target_particle_count, + damping_coefficient, mechanism) +end diff --git a/compare_akinci/css_validation.jl b/compare_akinci/css_validation.jl new file mode 100644 index 0000000000..33c49e4a94 --- /dev/null +++ b/compare_akinci/css_validation.jl @@ -0,0 +1,114 @@ +using LinearAlgebra +using Printf +using Statistics +using TrixiParticles + +include(joinpath(@__DIR__, "surface_tension_calibration.jl")) + +function initial_pair_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) + + 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.interact!(dv, v, u, v, u, system, system, semi) + Array(dv[1:ndims(system), :]) + end + return acceleration, system +end + +function css_static_laplace_balance(; target_particle_count=750, + surface_tension_coefficient=1.0, + reference_density=1000.0, + pressure_basis=1.0, + ideal_density_threshold=0.95, + interface_taper_start=0.8, + support_taper_width=0.025) + initial_condition = deformed_drop(; stretch=1.0, reference_density, + target_particle_count) + particle_spacing = initial_condition.particle_spacing + smoothing_kernel = WendlandC2Kernel{3}() + smoothing_length = 1.4particle_spacing + normal_method = ColorfieldSurfaceNormal(; boundary_contact_threshold=Inf, + interface_threshold=0.01, + ideal_density_threshold, + interface_taper_start, + support_taper_width) + + css_state_equation = StateEquationCole(; sound_speed=100.0, reference_density, + exponent=1) + css_system = WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel, + smoothing_length, + density_calculator=ContinuityDensity(), + state_equation=css_state_equation, + surface_tension=SurfaceTensionMomentumMorris(; + surface_tension_coefficient), + surface_normal_method=normal_method, + reference_particle_spacing=particle_spacing) + css_acceleration, css_system = initial_pair_acceleration(css_system) + + # With exponent one, Cole's equation reduces exactly to + # p = c^2 (rho - rho_0). Adjusting rho_0 therefore provides a uniform pressure basis. + 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, + smoothing_length, + density_calculator=ContinuityDensity(), + state_equation=pressure_state_equation) + pressure_acceleration, _ = initial_pair_acceleration(pressure_system) + pressure_acceleration ./= 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) + residual = capillary + pressure_jump * unit_pressure + + mass = css_system.mass + volume = sum(mass) / reference_density + equivalent_radius = cbrt(3volume / (4pi)) + inferred_surface_tension = pressure_jump * equivalent_radius / 2 + surface_area = sum(mass .* css_system.cache.delta_s) / reference_density + surface_area_ratio = surface_area / (4pi * equivalent_radius^2) + coordinates = initial_condition.coordinates + center = vec(sum(coordinates .* reshape(mass, 1, :); dims=2) / sum(mass)) + relative_coordinates = coordinates .- center + capillary_virial = -sum(mass .* vec(sum(relative_coordinates .* css_acceleration; + dims=1))) + virial_surface_tension = capillary_virial / + (2 * 4pi * equivalent_radius^2) + total_capillary_force = vec(sum(css_acceleration .* reshape(mass, 1, :); dims=2)) + correction_range = extrema(css_system.cache.divergence_correction[interface]) + + return (; particle_count=nparticles(css_system), interface_particles=length(interface), + pressure_jump, equivalent_radius, inferred_surface_tension, + surface_area_ratio, virial_surface_tension, + relative_residual=norm(residual) / norm(capillary), + correction_range, + total_capillary_force=norm(total_capillary_force), + acceleration_rms=sqrt(mean(abs2, capillary))) +end + +function print_css_balance(result) + @printf("CSS static n=%5d interface=%4d sigma_fit=%8.5f sigma_virial=%8.5f N/m dp=%9.3f Pa R=%8.5f m A/A0=%7.4f q=[%6.3f,%6.3f] residual=%8.3e |F|=%8.3e N a_rms=%8.3e m/s^2\n", + result.particle_count, result.interface_particles, + result.inferred_surface_tension, result.virial_surface_tension, + result.pressure_jump, + result.equivalent_radius, result.surface_area_ratio, + result.correction_range..., result.relative_residual, + result.total_capillary_force, result.acceleration_rms) +end + +if abspath(PROGRAM_FILE) == @__FILE__ + target_counts = isempty(ARGS) ? (375, 750, 1500) : parse.(Int, ARGS) + for target_particle_count in target_counts + print_css_balance(css_static_laplace_balance(; target_particle_count)) + end +end diff --git a/compare_akinci/figure_01_05_water_crown.png b/compare_akinci/figure_01_05_water_crown.png new file mode 100644 index 0000000000..87e37f61a3 Binary files /dev/null and b/compare_akinci/figure_01_05_water_crown.png differ diff --git a/compare_akinci/figure_01_particle_diagnostic.png b/compare_akinci/figure_01_particle_diagnostic.png new file mode 100644 index 0000000000..8c53f12e0a Binary files /dev/null and b/compare_akinci/figure_01_particle_diagnostic.png differ diff --git a/compare_akinci/figure_02_css_akinci_diagnostic.png b/compare_akinci/figure_02_css_akinci_diagnostic.png new file mode 100644 index 0000000000..8db3a00496 Binary files /dev/null and b/compare_akinci/figure_02_css_akinci_diagnostic.png differ diff --git a/compare_akinci/figure_02_cube_to_sphere.png b/compare_akinci/figure_02_cube_to_sphere.png new file mode 100644 index 0000000000..1e3704cb40 Binary files /dev/null and b/compare_akinci/figure_02_cube_to_sphere.png differ diff --git a/compare_akinci/figure_02_diagnostics.jl b/compare_akinci/figure_02_diagnostics.jl new file mode 100644 index 0000000000..b5b511cb08 --- /dev/null +++ b/compare_akinci/figure_02_diagnostics.jl @@ -0,0 +1,107 @@ +using CairoMakie +using Serialization + +include(joinpath(@__DIR__, "figure_02_metrics.jl")) + +function nearest_frame(snapshot, target_time) + _, index = findmin(abs.(snapshot.times .- target_time)) + return snapshot.frames[index] +end + +function center_slice(frame; system_index=1) + system = frame.systems[system_index] + coordinates = system.coordinates + center_y = (minimum(coordinates[2, :]) + maximum(coordinates[2, :])) / 2 + mask = abs.(coordinates[2, :] .- center_y) .<= 0.75 * system.particle_spacing + return coordinates[:, mask], system.particle_spacing +end + +function top_slice(frame; system_index=1) + system = frame.systems[system_index] + coordinates = system.coordinates + center_z = (minimum(coordinates[3, :]) + maximum(coordinates[3, :])) / 2 + mask = abs.(coordinates[3, :] .- center_z) .<= 0.75 * system.particle_spacing + return coordinates[:, mask], system.particle_spacing +end + +function figure_02_diagnostic(akinci_path, css_path, output_path) + akinci = open(deserialize, akinci_path) + css = open(deserialize, css_path) + figure = Figure(; size=(1760, 720), fontsize=17) + models = (("Akinci", akinci, :darkorange2), ("CSS", css, :dodgerblue3)) + final_time = min(last(akinci.times), last(css.times)) + panel_times = (0.05, final_time) + + for (column, time) in enumerate(panel_times) + slices = map(models) do (_, snapshot, _) + center_slice(nearest_frame(snapshot, time)) + end + x_min = minimum(minimum(coordinates[1, :]) for (coordinates, _) in slices) + x_max = maximum(maximum(coordinates[1, :]) for (coordinates, _) in slices) + z_min = minimum(minimum(coordinates[3, :]) for (coordinates, _) in slices) + z_max = maximum(maximum(coordinates[3, :]) for (coordinates, _) in slices) + margin = 2maximum(last, slices) + + for (row, ((label, _, color), (coordinates, _))) in enumerate(zip(models, slices)) + axis = Axis(figure[row, column]; + title="$label, t = $(time) s", xlabel="x [m]", ylabel="z [m]", + aspect=DataAspect()) + scatter!(axis, coordinates[1, :], coordinates[3, :]; color, markersize=3) + limits!(axis, x_min - margin, x_max + margin, + min(-margin, z_min - margin), z_max + margin) + end + end + + final_slices = map(models) do (_, snapshot, _) + top_slice(nearest_frame(snapshot, final_time)) + end + final_coordinates = first.(final_slices) + final_spacing = last.(final_slices) + x_min = minimum(minimum(coordinates[1, :]) for coordinates in final_coordinates) + x_max = maximum(maximum(coordinates[1, :]) for coordinates in final_coordinates) + y_min = minimum(minimum(coordinates[2, :]) for coordinates in final_coordinates) + y_max = maximum(maximum(coordinates[2, :]) for coordinates in final_coordinates) + margin = 2 * maximum(final_spacing) + for (row, ((label, snapshot, color), coordinates)) in + enumerate(zip(models, final_coordinates)) + metrics = figure_02_metrics(snapshot) + final = metric_at_time(metrics, final_time) + axis = Axis(figure[row, 3]; + title="$label top slice, asymmetry = $(round(100 * final.planar_asymmetry; digits=2))%", + xlabel="x [m]", ylabel="y [m]", aspect=DataAspect()) + scatter!(axis, coordinates[1, :], coordinates[2, :]; color, + markersize=3) + limits!(axis, x_min - margin, x_max + margin, + y_min - margin, y_max + margin) + end + + sphere_axis = Axis(figure[1, 4]; title="Sphere formation", + xlabel="time [s]", ylabel="radial asphericity", yscale=log10) + spread_axis = Axis(figure[2, 4]; title="Impact spreading (not settled)", + xlabel="time [s]", ylabel="height / width") + for (label, snapshot, color) in models + metrics = figure_02_metrics(snapshot) + sphere_metrics = filter(row -> row.time <= 0.05, metrics) + lines!(sphere_axis, getproperty.(sphere_metrics, :time), + getproperty.(sphere_metrics, :asphericity); label, color, linewidth=3) + scatter!(sphere_axis, getproperty.(sphere_metrics, :time), + getproperty.(sphere_metrics, :asphericity); color, markersize=8) + lines!(spread_axis, getproperty.(metrics, :time), + getproperty.(metrics, :height_to_width); label, color, linewidth=3) + scatter!(spread_axis, getproperty.(metrics, :time), + getproperty.(metrics, :height_to_width); color, markersize=8) + end + vlines!(sphere_axis, [0.05]; color=:gray45, linestyle=:dash, + label="gravity release") + vlines!(spread_axis, [0.05]; color=:gray45, linestyle=:dash) + axislegend(sphere_axis; position=:lb) + save(output_path, figure; px_per_unit=1.5) + println("Wrote Figure 2 CSS/Akinci diagnostic to $output_path") + return output_path +end + +if abspath(PROGRAM_FILE) == @__FILE__ + length(ARGS) == 3 || + error("pass Akinci snapshot, CSS snapshot, and output PNG") + figure_02_diagnostic(ARGS...) +end diff --git a/compare_akinci/figure_02_metrics.jl b/compare_akinci/figure_02_metrics.jl new file mode 100644 index 0000000000..7c79411cf7 --- /dev/null +++ b/compare_akinci/figure_02_metrics.jl @@ -0,0 +1,311 @@ +using LinearAlgebra +using Printf +using Serialization +using Statistics + +function radial_asphericity(coordinates, particle_spacing) + center = vec(mean(coordinates; dims=2)) + centered = coordinates .- center + radius_squared = vec(sum(abs2, centered; dims=1)) + + volume = size(coordinates, 2) * particle_spacing^3 + equivalent_radius = cbrt(3volume / (4pi)) + second_moment_error = mean(radius_squared) / (3equivalent_radius^2 / 5) - 1 + fourth_moment_error = mean(abs2, radius_squared) / + (3equivalent_radius^4 / 7) - 1 + asphericity = sqrt((second_moment_error^2 + fourth_moment_error^2) / 2) + return (; asphericity, second_moment_error, fourth_moment_error, + equivalent_radius) +end + +function angular_alignment(coordinates, particle_spacing) + center = vec(mean(coordinates; dims=2)) + center_z = (minimum(coordinates[3, :]) + maximum(coordinates[3, :])) / 2 + slab = abs.(coordinates[3, :] .- center_z) .<= 0.75 * particle_spacing + x = coordinates[1, slab] .- center[1] + y = coordinates[2, slab] .- center[2] + radius = hypot.(x, y) + outer_radius = maximum(radius) + annulus = (radius .>= 0.2outer_radius) .& (radius .<= 0.85outer_radius) + annulus_x = x[annulus] + annulus_y = y[annulus] + angles = mod.(atan.(annulus_y, annulus_x), 2pi) + + bins = 72 + counts = zeros(Int, bins) + for angle in angles + index = mod(floor(Int, angle / (2pi) * bins), bins) + 1 + counts[index] += 1 + end + angular_bin_cv = std(counts) / mean(counts) + eightfold_alignment = abs(mean(cis.(8angles))) + sixteenfold_alignment = abs(mean(cis.(16angles))) + radial_neighbor_alignment = if length(angles) < 2 + NaN + else + nearest_neighbor_angles = similar(angles) + for particle in eachindex(angles) + nearest_distance_squared = Inf + nearest_neighbor = firstindex(angles) + for neighbor in eachindex(angles) + particle == neighbor && continue + distance_squared = abs2(annulus_x[particle] - annulus_x[neighbor]) + + abs2(annulus_y[particle] - annulus_y[neighbor]) + if distance_squared < nearest_distance_squared + nearest_distance_squared = distance_squared + nearest_neighbor = neighbor + end + end + nearest_neighbor_angles[particle] = atan(annulus_y[nearest_neighbor] - + annulus_y[particle], + annulus_x[nearest_neighbor] - + annulus_x[particle]) + end + abs(mean(cis.(2 .* (nearest_neighbor_angles .- angles)))) + end + return (; angular_bin_cv, eightfold_alignment, sixteenfold_alignment, + radial_neighbor_alignment) +end + +function figure_02_metrics(snapshot; system_index=1) + return map(snapshot.times, snapshot.frames) do time, frame + system = frame.systems[system_index] + coordinates = system.coordinates + particle_spacing = system.particle_spacing + radial = radial_asphericity(coordinates, particle_spacing) + alignment = angular_alignment(coordinates, particle_spacing) + extents = vec(maximum(coordinates; dims=2) - minimum(coordinates; dims=2)) .+ + particle_spacing + width_x, width_y = extents[1], extents[2] + width = sqrt(width_x * width_y) + height = extents[3] + density_min, density_max = extrema(system.density) + rms_speed = sqrt(mean(vec(sum(abs2, system.velocity; dims=1)))) + return (; time, radial..., alignment..., width_x, width_y, width, height, + height_to_width=height / width, + planar_asymmetry=abs(width_x - width_y) / width, + minimum_z=minimum(coordinates[3, :]), + density_min, density_max, rms_speed) + end +end + +function write_figure_02_metrics(snapshot_path, output_path=nothing) + snapshot = open(deserialize, snapshot_path) + metrics = figure_02_metrics(snapshot) + io = isnothing(output_path) ? stdout : open(output_path, "w") + try + println(io, + "time,radial_asphericity,second_moment_error,fourth_moment_error,equivalent_radius,angular_bin_cv,eightfold_alignment,sixteenfold_alignment,radial_neighbor_alignment,width_x,width_y,width,height,height_to_width,planar_asymmetry,minimum_z,density_min,density_max,rms_speed") + for row in metrics + @printf(io, + "%.10g,%.10g,%.10g,%.10g,%.10g,%.10g,%.10g,%.10g,%.10g,%.10g,%.10g,%.10g,%.10g,%.10g,%.10g,%.10g,%.10g,%.10g,%.10g\n", + row.time, row.asphericity, row.second_moment_error, + row.fourth_moment_error, row.equivalent_radius, + row.angular_bin_cv, row.eightfold_alignment, + row.sixteenfold_alignment, row.radial_neighbor_alignment, + row.width_x, row.width_y, row.width, row.height, + row.height_to_width, row.planar_asymmetry, row.minimum_z, + row.density_min, row.density_max, row.rms_speed) + end + finally + isnothing(output_path) || close(io) + end + + stats = snapshot.solver_stats + @printf(stderr, "%s model=%s sigma=%s accepted=%s rejected=%s runtime=%.3f s\n", + snapshot.case_name, snapshot.model, + string(snapshot.surface_tension_coefficient), + string(stats.accepted_steps), string(stats.rejected_steps), snapshot.runtime) + return metrics +end + +function metric_at_time(metrics, target_time) + _, index = findmin([abs(row.time - target_time) for row in metrics]) + return metrics[index] +end + +function figure_02_comparison(baseline, candidate) + baseline_metrics = figure_02_metrics(baseline) + candidate_metrics = figure_02_metrics(candidate) + common_times = intersect(getproperty.(baseline_metrics, :time), + getproperty.(candidate_metrics, :time)) + pre_release_times = filter(time -> 0 < time <= 0.05, common_times) + isempty(pre_release_times) && + error("Figure 2 comparison needs saved states before release") + + pre_release_error = maximum(pre_release_times) do time + baseline_row = metric_at_time(baseline_metrics, time) + candidate_row = metric_at_time(candidate_metrics, time) + abs(candidate_row.asphericity - baseline_row.asphericity) + end + baseline_release = metric_at_time(baseline_metrics, 0.05) + candidate_release = metric_at_time(candidate_metrics, 0.05) + post_release_times = filter(time -> time >= 0.05, common_times) + maximum_post_release_width_error = maximum(post_release_times) do time + baseline_row = metric_at_time(baseline_metrics, time) + candidate_row = metric_at_time(candidate_metrics, time) + abs(candidate_row.width / baseline_row.width - 1) + end + final_time = min(last(baseline.times), last(candidate.times)) + baseline_final = metric_at_time(baseline_metrics, final_time) + candidate_final = metric_at_time(candidate_metrics, final_time) + final_height_error_in_spacing = abs(candidate_final.height - baseline_final.height) / + candidate.frames[1].systems[1].particle_spacing + final_aspect_error = abs(candidate_final.height_to_width - + baseline_final.height_to_width) + maximum_angular_bin_cv = 1.25 * baseline_final.angular_bin_cv + maximum_eightfold_alignment = max(0.02, + 2 * baseline_final.eightfold_alignment) + maximum_radial_neighbor_alignment = max(0.05, + 2 * baseline_final.radial_neighbor_alignment) + qualitative_maximum_angular_bin_cv = 2 * baseline_final.angular_bin_cv + qualitative_maximum_eightfold_alignment = max(0.03, + 4 * baseline_final.eightfold_alignment) + qualitative_maximum_radial_neighbor_alignment = max(0.1, + 4 * + baseline_final.radial_neighbor_alignment) + + density_min = minimum(row.density_min for row in candidate_metrics) + density_max = maximum(row.density_max for row in candidate_metrics) + minimum_z = minimum(row.minimum_z for row in candidate_metrics) + accepted_steps = candidate.solver_stats.accepted_steps + rejected_steps = candidate.solver_stats.rejected_steps + baseline_accepted_steps = baseline.solver_stats.accepted_steps + baseline_rejected_steps = baseline.solver_stats.rejected_steps + rejected_fraction = rejected_steps / (accepted_steps + rejected_steps) + timestep_p01 = candidate.timestep_stats.p01 + timestep_median = candidate.timestep_stats.median + timestep_tail_to_head = candidate.timestep_stats.tail_to_head + reliability_pass = rejected_fraction <= 0.25 && + density_min >= 900 && density_max <= 1_020 && minimum_z >= 0 && + !ismissing(timestep_tail_to_head) && timestep_tail_to_head >= 0.5 + shape_pass = pre_release_error <= 0.025 && + candidate_release.asphericity <= baseline_release.asphericity + 0.01 && + maximum_post_release_width_error <= 0.1 && + final_height_error_in_spacing <= 2 && final_aspect_error <= 0.05 && + candidate_final.height_to_width <= 0.2 && + candidate_final.planar_asymmetry <= 0.05 && + candidate_final.angular_bin_cv <= maximum_angular_bin_cv && + candidate_final.eightfold_alignment <= maximum_eightfold_alignment && + candidate_final.radial_neighbor_alignment <= + maximum_radial_neighbor_alignment + qualitative_pass = candidate_release.asphericity <= 0.05 && + candidate_final.height_to_width <= 0.25 && + candidate_final.planar_asymmetry <= 0.1 && + abs(candidate_final.width / baseline_final.width - 1) <= 0.2 && + candidate_final.angular_bin_cv <= + qualitative_maximum_angular_bin_cv && + candidate_final.eightfold_alignment <= + qualitative_maximum_eightfold_alignment && + candidate_final.radial_neighbor_alignment <= + qualitative_maximum_radial_neighbor_alignment && + reliability_pass + nominal_acceptance_pass = shape_pass && reliability_pass + spacing = candidate.frames[1].systems[1].particle_spacing + return (; case_name=candidate.case_name, + sigma=candidate.surface_tension_coefficient, + artificial_viscosity_alpha=hasproperty(candidate, + :artificial_viscosity_alpha) ? + candidate.artificial_viscosity_alpha : missing, + surface_tension_mode=hasproperty(candidate, :surface_tension_mode) ? + candidate.surface_tension_mode : missing, + smoothing_kernel_mode=hasproperty(candidate, :smoothing_kernel_mode) ? + candidate.smoothing_kernel_mode : missing, + smoothing_length_ratio=hasproperty(candidate, :smoothing_length_ratio) ? + candidate.smoothing_length_ratio : missing, + normal_smoothing=hasproperty(candidate, :normal_smoothing) ? + candidate.normal_smoothing : missing, + contact_angle=hasproperty(candidate, :contact_angle) ? + candidate.contact_angle : missing, + ccsf_contact_angle=hasproperty(candidate, :ccsf_contact_angle) ? + candidate.ccsf_contact_angle : missing, + viscosity_mode=hasproperty(candidate, :viscosity_mode) ? + candidate.viscosity_mode : missing, + density_diffusion_mode=hasproperty(candidate, :density_diffusion_mode) ? + candidate.density_diffusion_mode : missing, + density_diffusion_delta=hasproperty(candidate, :density_diffusion_delta) ? + candidate.density_diffusion_delta : missing, + initial_particle_distribution=hasproperty(candidate, + :initial_particle_distribution) ? + candidate.initial_particle_distribution : missing, + pressure_stabilization=hasproperty(candidate, :pressure_stabilization) ? + candidate.pressure_stabilization : missing, + tic_strength=hasproperty(candidate, :tic_strength) ? + candidate.tic_strength : missing, + shifting_mode=hasproperty(candidate, :shifting_mode) ? + candidate.shifting_mode : missing, + shifting_v_max_factor=hasproperty(candidate, + :shifting_v_max_factor) ? + candidate.shifting_v_max_factor : missing, + shifting_sound_speed_factor=hasproperty(candidate, + :shifting_sound_speed_factor) ? + candidate.shifting_sound_speed_factor : missing, + particle_spacing=spacing, + pre_release_asphericity_error=pre_release_error, + baseline_release_asphericity=baseline_release.asphericity, + css_release_asphericity=candidate_release.asphericity, + maximum_post_release_width_error, + baseline_final_aspect=baseline_final.height_to_width, + css_final_aspect=candidate_final.height_to_width, + final_aspect_error, final_height_error_in_spacing, + css_final_planar_asymmetry=candidate_final.planar_asymmetry, + baseline_angular_bin_cv=baseline_final.angular_bin_cv, + css_angular_bin_cv=candidate_final.angular_bin_cv, + baseline_eightfold_alignment=baseline_final.eightfold_alignment, + css_eightfold_alignment=candidate_final.eightfold_alignment, + baseline_radial_neighbor_alignment=baseline_final.radial_neighbor_alignment, + css_radial_neighbor_alignment=candidate_final.radial_neighbor_alignment, + density_min, density_max, minimum_z, + baseline_accepted_steps, baseline_rejected_steps, + baseline_runtime=baseline.runtime, + accepted_steps, rejected_steps, rejected_fraction, + timestep_p01, timestep_median, timestep_tail_to_head, + runtime=candidate.runtime, shape_pass, reliability_pass, + qualitative_pass, nominal_acceptance_pass) +end + +function write_figure_02_comparisons(baseline_path, candidate_paths, output_path) + baseline = open(deserialize, baseline_path) + rows = map(enumerate(candidate_paths)) do (index, path) + candidate = open(deserialize, path) + row = figure_02_comparison(baseline, candidate) + gate_role = index == 1 ? :nominal_acceptance : :robustness + required_pass = index == 1 ? row.nominal_acceptance_pass : + row.qualitative_pass + merge(row, (; gate_role, required_pass)) + end + names = propertynames(first(rows)) + open(output_path, "w") do io + println(io, join(names, ',')) + for row in rows + println(io, join((getproperty(row, name) for name in names), ',')) + end + end + for row in rows + @printf("sigma=%.6g dx=%.6g release=%.5f final-h/w=%.4f symmetry=%.2f%% angular-cv=%.3f m8=%.3f radial=%.3f width-error=%.2f%% height-error=%.2f dx density=[%.2f, %.2f] rejected=%.1f%% qualitative=%s required=%s\n", + row.sigma, row.particle_spacing, + row.css_release_asphericity, row.css_final_aspect, + 100 * row.css_final_planar_asymmetry, + row.css_angular_bin_cv, row.css_eightfold_alignment, + row.css_radial_neighbor_alignment, + 100 * row.maximum_post_release_width_error, + row.final_height_error_in_spacing, row.density_min, row.density_max, + 100 * row.rejected_fraction, + row.qualitative_pass ? "pass" : "fail", + row.required_pass ? "pass" : "fail") + end + return rows +end + +if abspath(PROGRAM_FILE) == @__FILE__ + if !isempty(ARGS) && ARGS[1] == "compare" + length(ARGS) >= 4 || + error("pass 'compare', an Akinci snapshot, output CSV, and CSS snapshots") + write_figure_02_comparisons(ARGS[2], ARGS[4:end], ARGS[3]) + else + length(ARGS) in (1, 2) || + error("pass a Figure 2 snapshot and optional CSV output path") + output = length(ARGS) == 2 ? ARGS[2] : nothing + write_figure_02_metrics(ARGS[1], output) + end +end diff --git a/compare_akinci/figure_02_packing.jl b/compare_akinci/figure_02_packing.jl new file mode 100644 index 0000000000..af4d0e8009 --- /dev/null +++ b/compare_akinci/figure_02_packing.jl @@ -0,0 +1,86 @@ +using LinearAlgebra +using OrdinaryDiffEqLowStorageRK +using Random +using Statistics +using TrixiParticles + +function cube_surface_mesh(side_length, bottom_height) + half_width = side_length / 2 + vertices = [SVector(-half_width, -half_width, bottom_height), + SVector(half_width, -half_width, bottom_height), + SVector(half_width, half_width, bottom_height), + SVector(-half_width, half_width, bottom_height)] + face_vertices = [(vertices[1], vertices[2], vertices[3]), + (vertices[1], vertices[3], vertices[4])] + face_normals = fill(SVector(0.0, 0.0, 1.0), 2) + bottom = TrixiParticles.TriangleMesh(face_vertices, face_normals, vertices) + + return extrude_geometry(bottom, side_length) +end + +function jittered_initial_condition(initial_condition; relative_amplitude, seed) + coordinates = copy(initial_condition.coordinates) + rng = MersenneTwister(seed) + offsets = 2 .* rand(rng, size(coordinates)...) .- 1 + offsets .-= mean(offsets; dims=2) + coordinates .+= relative_amplitude * initial_condition.particle_spacing .* offsets + + return InitialCondition(; coordinates, + velocity=copy(initial_condition.velocity), + mass=copy(initial_condition.mass), + density=copy(initial_condition.density), + pressure=copy(initial_condition.pressure), + particle_spacing=initial_condition.particle_spacing) +end + +function packed_cube_initial_condition(; particle_spacing, cube_side_length=0.01, + cube_bottom_height=0.0025, + density=1000.0, relative_jitter=0.1, + seed=20260805, maxiters=1000) + n_cube = ntuple(_ -> round(Int, cube_side_length / particle_spacing), 3) + cube_min = (-cube_side_length / 2, -cube_side_length / 2, + cube_bottom_height) + lattice = RectangularShape(particle_spacing, n_cube, cube_min; density) + jittered = jittered_initial_condition(lattice; + relative_amplitude=relative_jitter, seed) + + geometry = cube_surface_mesh(cube_side_length, cube_bottom_height) + boundary_thickness = 3 * particle_spacing + signed_distance_field = SignedDistanceField(geometry, particle_spacing; + use_for_boundary_packing=true, + max_signed_distance=boundary_thickness) + boundary = sample_boundary(signed_distance_field; boundary_density=density, + boundary_thickness, place_on_shell=false) + + smoothing_kernel = SchoenbergQuinticSplineKernel{3}() + smoothing_length = 0.8 * particle_spacing + background_pressure = 1.0 + packing_system = ParticlePackingSystem(jittered; smoothing_kernel, + smoothing_length, + signed_distance_field, + background_pressure) + boundary_system = ParticlePackingSystem(boundary; smoothing_kernel, + smoothing_length, is_boundary=true, + boundary_compress_factor=0.8, + signed_distance_field, + boundary_thickness, + background_pressure) + semi = Semidiscretization(packing_system, boundary_system) + ode = semidiscretize(semi, (0.0, 10_000.0)) + solution = solve(ode, RDPK3SpFSAL35(); abstol=1.0e-7, reltol=1.0e-4, + save_everystep=false, maxiters, + callback=CallbackSet(UpdateCallback())) + packed = InitialCondition(solution, packing_system, semi) + nparticles(packed) == nparticles(lattice) || + error("particle packing removed $(nparticles(lattice) - nparticles(packed)) particles") + + displacement = packed.coordinates - lattice.coordinates + rms_displacement = sqrt(sum(abs2, displacement) / nparticles(lattice)) / + particle_spacing + maximum_displacement = maximum(norm, eachcol(displacement)) / particle_spacing + diagnostics = (; accepted_steps=solution.stats.naccept, + rejected_steps=solution.stats.nreject, + rms_displacement, maximum_displacement, + relative_jitter, seed, maxiters) + return packed, diagnostics +end diff --git a/compare_akinci/figure_02_particle_diagnostic.png b/compare_akinci/figure_02_particle_diagnostic.png new file mode 100644 index 0000000000..4f9503624a Binary files /dev/null and b/compare_akinci/figure_02_particle_diagnostic.png differ diff --git a/compare_akinci/figure_02_staged_probe.jl b/compare_akinci/figure_02_staged_probe.jl new file mode 100644 index 0000000000..a2ef92f041 --- /dev/null +++ b/compare_akinci/figure_02_staged_probe.jl @@ -0,0 +1,121 @@ +include("simulate.jl") + +function transition_initial_condition(solution; system_index=1) + semi = solution.prob.p.semi + system = semi.systems[system_index] + state = last(solution.u) + v_ode, u_ode = state.x + TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, last(solution.t)) + v = TrixiParticles.wrap_v(v_ode, system, semi) + u = TrixiParticles.wrap_u(u_ode, system, semi) + particles = collect(eachparticle(system)) + + coordinates = Array(TrixiParticles.current_coordinates(u, system))[:, particles] + velocity = Array(TrixiParticles.current_velocity(v, system))[:, particles] + density = [TrixiParticles.current_density(v, system, particle) + for particle in particles] + pressure = [TrixiParticles.current_pressure(v, system, particle) + for particle in particles] + mass = collect(system.mass[particles]) + particle_spacing = TrixiParticles.particle_spacing(system, first(particles)) + + return InitialCondition(; coordinates, velocity, density, pressure, mass, + particle_spacing) +end + +function segment_frames(solution) + semi = solution.prob.p.semi + return map(snapshot_frame, solution.u, Iterators.repeated(semi), solution.t) +end + +function combined_solver_stats(solutions) + stats = solver_stats.(solutions) + return (; accepted_steps=sum(getproperty.(stats, :accepted_steps)), + rejected_steps=sum(getproperty.(stats, :rejected_steps))) +end + +function write_staged_figure_02_snapshot(config, output; + particle_spacing=config.css.particle_spacing, + shifting_stop_time=0.03) + initial_time, final_time = config.kwargs.tspan + initial_time < shifting_stop_time < config.kwargs.release_time || + throw(ArgumentError("shifting must stop between the initial and release times")) + + first_saveat = filter(time -> time <= shifting_stop_time, + config.kwargs.solution_saveat) + first_kwargs = merge(config.kwargs, + (; tspan=(initial_time, shifting_stop_time), + solution_saveat=Tuple(first_saveat))) + first_css = merge(config.css, + (; initial_particle_distribution=:lattice, + shifting_mode=:consistent_sun2019)) + first_config = merge(config, + (; name="$(config.name)_shifted_stage", kwargs=first_kwargs, + css=first_css)) + + first_solution = nothing + first_dt = nothing + first_runtime = @elapsed begin + first_solution, first_dt, _ = run_simulation(first_config; particle_spacing) + end + drop_initial_condition = transition_initial_condition(first_solution) + + second_saveat = filter(time -> time >= shifting_stop_time, + config.kwargs.solution_saveat) + second_kwargs = merge(config.kwargs, + (; tspan=(shifting_stop_time, final_time), + solution_saveat=Tuple(second_saveat), + drop_initial_condition)) + second_css = merge(config.css, + (; initial_particle_distribution=:lattice, + shifting_mode=:none)) + second_config = merge(config, + (; name="$(config.name)_unshifted_stage", kwargs=second_kwargs, + css=second_css)) + + second_solution = nothing + second_dt = nothing + second_runtime = @elapsed begin + second_solution, second_dt, _ = run_simulation(second_config; particle_spacing) + end + + first_frames = segment_frames(first_solution) + second_frames = segment_frames(second_solution) + duplicate_transition = isapprox(last(first_solution.t), first(second_solution.t)) + second_indices = duplicate_transition ? (2:length(second_frames)) : + eachindex(second_frames) + frames = vcat(first_frames, second_frames[second_indices]) + times = vcat(collect(first_solution.t), collect(second_solution.t)[second_indices]) + accepted_dt = vcat(first_dt, second_dt) + solutions = (first_solution, second_solution) + snapshot = (; case_name="$(config.name)_staged", + model=:css, + surface_tension_coefficient=config.css.surface_tension_coefficient, + particle_spacing, + artificial_viscosity_alpha=config.css.artificial_viscosity_alpha, + initial_particle_distribution=:lattice, + packing_diagnostics=missing, + shifting_mode=:staged_sun2019, + shifting_stop_time, + shifting_v_max_factor=config.css.shifting_v_max_factor, + shifting_sound_speed_factor=config.css.shifting_sound_speed_factor, + css_hydrodynamics=:modern, + runtime=first_runtime + second_runtime, + solver_stats=combined_solver_stats(solutions), + timestep_stats=timestep_stats(accepted_dt), + times, frames) + + open(output, "w") do io + serialize(io, snapshot) + end + return output +end + +if abspath(PROGRAM_FILE) == @__FILE__ + length(ARGS) in 1:3 || + error("pass an output path, optional particle spacing, and optional shifting stop time") + particle_spacing = length(ARGS) >= 2 ? parse(Float64, ARGS[2]) : 0.01 / 15 + shifting_stop_time = length(ARGS) == 3 ? parse(Float64, ARGS[3]) : 0.03 + write_staged_figure_02_snapshot(case_config("cube_to_sphere_css"), ARGS[1]; + particle_spacing, shifting_stop_time) +end diff --git a/compare_akinci/figure_02_surface_switch.jl b/compare_akinci/figure_02_surface_switch.jl new file mode 100644 index 0000000000..1dca02bdd9 --- /dev/null +++ b/compare_akinci/figure_02_surface_switch.jl @@ -0,0 +1,104 @@ +include("figure_02_staged_probe.jl") + +function write_surface_switch_snapshot(output; particle_spacing=0.01 / 15, + switch_time=nothing) + base = case_config("cube_to_sphere_css") + switch_time = isnothing(switch_time) ? base.kwargs.release_time : switch_time + initial_time, final_time = base.kwargs.tspan + initial_time < switch_time < final_time || + throw(ArgumentError("switch time must lie inside the simulation interval")) + + common_css = (; pressure_stabilization=:interface_tic, + tic_strength=0.25, + shifting_mode=:consistent_sun2019, + contact_angle=nothing, + initial_particle_distribution=:lattice) + + first_saveat = filter(time -> time <= switch_time, base.kwargs.solution_saveat) + first_kwargs = merge(base.kwargs, + (; tspan=(initial_time, switch_time), + solution_saveat=Tuple(first_saveat))) + first_css = merge(base.css, common_css, + (; surface_tension_mode=:c_csf, + ccsf_contact_angle=nothing, + normal_smoothing=false)) + first_config = merge(base, + (; name="cube_to_sphere_c_csf_stage", + kwargs=first_kwargs, css=first_css)) + + first_solution = nothing + first_dt = nothing + first_runtime = @elapsed begin + first_solution, first_dt, _ = run_simulation(first_config; particle_spacing) + end + + drop_initial_condition = transition_initial_condition(first_solution) + second_saveat = filter(time -> time >= switch_time, base.kwargs.solution_saveat) + second_kwargs = merge(base.kwargs, + (; tspan=(switch_time, final_time), + solution_saveat=Tuple(second_saveat), + drop_initial_condition)) + second_css = merge(base.css, common_css, + (; surface_tension_mode=:css, + ccsf_contact_angle=nothing, + normal_smoothing=true)) + second_config = merge(base, + (; name="cube_to_sphere_smoothed_css_stage", + kwargs=second_kwargs, css=second_css)) + + second_solution = nothing + second_dt = nothing + second_runtime = @elapsed begin + second_solution, second_dt, _ = run_simulation(second_config; particle_spacing) + end + + first_frames = segment_frames(first_solution) + second_frames = segment_frames(second_solution) + duplicate_switch = isapprox(last(first_solution.t), first(second_solution.t)) + second_indices = duplicate_switch ? (2:length(second_frames)) : eachindex(second_frames) + frames = vcat(first_frames, second_frames[second_indices]) + times = vcat(collect(first_solution.t), collect(second_solution.t)[second_indices]) + accepted_dt = vcat(first_dt, second_dt) + solutions = (first_solution, second_solution) + + snapshot = (; case_name="cube_to_sphere_c_csf_to_smoothed_css", + model=:css, + surface_tension_coefficient=base.css.surface_tension_coefficient, + particle_spacing, + artificial_viscosity_alpha=base.css.artificial_viscosity_alpha, + surface_tension_mode=:c_csf_to_smoothed_css, + smoothing_kernel_mode=base.css.smoothing_kernel_mode, + smoothing_length_ratio=base.css.smoothing_length_ratio, + normal_smoothing=:post_switch, + contact_angle=nothing, + ccsf_contact_angle=nothing, + viscosity_mode=base.css.viscosity_mode, + density_diffusion_mode=base.css.density_diffusion_mode, + density_diffusion_delta=base.css.density_diffusion_delta, + initial_particle_distribution=:lattice, + packing_diagnostics=missing, + pressure_stabilization=:interface_tic, + tic_strength=0.25, + shifting_mode=:consistent_sun2019, + shifting_v_max_factor=base.css.shifting_v_max_factor, + shifting_sound_speed_factor=base.css.shifting_sound_speed_factor, + css_hydrodynamics=:modern, + switch_time, + runtime=first_runtime + second_runtime, + solver_stats=combined_solver_stats(solutions), + timestep_stats=timestep_stats(accepted_dt), + times, frames) + + open(output, "w") do io + serialize(io, snapshot) + end + return output +end + +if abspath(PROGRAM_FILE) == @__FILE__ + length(ARGS) in 1:3 || + error("pass an output path, optional particle spacing, and optional switch time") + particle_spacing = length(ARGS) >= 2 ? parse(Float64, ARGS[2]) : 0.01 / 15 + switch_time = length(ARGS) == 3 ? parse(Float64, ARGS[3]) : nothing + write_surface_switch_snapshot(ARGS[1]; particle_spacing, switch_time) +end diff --git a/compare_akinci/figure_02_track_b.csv b/compare_akinci/figure_02_track_b.csv new file mode 100644 index 0000000000..7c1cd45843 --- /dev/null +++ b/compare_akinci/figure_02_track_b.csv @@ -0,0 +1,3 @@ +case_name,sigma,artificial_viscosity_alpha,initial_particle_distribution,pressure_stabilization,tic_strength,shifting_mode,shifting_v_max_factor,shifting_sound_speed_factor,particle_spacing,pre_release_asphericity_error,baseline_release_asphericity,css_release_asphericity,maximum_post_release_width_error,baseline_final_aspect,css_final_aspect,final_aspect_error,final_height_error_in_spacing,css_final_planar_asymmetry,baseline_angular_bin_cv,css_angular_bin_cv,baseline_eightfold_alignment,css_eightfold_alignment,baseline_radial_neighbor_alignment,css_radial_neighbor_alignment,density_min,density_max,minimum_z,baseline_accepted_steps,baseline_rejected_steps,baseline_runtime,accepted_steps,rejected_steps,rejected_fraction,timestep_p01,timestep_median,timestep_tail_to_head,runtime,shape_pass,reliability_pass,qualitative_pass,nominal_acceptance_pass,gate_role,required_pass +cube_to_sphere_css,0.012,0.05,lattice,none,1.0,consistent_sun2019,0.0,0.1,0.0005263157894736842,0.031942881712205905,0.009024115099824691,0.022950557358021106,0.1353421725609858,0.13537037526285053,0.160833651354737,0.025463276091886478,0.19816349804170527,0.0029894027765309163,0.1341839998088451,0.10836705223802826,0.0051320323133403475,0.002549750568696185,0.021848333669536858,0.04719726644605702,978.3307961709567,1000.3801765745535,0.00029724915161796235,11459,54,558.654183685,2253,109,0.04614733276883996,2.3648323240034817e-5,4.5005336489947756e-5,0.9825552207787136,416.986484059,false,true,true,false,nominal_acceptance,false +cube_to_sphere_css,0.012,0.05,lattice,none,1.0,consistent_sun2019,0.0,0.1,0.0006666666666666666,0.047725725751363794,0.009024115099824691,0.021061242569690265,0.1419498178300288,0.13537037526285053,0.16652499707899646,0.031154621816145922,0.31818237714505454,0.0128046518856672,0.1341839998088451,0.1614292531078542,0.0051320323133403475,0.0071236073961779625,0.021848333669536858,0.050474268457187055,955.5829659305515,1000.4045145534625,0.000374937383545668,11459,54,558.654183685,1751,167,0.08706986444212722,6.6766727801464926e-6,5.7729931035185866e-5,1.1516191146828068,118.103853659,false,true,true,false,robustness,true diff --git a/compare_akinci/figure_06_droplet_on_plate.png b/compare_akinci/figure_06_droplet_on_plate.png new file mode 100644 index 0000000000..f7101f3b12 Binary files /dev/null and b/compare_akinci/figure_06_droplet_on_plate.png differ diff --git a/compare_akinci/figure_07_stream_over_sphere.png b/compare_akinci/figure_07_stream_over_sphere.png new file mode 100644 index 0000000000..52ed46442a Binary files /dev/null and b/compare_akinci/figure_07_stream_over_sphere.png differ diff --git a/compare_akinci/figure_08_intermediate_force_diagnostic.png b/compare_akinci/figure_08_intermediate_force_diagnostic.png new file mode 100644 index 0000000000..72ee8fd62a Binary files /dev/null and b/compare_akinci/figure_08_intermediate_force_diagnostic.png differ diff --git a/compare_akinci/figure_08_intermediate_wetting.png b/compare_akinci/figure_08_intermediate_wetting.png new file mode 100644 index 0000000000..acf0b75d50 Binary files /dev/null and b/compare_akinci/figure_08_intermediate_wetting.png differ diff --git a/compare_akinci/figure_08_no_wetting.png b/compare_akinci/figure_08_no_wetting.png new file mode 100644 index 0000000000..d2e86281d2 Binary files /dev/null and b/compare_akinci/figure_08_no_wetting.png differ diff --git a/compare_akinci/figure_08_no_wetting_settled_force_diagnostic.png b/compare_akinci/figure_08_no_wetting_settled_force_diagnostic.png new file mode 100644 index 0000000000..e0d55a78e6 Binary files /dev/null and b/compare_akinci/figure_08_no_wetting_settled_force_diagnostic.png differ diff --git a/compare_akinci/figure_08_particle_diagnostic.png b/compare_akinci/figure_08_particle_diagnostic.png new file mode 100644 index 0000000000..efb9f2ed1e Binary files /dev/null and b/compare_akinci/figure_08_particle_diagnostic.png differ diff --git a/compare_akinci/figure_08_perfect_wetting.png b/compare_akinci/figure_08_perfect_wetting.png new file mode 100644 index 0000000000..e8bc7794a9 Binary files /dev/null and b/compare_akinci/figure_08_perfect_wetting.png differ diff --git a/compare_akinci/figure_08_physical_near_complete_wetting_diagnostic.png b/compare_akinci/figure_08_physical_near_complete_wetting_diagnostic.png new file mode 100644 index 0000000000..bbc4f9da64 Binary files /dev/null and b/compare_akinci/figure_08_physical_near_complete_wetting_diagnostic.png differ diff --git a/compare_akinci/figure_08_physical_wetting_diagnostic.png b/compare_akinci/figure_08_physical_wetting_diagnostic.png new file mode 100644 index 0000000000..1db0c789fd Binary files /dev/null and b/compare_akinci/figure_08_physical_wetting_diagnostic.png differ diff --git a/compare_akinci/figure_08_wcsph_method_diagnostic.png b/compare_akinci/figure_08_wcsph_method_diagnostic.png new file mode 100644 index 0000000000..ab87be2758 Binary files /dev/null and b/compare_akinci/figure_08_wcsph_method_diagnostic.png differ diff --git a/compare_akinci/figure_08_wcsph_seven_stage_diagnostic.png b/compare_akinci/figure_08_wcsph_seven_stage_diagnostic.png new file mode 100644 index 0000000000..cd71e618f6 Binary files /dev/null and b/compare_akinci/figure_08_wcsph_seven_stage_diagnostic.png differ diff --git a/compare_akinci/figure_09_droplet_splitting.png b/compare_akinci/figure_09_droplet_splitting.png new file mode 100644 index 0000000000..801ae1ace4 Binary files /dev/null and b/compare_akinci/figure_09_droplet_splitting.png differ diff --git a/compare_akinci/figure_10_rolling_droplet.png b/compare_akinci/figure_10_rolling_droplet.png new file mode 100644 index 0000000000..27f5abb93a Binary files /dev/null and b/compare_akinci/figure_10_rolling_droplet.png differ diff --git a/compare_akinci/force_analysis.jl b/compare_akinci/force_analysis.jl new file mode 100644 index 0000000000..9dc9a65dcf --- /dev/null +++ b/compare_akinci/force_analysis.jl @@ -0,0 +1,444 @@ +using LinearAlgebra +using OrdinaryDiffEqLowStorageRK +using Printf +using Serialization +using Statistics +using TrixiParticles + +include("cases.jl") + +const TP = TrixiParticles + +function diagnostic_setup(config; setup_overrides=(;)) + simulation_module = Module(Symbol("AkinciForceDiagnostic_", config.name)) + Core.eval(simulation_module, :(using TrixiParticles)) + example = joinpath(examples_dir(), "fluid", config.example) + kwargs = merge(config.kwargs, setup_overrides, + (; tspan=(0.0, 0.0), solution_saveat=(), saving_callback=nothing)) + trixi_include(simulation_module, example; kwargs...) + solution = Base.invokelatest(Core.eval, simulation_module, :sol) + return solution.prob.p.semi, deepcopy(solution.prob.u0).x +end + +function inject_frame!(v_ode, u_ode, semi, frame, time; system_index=1) + system = semi.systems[system_index] + snapshot_system = frame.systems[system_index] + TP.wrap_v(v_ode, system, semi) .= snapshot_system.velocity + TP.wrap_u(u_ode, system, semi) .= snapshot_system.coordinates + TP.update_systems_and_nhs(v_ode, u_ode, semi, time) + + v = TP.wrap_v(v_ode, system, semi) + density = [TP.current_density(v, system, particle) + for particle in TP.eachparticle(system)] + pressure = [TP.current_pressure(v, system, particle) + for particle in TP.eachparticle(system)] + density_error = maximum(abs, density - snapshot_system.density) + pressure_error = maximum(abs, pressure - snapshot_system.pressure) + return (; density, pressure, density_error, pressure_error) +end + +function force_components(v_ode, u_ode, semi, time; system_index=1) + system = semi.systems[system_index] + v_system = TP.wrap_v(v_ode, system, semi) + u_system = TP.wrap_u(u_ode, system, semi) + coordinates = Array(TP.current_coordinates(u_system, system)) + velocity = Array(TP.current_velocity(v_system, system)) + n_particles = length(TP.eachparticle(system)) + particle_spacing = TP.particle_spacing(system, first(TP.eachparticle(system))) + dimensions = TP.ndims(system) + scalar_type = eltype(coordinates) + + pressure_fluid = zeros(scalar_type, dimensions, n_particles) + pressure_boundary = zero(pressure_fluid) + viscosity_fluid = zero(pressure_fluid) + viscosity_boundary = zero(pressure_fluid) + cohesion = zero(pressure_fluid) + curvature = zero(pressure_fluid) + adhesion = zero(pressure_fluid) + fluid_normal_sum_before_filter = zero(pressure_fluid) + reference_density_normal_sum_before_filter = zero(pressure_fluid) + boundary_normal_sum_before_filter = zero(pressure_fluid) + normal_moment_matrix = zeros(scalar_type, dimensions, dimensions, n_particles) + smoothed_normal_sum = zero(pressure_fluid) + smoothed_normal_weight = zeros(scalar_type, n_particles) + + pressure_pair_magnitudes = zeros(scalar_type, n_particles) + viscosity_pair_magnitudes = zeros(scalar_type, n_particles) + cohesion_pair_magnitudes = zeros(scalar_type, n_particles) + curvature_pair_magnitudes = zeros(scalar_type, n_particles) + adhesion_pair_magnitudes = zeros(scalar_type, n_particles) + normal_pair_magnitudes = zeros(scalar_type, n_particles) + fluid_neighbor_count = zeros(Int, n_particles) + boundary_neighbor_count = zeros(Int, n_particles) + normal_boundary_neighbor_count = zeros(Int, n_particles) + surface_correction_sum = zeros(scalar_type, n_particles) + surface_correction_min = fill(typemax(scalar_type), n_particles) + surface_correction_max = fill(typemin(scalar_type), n_particles) + + correction = system.correction + sound_speed = TP.system_sound_speed(system) + surface_tension = TP.surface_tension_model(system) + surface_tension isa SurfaceTensionAkinci || + error("force decomposition currently requires `SurfaceTensionAkinci`") + raw_normal = copy(system.cache.surface_normal) + + for particle in TP.each_integrated_particle(system) + volume = TP.hydrodynamic_mass(system, particle) / + TP.current_density(v_system, system, particle) + weight = volume * TP.smoothing_kernel(system, zero(scalar_type), particle) + smoothed_normal_sum[:, particle] .= weight .* raw_normal[:, particle] + smoothed_normal_weight[particle] = weight + end + + system_coordinates = TP.current_coordinates(u_system, system) + for neighbor_system in semi.systems + v_neighbor = TP.wrap_v(v_ode, neighbor_system, semi) + u_neighbor = TP.wrap_u(u_ode, neighbor_system, semi) + neighbor_coordinates = TP.current_coordinates(u_neighbor, neighbor_system) + neighborhood_search = TP.get_neighborhood_search(system, neighbor_system, semi) + compact_support = TP.compact_support(system, neighbor_system) + almost_zero = sqrt(eps(compact_support^2)) + neighbor_is_fluid = neighbor_system isa TP.AbstractFluidSystem + normal_threshold = system.surface_normal_method.boundary_contact_threshold + maximum_boundary_colorfield = if neighbor_is_fluid || normal_threshold == Inf + zero(scalar_type) + else + maximum(neighbor_system.boundary_model.cache.colorfield) + end + + for particle in TP.each_integrated_particle(system) + m_a = TP.hydrodynamic_mass(system, particle) + p_a = TP.current_pressure(v_system, system, particle) + velocity_a = TP.current_velocity(v_system, system, particle) + rho_a = TP.current_density(v_system, system, particle) + + TP.foreach_neighbor(system_coordinates, neighbor_coordinates, + neighborhood_search, semi.parallelization_backend, + particle) do _, neighbor, pos_diff, distance + TP.skip_zero_distance(system) && distance < almost_zero && return + + grad_kernel = TP.smoothing_kernel_grad_unsafe(system, pos_diff, distance, + particle) + m_b = TP.hydrodynamic_mass(neighbor_system, neighbor) + velocity_b = TP.current_velocity(v_neighbor, neighbor_system, neighbor) + rho_b = TP.current_density(v_neighbor, neighbor_system, neighbor) + p_b = TP.neighbor_pressure(v_neighbor, neighbor_system, neighbor, p_a) + correction_rho_a = TP.correction_density(correction, system, particle, + rho_a) + correction_rho_b = TP.correction_density(correction, neighbor_system, + neighbor, rho_b) + (viscosity_correction, pressure_correction, + surface_correction) = TP.free_surface_correction(correction, system, + correction_rho_a, + correction_rho_b) + + pressure_acceleration = pressure_correction * + TP.pressure_acceleration(system, neighbor_system, + particle, neighbor, + m_a, m_b, p_a, p_b, + rho_a, rho_b, pos_diff, + distance, grad_kernel, + correction) + pressure_component = neighbor_is_fluid ? pressure_fluid : pressure_boundary + pressure_component[:, particle] .+= pressure_acceleration + pressure_pair_magnitudes[particle] += norm(pressure_acceleration) + + viscosity_acceleration = Ref(zero(velocity_a)) + TP.dv_viscosity!(viscosity_acceleration, system, neighbor_system, + v_system, v_neighbor, particle, neighbor, pos_diff, + distance, sound_speed, m_a, m_b, rho_a, rho_b, + velocity_a, velocity_b, grad_kernel, + viscosity_correction) + viscosity_component = neighbor_is_fluid ? viscosity_fluid : + viscosity_boundary + viscosity_component[:, particle] .+= viscosity_acceleration[] + viscosity_pair_magnitudes[particle] += norm(viscosity_acceleration[]) + + adhesion_acceleration = Ref(zero(velocity_a)) + TP.adhesion_force!(adhesion_acceleration, surface_tension, system, + neighbor_system, particle, neighbor, pos_diff, distance) + adhesion[:, particle] .+= adhesion_acceleration[] + adhesion_pair_magnitudes[particle] += norm(adhesion_acceleration[]) + + if neighbor_is_fluid + fluid_neighbor_count[particle] += 1 + normal_contribution = m_b / rho_b * grad_kernel + fluid_normal_sum_before_filter[:, particle] .+= normal_contribution + reference_density = neighbor_system.state_equation.reference_density + reference_density_normal_sum_before_filter[:, + particle] .+= m_b / + reference_density * + grad_kernel + normal_pair_magnitudes[particle] += norm(normal_contribution) + + volume = m_b / rho_b + kernel_weight = TP.smoothing_kernel(system, distance, particle) + smoothed_normal_sum[:, + particle] .+= volume * kernel_weight .* + raw_normal[:, neighbor] + smoothed_normal_weight[particle] += volume * kernel_weight + for column in 1:dimensions, row in 1:dimensions + normal_moment_matrix[row, column, + particle] -= volume * grad_kernel[row] * + pos_diff[column] + end + + surface_correction_sum[particle] += surface_correction + surface_correction_min[particle] = min(surface_correction_min[particle], + surface_correction) + surface_correction_max[particle] = max(surface_correction_max[particle], + surface_correction) + + support_radius = TP.compact_support(system.smoothing_kernel, + TP.smoothing_length(system, + particle)) + cohesion_acceleration = surface_correction * + TP.cohesion_force_akinci(surface_tension, + support_radius, m_b, + pos_diff, distance, + Val(dimensions)) + cohesion[:, particle] .+= cohesion_acceleration + cohesion_pair_magnitudes[particle] += norm(cohesion_acceleration) + + normal_a = TP.akinci_surface_normal(system, particle) + normal_b = TP.akinci_surface_normal(neighbor_system, neighbor) + curvature_acceleration = -surface_correction * + surface_tension.surface_tension_coefficient * + (normal_a - normal_b) + curvature[:, particle] .+= curvature_acceleration + curvature_pair_magnitudes[particle] += norm(curvature_acceleration) + else + boundary_neighbor_count[particle] += 1 + if normal_threshold < Inf + boundary_colorfield = neighbor_system.boundary_model.cache.colorfield + include_boundary = boundary_colorfield[neighbor] / + maximum_boundary_colorfield > normal_threshold + else + include_boundary = false + end + if include_boundary + normal_contribution = m_a / rho_a * grad_kernel + boundary_normal_sum_before_filter[:, + particle] .+= normal_contribution + normal_pair_magnitudes[particle] += norm(normal_contribution) + normal_boundary_neighbor_count[particle] += 1 + end + end + end + end + end + + surface_correction_mean = surface_correction_sum ./ max.(fluid_neighbor_count, 1) + surface_correction_min[iszero.(fluid_neighbor_count)] .= 0 + surface_correction_max[iszero.(fluid_neighbor_count)] .= 0 + + smoothed_normal = smoothed_normal_sum ./ reshape(smoothed_normal_weight, 1, :) + gradient_corrected_normal = similar(raw_normal) + for particle in TP.eachparticle(system) + moment = normal_moment_matrix[:, :, particle] + correction_matrix = abs(det(moment)) < 1.0f-9 ? + Matrix{scalar_type}(I, dimensions, dimensions) : inv(moment) + gradient_corrected_normal[:, + particle] .= correction_matrix * + fluid_normal_sum_before_filter[:, particle] + end + + reconstructed_neighbor_count = fluid_neighbor_count .+ 1 .+ + normal_boundary_neighbor_count + invalid_normal = reconstructed_neighbor_count .< 2^dimensions + 1 + gradient_corrected_normal[:, invalid_normal] .= 0 + smoothed_normal[:, invalid_normal] .= 0 + + scaled_normal = similar(raw_normal) + scaled_fluid_normal = similar(raw_normal) + scaled_reference_density_normal = similar(raw_normal) + scaled_gradient_corrected_normal = similar(raw_normal) + scaled_smoothed_normal = similar(raw_normal) + scaled_boundary_normal = similar(raw_normal) + for particle in TP.eachparticle(system) + support_radius = TP.compact_support(system.smoothing_kernel, + TP.smoothing_length(system, particle)) + scaled_normal[:, particle] .= support_radius .* raw_normal[:, particle] + scaled_fluid_normal[:, + particle] .= support_radius .* + fluid_normal_sum_before_filter[:, particle] + scaled_reference_density_normal[:, + particle] .= support_radius .* + reference_density_normal_sum_before_filter[:, + particle] + scaled_gradient_corrected_normal[:, + particle] .= support_radius .* + gradient_corrected_normal[:, particle] + scaled_smoothed_normal[:, + particle] .= support_radius .* smoothed_normal[:, particle] + scaled_boundary_normal[:, + particle] .= support_radius .* + boundary_normal_sum_before_filter[:, + particle] + end + + gravity = repeat(collect(system.acceleration), 1, n_particles) + total = pressure_fluid + pressure_boundary + viscosity_fluid + + viscosity_boundary + cohesion + curvature + adhesion + gravity + + dv_ode = zero(v_ode) + TP.system_interaction!(dv_ode, v_ode, u_ode, semi) + TP.add_source_terms!(dv_ode, v_ode, u_ode, semi, time) + rhs = Array(TP.wrap_v(dv_ode, system, semi))[1:dimensions, :] + + reconstructed_normal = fluid_normal_sum_before_filter + + boundary_normal_sum_before_filter + reconstructed_normal[:, reconstructed_neighbor_count .< 2^dimensions + 1] .= 0 + normal_residual = maximum(abs, raw_normal - reconstructed_normal) + rhs_residual = maximum(abs, rhs - total) + + return (; coordinates, velocity, particle_spacing, raw_normal, scaled_normal, + scaled_fluid_normal, scaled_reference_density_normal, scaled_boundary_normal, + scaled_gradient_corrected_normal, scaled_smoothed_normal, + fluid_normal_sum_before_filter, reference_density_normal_sum_before_filter, + boundary_normal_sum_before_filter, normal_moment_matrix, + smoothed_normal_weight, + normal_neighbor_count=copy(system.cache.neighbor_count), fluid_neighbor_count, + boundary_neighbor_count, normal_boundary_neighbor_count, + surface_correction_mean, + surface_correction_min, surface_correction_max, + pressure_fluid, pressure_boundary, viscosity_fluid, viscosity_boundary, + cohesion, curvature, adhesion, gravity, total, rhs, + pressure_pair_magnitudes, viscosity_pair_magnitudes, + cohesion_pair_magnitudes, curvature_pair_magnitudes, + adhesion_pair_magnitudes, normal_pair_magnitudes, + normal_residual, rhs_residual) +end + +vector_magnitudes(values) = vec(sqrt.(sum(abs2, values; dims=1))) + +function print_distribution(label, values) + magnitudes = vector_magnitudes(values) + @printf(" %-20s median=%10.4g p90=%10.4g p99=%10.4g max=%10.4g\n", + label, median(magnitudes), quantile(magnitudes, 0.9), + quantile(magnitudes, 0.99), maximum(magnitudes)) +end + +function radial_components(values, coordinates) + center_x = median(coordinates[1, :]) + center_y = median(coordinates[2, :]) + offset_x = coordinates[1, :] .- center_x + offset_y = coordinates[2, :] .- center_y + radii = hypot.(offset_x, offset_y) + mask = radii .> sqrt(eps(eltype(radii))) + radial = (values[1, mask] .* offset_x[mask] + + values[2, mask] .* offset_y[mask]) ./ radii[mask] + return radial +end + +function print_directions(label, values, coordinates) + radial = radial_components(values, coordinates) + @printf(" %-18s radial median=%9.4g, mean=%9.4g; z median=%9.4g, mean=%9.4g\n", + label, median(radial), mean(radial), median(values[3, :]), + mean(values[3, :])) +end + +function print_summary(time, state, quantities) + @printf("\nt = %.3f s\n", time) + @printf(" reconstruction: density error %.3e, pressure error %.3e, normal error %.3e, RHS error %.3e\n", + state.density_error, state.pressure_error, quantities.normal_residual, + quantities.rhs_residual) + print_distribution("scaled normal", quantities.scaled_normal) + print_distribution("normal from fluid", quantities.scaled_fluid_normal) + print_distribution("normal at rho0", quantities.scaled_reference_density_normal) + print_distribution("gradient-corrected", quantities.scaled_gradient_corrected_normal) + print_distribution("Shepard-smoothed", quantities.scaled_smoothed_normal) + if any(x -> !iszero(x), quantities.scaled_boundary_normal) + print_distribution("normal from wall", quantities.scaled_boundary_normal) + end + print_distribution("pressure fluid", quantities.pressure_fluid) + print_distribution("pressure boundary", quantities.pressure_boundary) + print_distribution("viscosity fluid", quantities.viscosity_fluid) + print_distribution("viscosity boundary", quantities.viscosity_boundary) + print_distribution("cohesion", quantities.cohesion) + print_distribution("curvature", quantities.curvature) + print_distribution("adhesion", quantities.adhesion) + print_distribution("total", quantities.total) + println(" signed components (positive radial is outward, positive z is upward):") + print_directions("pressure fluid", quantities.pressure_fluid, quantities.coordinates) + print_directions("pressure boundary", quantities.pressure_boundary, + quantities.coordinates) + print_directions("cohesion", quantities.cohesion, quantities.coordinates) + print_directions("curvature", quantities.curvature, quantities.coordinates) + print_directions("adhesion", quantities.adhesion, quantities.coordinates) + print_directions("total", quantities.total, quantities.coordinates) + + contact = quantities.boundary_neighbor_count .> 0 + if any(contact) + @printf(" wall-neighbor particles: %d; median z acceleration pressure %.4g, viscosity %.4g, adhesion %.4g, total %.4g\n", + count(contact), median(quantities.pressure_boundary[3, contact]), + median(quantities.viscosity_boundary[3, contact]), + median(quantities.adhesion[3, contact]), + median(quantities.total[3, contact])) + else + println(" wall-neighbor particles: 0") + end + normal_magnitudes = vector_magnitudes(quantities.raw_normal) + normal_active = normal_magnitudes .> 0 + if any(normal_active) + cancellation = normal_magnitudes[normal_active] ./ + quantities.normal_pair_magnitudes[normal_active] + @printf(" normals: %d active, %d filtered; median net/pair-magnitude ratio %.4f\n", + count(normal_active), length(normal_active) - count(normal_active), + median(cancellation)) + else + println(" normals: 0 active") + end + @printf(" neighbors: fluid %s, boundary %s, wall-normal %s, normal-cache %s\n", + extrema(quantities.fluid_neighbor_count), + extrema(quantities.boundary_neighbor_count), + extrema(quantities.normal_boundary_neighbor_count), + extrema(quantities.normal_neighbor_count)) + @printf(" surface correction: [%.4f, %.4f], median mean %.4f\n", + minimum(quantities.surface_correction_min), + maximum(quantities.surface_correction_max), + median(quantities.surface_correction_mean)) +end + +function analyze_forces(case_name, snapshot_path, output_path; requested_times=nothing, + setup_overrides=(;)) + config = case_config(case_name) + startswith(config.name, "wetting_") || error("force analysis is limited to Figure 8") + snapshot = open(deserialize, snapshot_path) + semi, (v_ode, u_ode) = diagnostic_setup(config; setup_overrides) + + frame_indices = if isnothing(requested_times) + eachindex(snapshot.times) + else + map(requested_times) do requested_time + index = argmin(abs.(snapshot.times .- requested_time)) + isapprox(snapshot.times[index], requested_time; atol=1.0e-10) || + error("snapshot has no frame at t=$requested_time") + index + end + end + + frames = GC.@preserve v_ode u_ode begin + map(frame_indices) do index + time = snapshot.times[index] + state = inject_frame!(v_ode, u_ode, semi, snapshot.frames[index], time) + quantities = force_components(v_ode, u_ode, semi, time) + print_summary(time, state, quantities) + return (; time, density=state.density, pressure=state.pressure, quantities...) + end + end + + result = (; case_name, source_snapshot=abspath(snapshot_path), frames) + open(output_path, "w") do io + serialize(io, result) + end + println("\nWrote per-particle force analysis to $output_path") + return result +end + +if abspath(PROGRAM_FILE) == @__FILE__ + 3 <= length(ARGS) <= 4 || + error("pass a Figure 8 case, input snapshot, output path, and optional comma-separated times") + requested_times = length(ARGS) == 4 ? parse.(Float64, split(ARGS[4], ',')) : nothing + analyze_forces(ARGS[1], ARGS[2], ARGS[3]; requested_times) +end diff --git a/compare_akinci/force_diagnostics.jl b/compare_akinci/force_diagnostics.jl new file mode 100644 index 0000000000..469c2696ce --- /dev/null +++ b/compare_akinci/force_diagnostics.jl @@ -0,0 +1,118 @@ +using CairoMakie +using LinearAlgebra +using Serialization +using Statistics + +vector_magnitudes(values) = vec(sqrt.(sum(abs2, values; dims=1))) + +const COMPONENTS = ((; label="scaled normal", field=:scaled_normal, units=""), + (; label="pressure", field=:pressure, units="m/s^2"), + (; label="viscosity", field=:viscosity, units="m/s^2"), + (; label="cohesion", field=:cohesion, units="m/s^2"), + (; label="normal difference", field=:curvature, units="m/s^2"), + (; label="adhesion", field=:adhesion, units="m/s^2"), + (; label="total", field=:total, units="m/s^2")) + +function component_values(frame, field) + field == :pressure && return frame.pressure_fluid + frame.pressure_boundary + field == :viscosity && return frame.viscosity_fluid + frame.viscosity_boundary + return getproperty(frame, field) +end + +function selected_frames(analysis, requested_times) + isnothing(requested_times) && return analysis.frames + return map(requested_times) do requested_time + index = argmin(abs.([frame.time for frame in analysis.frames] .- requested_time)) + frame = analysis.frames[index] + isapprox(frame.time, requested_time; atol=1.0e-10) || + error("analysis has no frame at t=$requested_time") + frame + end +end + +function center_slice(frame) + slice_center = median(frame.coordinates[2, :]) + return abs.(frame.coordinates[2, :] .- slice_center) .<= + 0.75 * frame.particle_spacing +end + +function reference_magnitude(frames, field) + magnitudes = reduce(vcat, + vector_magnitudes(component_values(frame, field)) + for frame in frames) + reference = quantile(magnitudes, 0.95) + return max(reference, eps(eltype(magnitudes))) +end + +function vector_segments(coordinates, values, mask, spacing, reference) + points = Point2f[] + colors = Float64[] + for particle in findall(mask) + vector = values[[1, 3], particle] + magnitude = norm(vector) + magnitude <= eps(eltype(values)) && continue + direction = vector / magnitude + length_ = 1.5 * spacing * min(magnitude / reference, 1) + origin = Point2f(coordinates[1, particle], coordinates[3, particle]) + endpoint = origin + Point2f(direction[1] * length_, direction[2] * length_) + push!(points, origin, endpoint) + push!(colors, magnitude, magnitude) + end + return points, colors +end + +function force_diagnostics(analysis_path, output_path; requested_times=nothing) + analysis = open(deserialize, analysis_path) + frames = selected_frames(analysis, requested_times) + references = Dict(component.field => reference_magnitude(frames, component.field) + for component in COMPONENTS) + + x_min = minimum(minimum(frame.coordinates[1, :]) for frame in frames) + x_max = maximum(maximum(frame.coordinates[1, :]) for frame in frames) + z_min = minimum(minimum(frame.coordinates[3, :]) for frame in frames) + z_max = maximum(maximum(frame.coordinates[3, :]) for frame in frames) + margin = 2 * maximum(frame.particle_spacing for frame in frames) + + figure = Figure(; size=(310 * length(COMPONENTS), 310 * length(frames)), + fontsize=15) + for (row, frame) in enumerate(frames) + mask = center_slice(frame) + for (column, component) in enumerate(COMPONENTS) + values = component_values(frame, component.field) + magnitudes = vector_magnitudes(values) + reference = references[component.field] + unit_suffix = isempty(component.units) ? "" : " $(component.units)" + title = "$(component.label) | t=$(round(frame.time; digits=3)) s\n" * + "median=$(round(median(magnitudes); sigdigits=3)), " * + "color cap=$(round(reference; sigdigits=3))$unit_suffix" + axis = Axis(figure[row, column]; title, xlabel="x [m]", + ylabel=column == 1 ? "z [m]" : "", aspect=DataAspect()) + points, + colors = vector_segments(frame.coordinates, values, mask, + frame.particle_spacing, reference) + isempty(points) || linesegments!(axis, points; color=colors, + colorrange=(0, reference), colormap=:viridis, + linewidth=1.5) + scatter!(axis, frame.coordinates[1, mask], frame.coordinates[3, mask]; + color=magnitudes[mask], colorrange=(0, reference), colormap=:viridis, + markersize=5, strokecolor=:black, strokewidth=0.35) + hlines!(axis, 0; color=:gray35, linewidth=1.5) + limits!(axis, x_min - margin, x_max + margin, z_min - margin, z_max + margin) + end + end + + Label(figure[0, :], + "$(analysis.case_name): per-particle vectors; " * + "line length is clipped at each component's p95", + fontsize=20, font=:bold) + save(output_path, figure) + println("Wrote per-particle force diagnostics to $output_path") + return output_path +end + +if abspath(PROGRAM_FILE) == @__FILE__ + 2 <= length(ARGS) <= 3 || + error("pass an analysis snapshot, output path, and optional comma-separated times") + requested_times = length(ARGS) == 3 ? parse.(Float64, split(ARGS[3], ',')) : nothing + force_diagnostics(ARGS[1], ARGS[2]; requested_times) +end diff --git a/compare_akinci/investigate_wetting_models.jl b/compare_akinci/investigate_wetting_models.jl new file mode 100644 index 0000000000..ece7a93057 --- /dev/null +++ b/compare_akinci/investigate_wetting_models.jl @@ -0,0 +1,130 @@ +using Printf +using TrixiParticles + +include(joinpath(@__DIR__, "simulate_delta_sph_wetting.jl")) +include(joinpath(@__DIR__, "surface_model_variants.jl")) + +function wetting_model_config(model_name, parameter, final_time; + contact_angle=180.0, + total_surface_tension=nothing, + cohesion_coefficient=0.0, + target_particle_count=750) + config = delta_sph_config("wetting_no", final_time; delta=0.1, + target_particle_count) + particle_spacing = cbrt(1.0e-6 / target_particle_count) + smoothing_kernel = config.kwargs.smoothing_kernel + smoothing_length = config.kwargs.smoothing_length + support_radius = TrixiParticles.compact_support(smoothing_kernel, smoothing_length) + reference_density = 1000.0 + + surface_tension, normal_method, + wall_coefficient = if model_name == "akinci" + model = SurfaceTensionAkinci(; surface_tension_coefficient=parameter) + model, ColorfieldSurfaceNormal(; boundary_contact_threshold=Inf), 0.0 + elseif model_name == "akinci_invariant" + calibration_factor = something(total_surface_tension, 1.0) + reference_spacing = cbrt(1.0e-6 / 750) + reference_support_radius = 2.8reference_spacing + model = SurfaceTensionAkinciResolutionInvariant(; + surface_tension_coefficient=parameter, + calibration_factor, + reference_support_radius) + model, ColorfieldSurfaceNormal(; boundary_contact_threshold=Inf), 0.0 + elseif model_name == "cohesion" + coefficient = akinci_cohesion_coefficient(parameter, reference_density, + support_radius) + CohesionForceAkinci(; surface_tension_coefficient=coefficient), nothing, 0.0 + elseif model_name == "cohesion_physical" + model = SurfaceTensionAkinciCohesionPhysical(; + surface_tension_coefficient=parameter, + reference_density) + wall_ratio = (1 + cosd(contact_angle)) / 2 + model, nothing, wall_ratio + elseif model_name == "akinci_wall" + isnothing(total_surface_tension) && + throw(ArgumentError("`akinci_wall` requires `total_surface_tension`")) + wall = akinci_wall_cohesion_coefficient(total_surface_tension, contact_angle, + reference_density, support_radius) + model = SurfaceTensionAkinciWallCohesion(; + surface_tension_coefficient=parameter, + wall_cohesion_coefficient=wall) + model, ColorfieldSurfaceNormal(; boundary_contact_threshold=Inf), wall + elseif model_name == "akinci_wall_direct" + isnothing(total_surface_tension) && + throw(ArgumentError("`akinci_wall_direct` requires an absolute wall coefficient")) + wall = total_surface_tension + model = SurfaceTensionAkinciWallCohesion(; + surface_tension_coefficient=parameter, + wall_cohesion_coefficient=wall) + model, ColorfieldSurfaceNormal(; boundary_contact_threshold=Inf), wall + elseif model_name == "akinci_contact" + isnothing(total_surface_tension) && + throw(ArgumentError("`akinci_contact` requires a contact-line surface tension")) + model = SurfaceTensionAkinciContactLine(; + surface_tension_coefficient=parameter, + contact_line_surface_tension=total_surface_tension, + contact_angle, reference_density) + model, ColorfieldSurfaceNormal(; boundary_contact_threshold=Inf), 0.0 + elseif model_name == "hybrid" + wall = akinci_wall_cohesion_coefficient(parameter, contact_angle, + reference_density, support_radius) + model = SurfaceTensionMorrisAkinci(; + surface_tension_coefficient=parameter, + cohesion_coefficient, + wall_cohesion_coefficient=wall) + normal = ColorfieldSurfaceNormal(; boundary_contact_threshold=Inf, + interface_threshold=0.01, + ideal_density_threshold=0.95) + model, normal, wall + elseif model_name == "morris" + model = SurfaceTensionMorris(; surface_tension_coefficient=parameter) + normal = ColorfieldSurfaceNormal(; boundary_contact_threshold=Inf, + interface_threshold=0.01, + ideal_density_threshold=0.95) + model, normal, 0.0 + elseif model_name == "momentum_morris" + model = SurfaceTensionMomentumMorris(; surface_tension_coefficient=parameter) + normal = ColorfieldSurfaceNormal(; interface_threshold=0.01, + ideal_density_threshold=0.95, + contact_angle) + model, normal, 0.0 + else + throw(ArgumentError("unknown model `$model_name`")) + end + + boundary_adhesion = model_name == "cohesion_physical" ? wall_coefficient : 0.0 + kwargs = merge(config.kwargs, + (; surface_tension, surface_normal_method=normal_method, + adhesion_coefficient=boundary_adhesion)) + @printf("model=%s parameter=%.8g contact_angle=%.3f wall_coefficient=%.8g support_radius=%.8g particles=%d\n", + model_name, parameter, contact_angle, + wall_coefficient, support_radius, target_particle_count) + return merge(config, (; name=(config.name * "_" * model_name), kwargs)) +end + +if abspath(PROGRAM_FILE) == @__FILE__ + length(ARGS) in (4, 5, 6, 7) || + error("usage: investigate_wetting_models.jl MODEL PARAMETER OUTPUT.jls " * + "FINAL_TIME [CONTACT_ANGLE [EXTRA [TARGET_PARTICLE_COUNT]]]") + model_name = ARGS[1] + parameter = parse(Float64, ARGS[2]) + output = ARGS[3] + final_time = parse(Float64, ARGS[4]) + contact_angle = length(ARGS) >= 5 ? parse(Float64, ARGS[5]) : 180.0 + extra = length(ARGS) == 6 ? parse(Float64, ARGS[6]) : nothing + if length(ARGS) == 7 + extra = parse(Float64, ARGS[6]) + end + target_particle_count = length(ARGS) == 7 ? parse(Int, ARGS[7]) : 750 + options = if model_name in ("akinci_invariant", "akinci_wall", + "akinci_wall_direct", "akinci_contact") + (; contact_angle, total_surface_tension=extra, target_particle_count) + elseif model_name == "hybrid" + (; contact_angle, cohesion_coefficient=something(extra, 0.0), + target_particle_count) + else + (; contact_angle, target_particle_count) + end + config = wetting_model_config(model_name, parameter, final_time; options...) + write_snapshot(config, output) +end diff --git a/compare_akinci/make_overview.jl b/compare_akinci/make_overview.jl new file mode 100644 index 0000000000..72c728ee35 --- /dev/null +++ b/compare_akinci/make_overview.jl @@ -0,0 +1,78 @@ +using CairoMakie +using FileIO + +include("cases.jl") + +const BACKGROUND = RGBf(0.94, 0.96, 0.97) +const PANEL_BACKGROUND = RGBf(0.035, 0.045, 0.065) +const TEXT_COLOR = RGBf(0.08, 0.11, 0.14) + +function draw_cell!(position, cell) + axis = Axis(position; aspect=DataAspect(), backgroundcolor=PANEL_BACKGROUND, + title=cell.label, titlecolor=TEXT_COLOR, titlesize=17) + hidedecorations!(axis) + hidespines!(axis) + + if hasproperty(cell, :file) + path = joinpath(@__DIR__, cell.file) + isfile(path) || error("missing rendered panel: $path") + image!(axis, rotr90(FileIO.load(path))) + else + text!(axis, 0.5, 0.5; text=cell.placeholder, align=(:center, :center), + color=:white, fontsize=22, space=:relative) + limits!(axis, 0, 1, 0, 1) + end + return axis +end + +function make_plate(plate) + rows, columns = plate.layout + figure = Figure(; size=(420 * columns, 355 * rows + 130), + backgroundcolor=BACKGROUND, fontsize=18) + Label(figure[1, 1:columns], plate.title; color=TEXT_COLOR, fontsize=28, + font=:bold, tellwidth=false) + + for (index, cell) in enumerate(plate.cells) + row = div(index - 1, columns) + 2 + column = mod1(index, columns) + draw_cell!(figure[row, column], cell) + end + + caption_row = rows + 2 + Label(figure[caption_row, 1:columns], plate.caption; color=TEXT_COLOR, + fontsize=17, justification=:left, halign=:left, tellwidth=false) + rowgap!(figure.layout, 10) + colgap!(figure.layout, 6) + + output = joinpath(@__DIR__, plate.output) + save(output, figure) + println("Wrote $(plate.title) to $output") + return output +end + +function make_overview(plates) + columns = 2 + rows = cld(length(plates), columns) + figure = Figure(; size=(1800, 560 * rows), backgroundcolor=BACKGROUND, + fontsize=18) + + for (index, plate) in enumerate(plates) + row = div(index - 1, columns) + 1 + column = mod1(index, columns) + axis = Axis(figure[row, column]; aspect=DataAspect(), + backgroundcolor=BACKGROUND) + image!(axis, rotr90(FileIO.load(joinpath(@__DIR__, plate.output)))) + hidedecorations!(axis) + hidespines!(axis) + end + + rowgap!(figure.layout, 12) + colgap!(figure.layout, 12) + output = joinpath(@__DIR__, "akinci_comparison.png") + save(output, figure) + println("Wrote Akinci comparison overview to $output") + return output +end + +foreach(make_plate, PLATES) +make_overview(PLATES) diff --git a/compare_akinci/particle_diagnostics.jl b/compare_akinci/particle_diagnostics.jl new file mode 100644 index 0000000000..56c634e8dc --- /dev/null +++ b/compare_akinci/particle_diagnostics.jl @@ -0,0 +1,70 @@ +using CairoMakie +using Serialization + +function fluid_slice(frame, system_index, slice_center, slice_half_width) + system = frame.systems[system_index] + coordinates = system.coordinates + mask = abs.(coordinates[2, :] .- slice_center) .<= slice_half_width + pressure = isnothing(system.pressure) ? nothing : system.pressure[mask] + return (; coordinates=coordinates[:, mask], pressure) +end + +function particle_diagnostics(snapshot_path, output_path; system_index=1, + color_by_pressure=false) + snapshot = open(deserialize, snapshot_path) + isempty(snapshot.frames) && error("snapshot contains no frames") + + system = snapshot.frames[1].systems[system_index] + spacing = system.particle_spacing + y_min = minimum(system.coordinates[2, :]) + y_max = maximum(system.coordinates[2, :]) + slice_center = (y_min + y_max) / 2 + slice_half_width = 0.75 * spacing + slices = [fluid_slice(frame, system_index, slice_center, slice_half_width) + for frame in snapshot.frames] + + x_min = minimum(minimum(slice.coordinates[1, :]) for slice in slices) + x_max = maximum(maximum(slice.coordinates[1, :]) for slice in slices) + z_min = minimum(minimum(slice.coordinates[3, :]) for slice in slices) + z_max = maximum(maximum(slice.coordinates[3, :]) for slice in slices) + margin = 2 * spacing + + pressure_max = if color_by_pressure + available_pressures = [slice.pressure + for slice in slices + if !isnothing(slice.pressure)] + pressures = isempty(available_pressures) ? Float64[] : + reduce(vcat, available_pressures) + isempty(pressures) ? 1.0 : max(maximum(pressures), eps(eltype(pressures))) + else + 1.0 + end + + columns = min(4, length(snapshot.frames)) + rows = cld(length(snapshot.frames), columns) + figure = Figure(; size=(360 * columns, 310 * rows), fontsize=18) + + for (index, (time, frame)) in enumerate(zip(snapshot.times, snapshot.frames)) + row = div(index - 1, columns) + 1 + column = mod1(index, columns) + axis = Axis(figure[row, column]; title="t = $(round(time; digits=4)) s", + xlabel="x [m]", ylabel="z [m]", aspect=DataAspect()) + slice = slices[index] + color = color_by_pressure && !isnothing(slice.pressure) ? slice.pressure : + :dodgerblue3 + scatter!(axis, slice.coordinates[1, :], slice.coordinates[3, :]; color, + colormap=:Reds, colorrange=(0, pressure_max), markersize=3) + limits!(axis, x_min - margin, x_max + margin, z_min - margin, z_max + margin) + end + + save(output_path, figure) + println("Wrote raw particle diagnostics to $output_path") + return output_path +end + +if abspath(PROGRAM_FILE) == @__FILE__ + 2 <= length(ARGS) <= 3 || + error("pass a snapshot path, output path, and optionally 'pressure'") + color_by_pressure = length(ARGS) == 3 && ARGS[3] == "pressure" + particle_diagnostics(ARGS[1], ARGS[2]; color_by_pressure) +end diff --git a/compare_akinci/phase1_diagnostics.jl b/compare_akinci/phase1_diagnostics.jl new file mode 100644 index 0000000000..a0d023a3f2 --- /dev/null +++ b/compare_akinci/phase1_diagnostics.jl @@ -0,0 +1,70 @@ +using CairoMakie + +function phase1_diagnostics(output_path) + particle_counts = [389, 739, 1503, 2969, 6031] + css_phase0 = [1.0333, 1.0026, 0.9970, 0.9557, 1.0079] + css_phase1 = [1.03091, 0.99925, 0.99265, 0.95259, 1.00437] + + radii = [0.00498, 0.00617, 0.00782] + inverse_radius = 2 ./ radii + morris_pressure = [460.464, 363.865, 275.497] + css_pressure = [468.614, 352.515, 250.727] + fit_x = range(minimum(inverse_radius), maximum(inverse_radius); length=100) + morris_fit = -48.792 .+ 1.270360 .* fit_x + css_fit = -132.249 .+ 1.497079 .* fit_x + + figure = Figure(; size=(1200, 500), fontsize=16) + static_axis = Axis(figure[1, 1]; + title="Static CSS balance", + xlabel="particle count", + ylabel="inferred sigma / input sigma", + xscale=log10, + xticks=(particle_counts, string.(particle_counts))) + band!(static_axis, particle_counts, fill(0.95, length(particle_counts)), + fill(1.05, length(particle_counts)); color=(:seagreen, 0.13), + label="+/-5% acceptance") + hlines!(static_axis, [1.0]; color=:gray35, linestyle=:dash) + lines!(static_axis, particle_counts, css_phase0; color=:gray45, linewidth=2, + label="Phase 0") + scatter!(static_axis, particle_counts, css_phase0; color=:gray45, markersize=11) + lines!(static_axis, particle_counts, css_phase1; color=:dodgerblue3, linewidth=3, + label="Phase 1") + scatter!(static_axis, particle_counts, css_phase1; color=:dodgerblue3, + markersize=12) + ylims!(static_axis, 0.93, 1.06) + axislegend(static_axis; position=:rb) + + laplace_axis = Axis(figure[1, 2]; + title="Dynamic three-radius Laplace fit", + xlabel="2 / R [1/m]", + ylabel="median interior pressure [Pa]") + lines!(laplace_axis, fit_x, morris_fit; color=:darkorange2, linewidth=2, + label="Morris fit: sigma=1.270") + scatter!(laplace_axis, inverse_radius, morris_pressure; color=:darkorange2, + markersize=13) + lines!(laplace_axis, fit_x, css_fit; color=:purple3, linewidth=2, + label="CSS fit: sigma=1.497") + scatter!(laplace_axis, inverse_radius, css_pressure; color=:purple3, + marker=:diamond, markersize=14) + axislegend(laplace_axis; position=:lt) + text!(laplace_axis, maximum(inverse_radius), minimum(css_pressure) + 12; + text="Both series finish below 5 min\nMorris: 137 s | CSS: 119 s", + align=(:right, :bottom), color=:gray25) + + Label(figure[0, :], + "Phase 1 surface-tension diagnostics: static preservation and dynamic bias", + fontsize=21, font=:bold) + Label(figure[2, :], + "Static CSS remains within 5%. Dynamic slope overprediction is retained as a Phase 2 convergence target.", + fontsize=14, color=:gray30) + + save(output_path, figure; px_per_unit=1.5) + println("Wrote Phase 1 diagnostics to $output_path") + return output_path +end + +if abspath(PROGRAM_FILE) == @__FILE__ + output_path = isempty(ARGS) ? + joinpath(@__DIR__, "phase1_surface_tension_diagnostic.png") : ARGS[1] + phase1_diagnostics(output_path) +end diff --git a/compare_akinci/phase1_reliability.jl b/compare_akinci/phase1_reliability.jl new file mode 100644 index 0000000000..fb51b2fb18 --- /dev/null +++ b/compare_akinci/phase1_reliability.jl @@ -0,0 +1,51 @@ +using Printf + +include(joinpath(@__DIR__, "surface_tension_calibration.jl")) + +function run_phase1_case(model_name, alpha, support_width, final_time, + target_particle_count) + model = calibration_model(model_name, 1.0) + result = laplace_pressure_calibration(model; final_time, target_particle_count, + interface_taper_start=alpha, + support_taper_width=support_width, + record_steps=true) + print_laplace_calibration(model_name, 1.0, result) + return result +end + +function run_phase1_sensitivity(model_name, final_time, target_particle_count) + println("model,alpha,support_width,sigma_fit,runtime,accepted,rejected,eta_p01," * + "eta_median,eta_tail_head,speed_rms,active,transition") + for alpha in (0.5, 0.8, 0.9), support_width in (0.025, 0.05, 0.10) + result = run_phase1_case(model_name, alpha, support_width, final_time, + target_particle_count) + @printf("%s,%.3f,%.3f,%.8f,%.3f,%d,%d,%.6f,%.6f,%.6f,%.6e,%d,%d\n", + model_name, alpha, support_width, result.inferred_surface_tension, + result.runtime, result.accepted_steps, result.rejected_steps, + result.eta_p01, result.eta_median, result.eta_tail_head, + result.speed_rms, result.active_particles, result.transition_particles) + end +end + +if abspath(PROGRAM_FILE) == @__FILE__ + length(ARGS) in (6, 4) || + error("usage: phase1_reliability.jl single MODEL ALPHA SUPPORT_WIDTH FINAL_TIME TARGET_PARTICLES\n" * + " or: phase1_reliability.jl sensitivity MODEL FINAL_TIME TARGET_PARTICLES") + + mode = ARGS[1] + model_name = ARGS[2] + if mode == "single" + alpha = parse(Float64, ARGS[3]) + support_width = parse(Float64, ARGS[4]) + final_time = parse(Float64, ARGS[5]) + target_particle_count = parse(Int, ARGS[6]) + run_phase1_case(model_name, alpha, support_width, final_time, + target_particle_count) + elseif mode == "sensitivity" + final_time = parse(Float64, ARGS[3]) + target_particle_count = parse(Int, ARGS[4]) + run_phase1_sensitivity(model_name, final_time, target_particle_count) + else + error("unknown mode `$mode`") + end +end diff --git a/compare_akinci/phase1_surface_tension_diagnostic.png b/compare_akinci/phase1_surface_tension_diagnostic.png new file mode 100644 index 0000000000..fa7a08f617 Binary files /dev/null and b/compare_akinci/phase1_surface_tension_diagnostic.png differ diff --git a/compare_akinci/pressure_equilibrium.jl b/compare_akinci/pressure_equilibrium.jl new file mode 100644 index 0000000000..c573a4a208 --- /dev/null +++ b/compare_akinci/pressure_equilibrium.jl @@ -0,0 +1,587 @@ +using LinearAlgebra +using Printf +using Serialization +using Statistics +using TrixiParticles + +const TP = TrixiParticles + +include(joinpath(@__DIR__, "boundary_volume.jl")) +include(joinpath(@__DIR__, "force_analysis.jl")) + +function corrected_boundary_mass() + particle_spacing = cbrt(1.0e-6 / 750) + plate_size = (0.06, 0.06) + n_plate = round.(Int, plate_size ./ particle_spacing) + plate = RectangularShape(particle_spacing, (n_plate..., 3), + (-plate_size[1] / 2, -plate_size[2] / 2, + -3particle_spacing); + density=1000.0) + return akinci_boundary_hydrodynamic_mass(plate, + SchoenbergCubicSplineKernel{3}(), + particle_spacing - eps(), 1000.0) +end + +function adami_pressure_weights(fluid_system, boundary_system, v_ode, u_ode, semi) + n_fluid = TP.nparticles(fluid_system) + n_boundary = TP.nparticles(boundary_system) + weights = zeros(eltype(fluid_system), n_boundary, n_fluid) + normalization = zeros(eltype(fluid_system), n_boundary) + + u_fluid = TP.wrap_u(u_ode, fluid_system, semi) + u_boundary = TP.wrap_u(u_ode, boundary_system, semi) + fluid_coordinates = TP.current_coordinates(u_fluid, fluid_system) + boundary_coordinates = TP.current_coordinates(u_boundary, boundary_system) + boundary_model = boundary_system.boundary_model + + TP.foreach_point_neighbor(boundary_system, fluid_system, boundary_coordinates, + fluid_coordinates, semi; + points=TP.eachparticle(boundary_system)) do boundary_particle, + fluid_particle, + pos_diff, + distance + weight = TP.smoothing_kernel(boundary_model, distance, boundary_particle) + weights[boundary_particle, fluid_particle] += weight + normalization[boundary_particle] += weight + end + + for boundary_particle in axes(weights, 1) + normalization[boundary_particle] > eps() || continue + weights[boundary_particle, :] ./= normalization[boundary_particle] + end + + return weights, copy(boundary_model.pressure) +end + +function reflected_adami_pressure_weights(fluid_system, boundary_system, v_ode, u_ode, + semi, ghost_shift) + n_fluid = TP.nparticles(fluid_system) + n_boundary = TP.nparticles(boundary_system) + weights = zeros(eltype(fluid_system), n_boundary, n_fluid) + pressure_numerator = zeros(eltype(fluid_system), n_boundary) + normalization = zeros(eltype(fluid_system), n_boundary) + + v_fluid = TP.wrap_v(v_ode, fluid_system, semi) + u_fluid = TP.wrap_u(u_ode, fluid_system, semi) + u_boundary = TP.wrap_u(u_ode, boundary_system, semi) + fluid_coordinates = TP.current_coordinates(u_fluid, fluid_system) + boundary_coordinates = TP.current_coordinates(u_boundary, boundary_system) + boundary_model = boundary_system.boundary_model + support = TP.compact_support(boundary_model.smoothing_kernel, + boundary_model.smoothing_length) + dimensions = TP.ndims(fluid_system) + top = maximum(boundary_coordinates[end, :]) + fluid_acceleration = TP.acceleration_source(fluid_system) + + for boundary_particle in TP.eachparticle(boundary_system) + isapprox(boundary_coordinates[end, boundary_particle], top) || continue + for fluid_particle in TP.eachparticle(fluid_system) + distance_squared = zero(eltype(fluid_system)) + for dimension in 1:dimensions + evaluation_coordinate = boundary_coordinates[dimension, boundary_particle] + + (dimension == dimensions ? ghost_shift : 0) + difference = evaluation_coordinate - + fluid_coordinates[dimension, fluid_particle] + distance_squared += difference^2 + end + distance_squared < support^2 || continue + distance = sqrt(distance_squared) + weight = TP.smoothing_kernel(boundary_model, distance, boundary_particle) + density = TP.current_density(v_fluid, fluid_system, fluid_particle) + hydrostatic_pressure = zero(eltype(fluid_system)) + for dimension in 1:dimensions + original_difference = boundary_coordinates[dimension, boundary_particle] - + fluid_coordinates[dimension, fluid_particle] + hydrostatic_pressure += fluid_acceleration[dimension] * density * + original_difference + end + weights[boundary_particle, fluid_particle] += weight + pressure_numerator[boundary_particle] += weight * hydrostatic_pressure + normalization[boundary_particle] += weight + end + end + + boundary_pressure = zeros(eltype(fluid_system), n_boundary) + for boundary_particle in TP.eachparticle(boundary_system) + normalization[boundary_particle] > eps() || continue + weights[boundary_particle, :] ./= normalization[boundary_particle] + boundary_pressure[boundary_particle] = max(pressure_numerator[boundary_particle] / + normalization[boundary_particle], 0) + end + + return weights, boundary_pressure +end + +function boundary_pressure_derivative(state_equation, pressure) + pressure_step = max(1.0e-3, 1.0e-6 * max(abs(pressure), 1.0)) + density = TP.inverse_state_equation(state_equation, pressure) + density_plus = TP.inverse_state_equation(state_equation, pressure + pressure_step) + return ((pressure + pressure_step) / density_plus^2 - pressure / density^2) / + pressure_step +end + +function boundary_pressure_derivative_continuity(state_equation, pressure, fluid_density) + pressure_step = max(1.0e-3, 1.0e-6 * max(abs(pressure), 1.0)) + density = TP.inverse_state_equation(state_equation, pressure) + density_plus = TP.inverse_state_equation(state_equation, pressure + pressure_step) + return ((pressure + pressure_step) / (fluid_density * density_plus) - + pressure / (fluid_density * density)) / pressure_step +end + +function pressure_over_density_derivative(state_equation, pressure) + pressure_step = max(1.0e-3, 1.0e-6 * max(abs(pressure), 1.0)) + density = TP.inverse_state_equation(state_equation, pressure) + density_plus = TP.inverse_state_equation(state_equation, pressure + pressure_step) + return ((pressure + pressure_step) / density_plus - pressure / density) / + pressure_step +end + +function planar_kernel_integral(smoothing_kernel, smoothing_length, wall_distance; + intervals=200) + support = TP.compact_support(smoothing_kernel, smoothing_length) + distance = max(wall_distance, zero(wall_distance)) + distance < support || return zero(wall_distance) + iseven(intervals) || error("Simpson quadrature requires an even interval count") + + step = (support - distance) / intervals + integral = zero(wall_distance) + for index in 0:intervals + radius = distance + index * step + value = TP.kernel(smoothing_kernel, radius, smoothing_length) * radius + coefficient = index == 0 || index == intervals ? 1 : (isodd(index) ? 4 : 2) + integral += coefficient * value + end + return 2pi * step * integral / 3 +end + +function add_semi_analytical_wall!(operator, baseline_wall_acceleration, fluid_system, + fluid_coordinates, fluid_density, density_calculator) + dimensions = TP.ndims(fluid_system) + dimensions == 3 || error("planar semi-analytical prototype currently requires 3D") + state_equation = fluid_system.state_equation + smoothing_kernel = fluid_system.smoothing_kernel + smoothing_length = TP.initial_smoothing_length(fluid_system) + gravity = -TP.acceleration_source(fluid_system)[end] + integral_cache = Dict{eltype(fluid_system), eltype(fluid_system)}() + + for particle in TP.eachparticle(fluid_system) + wall_distance = fluid_coordinates[end, particle] + integral = get!(integral_cache, wall_distance) do + planar_kernel_integral(smoothing_kernel, smoothing_length, wall_distance) + end + iszero(integral) && continue + + density = fluid_density[particle] + wall_pressure = max(density * gravity * wall_distance, 0) + wall_density = TP.inverse_state_equation(state_equation, wall_pressure) + row = dimensions * particle + + if density_calculator isa SummationDensity + baseline_wall_acceleration[end, + particle] = integral * wall_pressure / + wall_density + operator[row, + particle] += integral * + (wall_density / density^2 + + pressure_over_density_derivative(state_equation, + wall_pressure)) + else + baseline_wall_acceleration[end, particle] = integral * wall_pressure / density + operator[row, particle] += 2integral / density + end + end + + return baseline_wall_acceleration +end + +function add_mirrored_ghost_wall!(operator, baseline_wall_acceleration, fluid_system, + fluid_coordinates, fluid_density, density_calculator) + dimensions = TP.ndims(fluid_system) + dimensions == 3 || error("mirrored ghost prototype currently requires 3D") + support = TP.compact_support(fluid_system.smoothing_kernel, + TP.initial_smoothing_length(fluid_system)) + gravity = -TP.acceleration_source(fluid_system)[end] + + for source_particle in TP.eachparticle(fluid_system) + source_height = fluid_coordinates[end, source_particle] + source_height < support || continue + ghost_pressure_offset = 2fluid_density[source_particle] * gravity * source_height + source_mass = TP.hydrodynamic_mass(fluid_system, source_particle) + + for particle in TP.eachparticle(fluid_system) + pos_diff = SVector(ntuple(dimensions) do dimension + ghost_coordinate = dimension == dimensions ? + -source_height : + fluid_coordinates[dimension, + source_particle] + fluid_coordinates[dimension, particle] - + ghost_coordinate + end) + distance = norm(pos_diff) + distance < support || continue + gradient = TP.smoothing_kernel_grad_unsafe(fluid_system, pos_diff, distance, + particle) + + if density_calculator isa SummationDensity + coefficient_particle = -source_mass / fluid_density[particle]^2 + coefficient_source = -source_mass / fluid_density[source_particle]^2 + baseline_coefficient = coefficient_source * ghost_pressure_offset + else + coefficient_particle = -source_mass / + (fluid_density[particle] * + fluid_density[source_particle]) + coefficient_source = coefficient_particle + baseline_coefficient = coefficient_source * ghost_pressure_offset + end + + for dimension in 1:dimensions + row = dimension + dimensions * (particle - 1) + operator[row, particle] += coefficient_particle * gradient[dimension] + operator[row, source_particle] += coefficient_source * gradient[dimension] + baseline_wall_acceleration[dimension, + particle] += baseline_coefficient * + gradient[dimension] + end + end + end + + return baseline_wall_acceleration +end + +function curvature_acceleration(assembled, scaled_normal) + (; semi, fluid_system, v_ode, u_ode) = assembled.setup + v_fluid = TP.wrap_v(v_ode, fluid_system, semi) + u_fluid = TP.wrap_u(u_ode, fluid_system, semi) + coordinates = TP.current_coordinates(u_fluid, fluid_system) + acceleration = zeros(eltype(fluid_system), TP.ndims(fluid_system), + TP.nparticles(fluid_system)) + coefficient = TP.surface_tension_model(fluid_system).surface_tension_coefficient + correction = fluid_system.correction + + TP.foreach_point_neighbor(fluid_system, fluid_system, coordinates, coordinates, semi; + points=TP.each_integrated_particle(fluid_system)) do particle, + neighbor, + pos_diff, + distance + distance^2 < eps(TP.initial_smoothing_length(fluid_system)^2) && return + density_particle = TP.current_density(v_fluid, fluid_system, particle) + density_neighbor = TP.current_density(v_fluid, fluid_system, neighbor) + correction_density_particle = TP.correction_density(correction, fluid_system, + particle, density_particle) + correction_density_neighbor = TP.correction_density(correction, fluid_system, + neighbor, density_neighbor) + _, _, + surface_correction = TP.free_surface_correction(correction, fluid_system, + correction_density_particle, + correction_density_neighbor) + acceleration[:, + particle] .-= surface_correction * coefficient .* + (scaled_normal[:, particle] - + scaled_normal[:, neighbor]) + end + return acceleration +end + +function impose_contact_angle(assembled, angle_degrees) + normal = copy(assembled.quantities.scaled_normal) + coordinates = assembled.quantities.coordinates + center = vec(mean(coordinates; dims=2)) + wall_normal = SVector(0.0, 0.0, 1.0) + angle = deg2rad(angle_degrees) + contact = assembled.quantities.boundary_neighbor_count .> 0 + + for particle in findall(contact) + magnitude = norm(normal[:, particle]) + iszero(magnitude) && continue + tangential = normal[:, particle] - + dot(normal[:, particle], wall_normal) * wall_normal + if norm(tangential) <= eps() + tangential = center - coordinates[:, particle] + tangential[end] = 0 + end + tangential_direction = norm(tangential) > eps() ? tangential / norm(tangential) : + zero(wall_normal) + direction = -cos(angle) * wall_normal + sin(angle) * tangential_direction + normal[:, particle] .= magnitude .* direction + end + + original_curvature = curvature_acceleration(assembled, + assembled.quantities.scaled_normal) + reconstruction_error = maximum(abs, original_curvature - assembled.quantities.curvature) + reconstruction_error < 1.0e-10 || + error("curvature reconstruction failed with error $reconstruction_error") + modified_curvature = curvature_acceleration(assembled, normal) + baseline_acceleration = assembled.baseline_acceleration + + vec(modified_curvature - original_curvature) + return merge(assembled, + (; baseline_acceleration, contact_angle=angle_degrees, + contact_count=count(contact), + curvature_reconstruction_error=reconstruction_error)) +end + +function assemble_pressure_operator(density_calculator; + snapshot_path="/tmp/opencode/wetting_no_paper_final.jls", + ghost_shift=0.0, wall_quadrature=:dummy) + boundary_mass = corrected_boundary_mass() + config = case_config("wetting_no") + semi, + ode_state = diagnostic_setup(config; + setup_overrides=(; + fluid_density_calculator=density_calculator, + boundary_hydrodynamic_mass=boundary_mass)) + v_ode, u_ode = ode_state + snapshot = open(deserialize, snapshot_path) + frame = first(snapshot.frames) + fluid_system = semi.systems[1] + v_fluid = TP.wrap_v(v_ode, fluid_system, semi) + u_fluid = TP.wrap_u(u_ode, fluid_system, semi) + dimensions = TP.ndims(fluid_system) + v_fluid[1:dimensions, :] .= frame.systems[1].velocity + u_fluid .= frame.systems[1].coordinates + TP.update_systems_and_nhs(v_ode, u_ode, semi, 0.0) + + setup = (; semi, fluid_system, v_ode, u_ode) + boundary_system = semi.systems[2] + quantities = force_components(v_ode, u_ode, semi, 0.0) + n_fluid = TP.nparticles(fluid_system) + operator = zeros(eltype(fluid_system), dimensions * n_fluid, n_fluid) + + fluid_coordinates = TP.current_coordinates(u_fluid, fluid_system) + fluid_density = [TP.current_density(v_fluid, fluid_system, particle) + for particle in TP.eachparticle(fluid_system)] + + TP.foreach_point_neighbor(fluid_system, fluid_system, fluid_coordinates, + fluid_coordinates, semi; + points=TP.each_integrated_particle(fluid_system)) do particle, + neighbor, + pos_diff, + distance + distance < sqrt(eps(TP.compact_support(fluid_system, fluid_system)^2)) && return + gradient = TP.smoothing_kernel_grad_unsafe(fluid_system, pos_diff, distance, + particle) + neighbor_mass = TP.hydrodynamic_mass(fluid_system, neighbor) + if density_calculator isa SummationDensity + coefficient_particle = -neighbor_mass / fluid_density[particle]^2 + coefficient_neighbor = -neighbor_mass / fluid_density[neighbor]^2 + else + coefficient_particle = -neighbor_mass / + (fluid_density[particle] * fluid_density[neighbor]) + coefficient_neighbor = coefficient_particle + end + for dimension in 1:dimensions + row = dimension + dimensions * (particle - 1) + operator[row, particle] += coefficient_particle * gradient[dimension] + operator[row, neighbor] += coefficient_neighbor * gradient[dimension] + end + end + + if wall_quadrature == :semi_analytical || wall_quadrature == :mirrored_ghosts + boundary_acceleration = zeros(eltype(fluid_system), dimensions, n_fluid) + if wall_quadrature == :semi_analytical + add_semi_analytical_wall!(operator, boundary_acceleration, fluid_system, + fluid_coordinates, fluid_density, density_calculator) + else + add_mirrored_ghost_wall!(operator, boundary_acceleration, fluid_system, + fluid_coordinates, fluid_density, density_calculator) + end + nonpressure_acceleration = quantities.total - quantities.pressure_boundary + baseline_acceleration = vec(nonpressure_acceleration + boundary_acceleration) + return (; operator, baseline_acceleration, setup, quantities, fluid_density, + boundary_reconstruction_error=NaN) + elseif wall_quadrature != :dummy + throw(ArgumentError("unknown wall quadrature: $wall_quadrature")) + end + + pressure_weights, + boundary_pressure = if iszero(ghost_shift) + adami_pressure_weights(fluid_system, boundary_system, v_ode, u_ode, semi) + else + reflected_adami_pressure_weights(fluid_system, boundary_system, v_ode, u_ode, semi, + ghost_shift) + end + boundary_model = boundary_system.boundary_model + boundary_state_equation = boundary_model.state_equation + boundary_density = TP.inverse_state_equation.(Ref(boundary_state_equation), + boundary_pressure) + boundary_mass = boundary_model.hydrodynamic_mass + u_boundary = TP.wrap_u(u_ode, boundary_system, semi) + boundary_coordinates = TP.current_coordinates(u_boundary, boundary_system) + + TP.foreach_point_neighbor(fluid_system, boundary_system, fluid_coordinates, + boundary_coordinates, semi; + points=TP.each_integrated_particle(fluid_system)) do particle, + boundary_particle, + pos_diff, + distance + gradient = TP.smoothing_kernel_grad_unsafe(fluid_system, pos_diff, distance, + particle) + if density_calculator isa SummationDensity + direct_coefficient = -boundary_mass[boundary_particle] / + fluid_density[particle]^2 + pressure_derivative = boundary_pressure_derivative(boundary_state_equation, + boundary_pressure[boundary_particle]) + else + direct_coefficient = -boundary_mass[boundary_particle] / + (fluid_density[particle] * + boundary_density[boundary_particle]) + pressure_derivative = boundary_pressure_derivative_continuity(boundary_state_equation, + boundary_pressure[boundary_particle], + fluid_density[particle]) + end + boundary_coefficient = -boundary_mass[boundary_particle] * pressure_derivative + for dimension in 1:dimensions + row = dimension + dimensions * (particle - 1) + operator[row, particle] += direct_coefficient * gradient[dimension] + factor = boundary_coefficient * gradient[dimension] + for fluid_pressure_particle in axes(pressure_weights, 2) + weight = pressure_weights[boundary_particle, fluid_pressure_particle] + iszero(weight) && continue + operator[row, fluid_pressure_particle] += factor * weight + end + end + end + + boundary_acceleration = zeros(eltype(fluid_system), dimensions, n_fluid) + TP.foreach_point_neighbor(fluid_system, boundary_system, fluid_coordinates, + boundary_coordinates, semi; + points=TP.each_integrated_particle(fluid_system)) do particle, + boundary_particle, + pos_diff, + distance + gradient = TP.smoothing_kernel_grad_unsafe(fluid_system, pos_diff, distance, + particle) + pressure_factor = if density_calculator isa SummationDensity + boundary_pressure[boundary_particle] / boundary_density[boundary_particle]^2 + else + boundary_pressure[boundary_particle] / + (fluid_density[particle] * boundary_density[boundary_particle]) + end + boundary_acceleration[:, + particle] .-= boundary_mass[boundary_particle] * + pressure_factor .* gradient + end + nonpressure_acceleration = quantities.total - quantities.pressure_boundary + baseline_acceleration = vec(nonpressure_acceleration + boundary_acceleration) + boundary_reconstruction_error = iszero(ghost_shift) ? + maximum(abs, + boundary_acceleration - + quantities.pressure_boundary) : NaN + return (; operator, baseline_acceleration, setup, quantities, fluid_density, + boundary_reconstruction_error) +end + +function estimate_lipschitz(operator; iterations=40) + vector = fill(inv(sqrt(size(operator, 2))), size(operator, 2)) + eigenvalue = zero(eltype(operator)) + for _ in 1:iterations + product = transpose(operator) * (operator * vector) + eigenvalue = norm(product) + vector .= product ./ eigenvalue + end + return eigenvalue +end + +function nonnegative_least_squares(operator, target; initial_pressure=nothing, + max_iterations=5000, tolerance=1.0e-9) + pressure = isnothing(initial_pressure) ? zeros(size(operator, 2)) : + max.(copy(initial_pressure), 0) + extrapolated = copy(pressure) + momentum = one(eltype(operator)) + lipschitz = estimate_lipschitz(operator) + + for iteration in 1:max_iterations + gradient = transpose(operator) * (operator * extrapolated - target) + pressure_new = max.(extrapolated .- gradient ./ lipschitz, 0) + relative_change = norm(pressure_new - pressure) / max(norm(pressure), 1) + momentum_new = (1 + sqrt(1 + 4momentum^2)) / 2 + extrapolated .= pressure_new .+ + ((momentum - 1) / momentum_new) .* (pressure_new - pressure) + pressure .= pressure_new + momentum = momentum_new + relative_change < tolerance && return pressure, iteration, lipschitz + end + + return pressure, max_iterations, lipschitz +end + +function print_equilibrium_summary(label, assembled, pressure_kpa) + operator_kpa = 1000assembled.operator + residual = assembled.baseline_acceleration + operator_kpa * pressure_kpa + residual_vectors = reshape(residual, TP.ndims(assembled.setup.fluid_system), :) + residual_magnitude = vec(sqrt.(sum(abs2, residual_vectors; dims=1))) + baseline_vectors = reshape(assembled.baseline_acceleration, + TP.ndims(assembled.setup.fluid_system), :) + baseline_magnitude = vec(sqrt.(sum(abs2, baseline_vectors; dims=1))) + + @printf("%s\n", label) + @printf(" pressure [Pa]: median %.3f, p90 %.3f, max %.3f, active %d/%d\n", + 1000median(pressure_kpa), 1000quantile(pressure_kpa, 0.9), + 1000maximum(pressure_kpa), count(>(0), pressure_kpa), length(pressure_kpa)) + @printf(" acceleration magnitude baseline/residual RMS: %.4f / %.4f m/s^2\n", + sqrt(mean(abs2, baseline_magnitude)), sqrt(mean(abs2, residual_magnitude))) + @printf(" residual magnitude median %.4f, p90 %.4f, max %.4f m/s^2\n", + median(residual_magnitude), quantile(residual_magnitude, 0.9), + maximum(residual_magnitude)) + @printf(" residual mean z: %.6f m/s^2\n", mean(residual_vectors[end, :])) + isfinite(assembled.boundary_reconstruction_error) && + @printf(" boundary-pressure reconstruction error: %.3e m/s^2\n", + assembled.boundary_reconstruction_error) + + return residual_vectors +end + +function solve_equilibrium(snapshot_path, output_path) + particle_spacing = cbrt(1.0e-6 / 750) + configurations = (("SummationDensity", SummationDensity(), 0.0, :dummy), + ("ContinuityDensity", ContinuityDensity(), 0.0, :dummy), + ("ContinuityDensity reflected mDBC", ContinuityDensity(), + particle_spacing, :dummy), + ("SummationDensity semi-analytical wall", SummationDensity(), 0.0, + :semi_analytical), + ("ContinuityDensity semi-analytical wall", ContinuityDensity(), 0.0, + :semi_analytical), + ("ContinuityDensity mirrored ghost forces", ContinuityDensity(), 0.0, + :mirrored_ghosts)) + results = map(configurations) do configuration + label, density_calculator, ghost_shift, wall_quadrature = configuration + assembled = assemble_pressure_operator(density_calculator; snapshot_path, + ghost_shift, wall_quadrature) + operator_kpa = 1000assembled.operator + target = -assembled.baseline_acceleration + radius = cbrt(3.0e-6 / (4pi)) + initial_pressure = fill(2 / radius / 1000, size(operator_kpa, 2)) + pressure_kpa, iterations, + lipschitz = nonnegative_least_squares(operator_kpa, + target; + initial_pressure) + residual = print_equilibrium_summary(label, assembled, pressure_kpa) + normal_matrix = transpose(operator_kpa) * operator_kpa + normal_rhs = transpose(operator_kpa) * target + unconstrained_pressure = (normal_matrix + 1.0e-12I) \ normal_rhs + unconstrained_residual = assembled.baseline_acceleration + + operator_kpa * unconstrained_pressure + unconstrained_vectors = reshape(unconstrained_residual, + TP.ndims(assembled.setup.fluid_system), :) + @printf(" unconstrained residual RMS %.4f, mean z %.6f, negative pressure %d/%d\n", + sqrt(mean(abs2, unconstrained_residual)), + mean(unconstrained_vectors[end, :]), count(<(0), unconstrained_pressure), + length(unconstrained_pressure)) + density = TP.inverse_state_equation.(Ref(assembled.setup.fluid_system.state_equation), + 1000pressure_kpa) + (; label, pressure=1000pressure_kpa, density, residual, iterations, lipschitz, + baseline_acceleration=assembled.baseline_acceleration, + unconstrained_pressure=1000unconstrained_pressure, + unconstrained_residual) + end + + open(output_path, "w") do io + serialize(io, (; results)) + end + println("Wrote pressure equilibrium analysis to ", output_path) + return results +end + +if abspath(PROGRAM_FILE) == @__FILE__ + length(ARGS) == 2 || error("usage: pressure_equilibrium.jl SNAPSHOT.jls OUTPUT.jls") + solve_equilibrium(ARGS[1], ARGS[2]) +end diff --git a/compare_akinci/pressure_resolution_study.jl b/compare_akinci/pressure_resolution_study.jl new file mode 100644 index 0000000000..83dbcb2cd9 --- /dev/null +++ b/compare_akinci/pressure_resolution_study.jl @@ -0,0 +1,208 @@ +using LinearAlgebra +using Printf +using Serialization +using SparseArrays +using Statistics +using TrixiParticles + +const TP = TrixiParticles + +include(joinpath(@__DIR__, "pressure_equilibrium.jl")) + +function corrected_boundary_mass(particle_spacing) + plate_size = (0.06, 0.06) + n_plate = round.(Int, plate_size ./ particle_spacing) + plate = RectangularShape(particle_spacing, (n_plate..., 3), + (-plate_size[1] / 2, -plate_size[2] / 2, + -3particle_spacing); + density=1000.0) + return akinci_boundary_hydrodynamic_mass(plate, + SchoenbergCubicSplineKernel{3}(), + particle_spacing - eps(), 1000.0) +end + +function assemble_sparse_operator(target_particle_count) + particle_spacing = cbrt(1.0e-6 / target_particle_count) + boundary_mass = corrected_boundary_mass(particle_spacing) + config = case_config("wetting_no") + semi, + ode_state = diagnostic_setup(config; + setup_overrides=(; + particle_spacing, + fluid_density_calculator=ContinuityDensity(), + boundary_hydrodynamic_mass=boundary_mass, + parallelization_backend=SerialBackend())) + v_ode, u_ode = ode_state + TP.update_systems_and_nhs(v_ode, u_ode, semi, 0.0) + + fluid_system = semi.systems[1] + dimensions = TP.ndims(fluid_system) + n_fluid = TP.nparticles(fluid_system) + v_fluid = TP.wrap_v(v_ode, fluid_system, semi) + u_fluid = TP.wrap_u(u_ode, fluid_system, semi) + fluid_coordinates = TP.current_coordinates(u_fluid, fluid_system) + fluid_density = [TP.current_density(v_fluid, fluid_system, particle) + for particle in TP.eachparticle(fluid_system)] + quantities = force_components(v_ode, u_ode, semi, 0.0) + + estimated_entries = 180n_fluid + rows = sizehint!(Int[], estimated_entries) + columns = sizehint!(Int[], estimated_entries) + values = sizehint!(Float64[], estimated_entries) + + TP.foreach_point_neighbor(fluid_system, fluid_system, fluid_coordinates, + fluid_coordinates, semi; + points=TP.each_integrated_particle(fluid_system)) do particle, + neighbor, + pos_diff, + distance + distance < sqrt(eps(TP.compact_support(fluid_system, fluid_system)^2)) && return + gradient = TP.smoothing_kernel_grad_unsafe(fluid_system, pos_diff, distance, + particle) + neighbor_mass = TP.hydrodynamic_mass(fluid_system, neighbor) + coefficient = -neighbor_mass / + (fluid_density[particle] * fluid_density[neighbor]) + for dimension in 1:dimensions + row = dimension + dimensions * (particle - 1) + push!(rows, row) + push!(columns, particle) + push!(values, coefficient * gradient[dimension]) + push!(rows, row) + push!(columns, neighbor) + push!(values, coefficient * gradient[dimension]) + end + end + + baseline_wall_acceleration = zeros(Float64, dimensions, n_fluid) + wall_coefficient = zeros(Float64, n_fluid) + smoothing_length = TP.initial_smoothing_length(fluid_system) + smoothing_kernel = fluid_system.smoothing_kernel + gravity = -TP.acceleration_source(fluid_system)[end] + support_count = 0 + integral_cache = Dict{Float64, Float64}() + for particle in TP.eachparticle(fluid_system) + wall_distance = fluid_coordinates[end, particle] + integral = get!(integral_cache, wall_distance) do + planar_kernel_integral(smoothing_kernel, smoothing_length, wall_distance) + end + iszero(integral) && continue + support_count += 1 + density = fluid_density[particle] + wall_pressure = max(density * gravity * wall_distance, 0) + baseline_wall_acceleration[end, particle] = integral * wall_pressure / density + wall_coefficient[particle] = 2integral / density + push!(rows, dimensions * particle) + push!(columns, particle) + push!(values, wall_coefficient[particle]) + end + + operator = sparse(rows, columns, values, dimensions * n_fluid, n_fluid) + nonpressure_acceleration = quantities.total - quantities.pressure_boundary + baseline_acceleration = vec(nonpressure_acceleration + baseline_wall_acceleration) + return (; operator, baseline_acceleration, wall_coefficient, + baseline_wall_acceleration=vec(baseline_wall_acceleration[end, :]), + particle_spacing, n_fluid, support_count, + boundary_particle_count=TP.nparticles(semi.systems[2])) +end + +function cgls(operator, target; initial=nothing, max_iterations=1500, + tolerance=1.0e-9) + solution = isnothing(initial) ? zeros(size(operator, 2)) : copy(initial) + residual = target - operator * solution + gradient = transpose(operator) * residual + direction = copy(gradient) + gradient_norm_squared = dot(gradient, gradient) + initial_gradient_norm = sqrt(gradient_norm_squared) + + for iteration in 1:max_iterations + projected_direction = operator * direction + denominator = dot(projected_direction, projected_direction) + denominator > eps() || return solution, iteration + step = gradient_norm_squared / denominator + solution .+= step .* direction + residual .-= step .* projected_direction + gradient_new = transpose(operator) * residual + gradient_norm_squared_new = dot(gradient_new, gradient_new) + if sqrt(gradient_norm_squared_new) < tolerance * max(initial_gradient_norm, 1) + return solution, iteration + end + direction .= gradient_new .+ + (gradient_norm_squared_new / gradient_norm_squared) .* direction + gradient .= gradient_new + gradient_norm_squared = gradient_norm_squared_new + end + + return solution, max_iterations +end + +function solve_resolution(target_particle_count) + assembled = assemble_sparse_operator(target_particle_count) + operator_kpa = 1000assembled.operator + target = -assembled.baseline_acceleration + radius = cbrt(3.0e-6 / (4pi)) + initial_pressure = fill(2 / radius / 1000, assembled.n_fluid) + pressure_kpa, iterations = cgls(operator_kpa, target; initial=initial_pressure) + + if any(<(0), pressure_kpa) + pressure_kpa, iterations, + _ = nonnegative_least_squares(operator_kpa, target; + initial_pressure=max.(pressure_kpa, + 0), + max_iterations=2000) + end + + residual = assembled.baseline_acceleration + operator_kpa * pressure_kpa + residual_vectors = reshape(residual, 3, :) + residual_magnitude = vec(sqrt.(sum(abs2, residual_vectors; dims=1))) + wall_acceleration = assembled.baseline_wall_acceleration + + assembled.wall_coefficient .* (1000pressure_kpa) + active_wall_acceleration = wall_acceleration[assembled.wall_coefficient .> 0] + + result = (; target_particle_count, particle_count=assembled.n_fluid, + boundary_particle_count=assembled.boundary_particle_count, + particle_spacing=assembled.particle_spacing, + support_count=assembled.support_count, pressure=1000pressure_kpa, + iterations, residual_rms=sqrt(mean(abs2, residual_magnitude)), + residual_median=median(residual_magnitude), + residual_p90=quantile(residual_magnitude, 0.9), + residual_maximum=maximum(residual_magnitude), + mean_vertical_residual=mean(residual_vectors[end, :]), + wall_acceleration_sum=sum(wall_acceleration), + wall_acceleration_median=median(active_wall_acceleration), + wall_acceleration_maximum=maximum(active_wall_acceleration)) + + @printf("target=%d actual=%d dx=%.6g boundary=%d support=%d\n", + target_particle_count, result.particle_count, result.particle_spacing, + result.boundary_particle_count, result.support_count) + @printf(" residual RMS=%.6f median=%.6f p90=%.6f max=%.6f mean_z=%.6f\n", + result.residual_rms, result.residual_median, result.residual_p90, + result.residual_maximum, result.mean_vertical_residual) + @printf(" pressure range=[%.3f, %.3f] Pa, wall sum=%.3f median=%.3f max=%.3f\n", + minimum(result.pressure), maximum(result.pressure), + result.wall_acceleration_sum, result.wall_acceleration_median, + result.wall_acceleration_maximum) + return result +end + +function run_resolution_study(output_path, targets) + results = if isfile(output_path) + open(deserialize, output_path).results + else + NamedTuple[] + end + for target in targets + any(result -> result.target_particle_count == target, results) && continue + push!(results, solve_resolution(target)) + open(output_path, "w") do io + serialize(io, (; results)) + end + end + println("Wrote pressure resolution study to ", output_path) + return results +end + +if abspath(PROGRAM_FILE) == @__FILE__ + length(ARGS) >= 2 || + error("usage: pressure_resolution_study.jl OUTPUT.jls TARGET_COUNT [TARGET_COUNT ...]") + run_resolution_study(ARGS[1], parse.(Int, ARGS[2:end])) +end diff --git a/compare_akinci/render.jl b/compare_akinci/render.jl new file mode 100644 index 0000000000..b112f525b9 --- /dev/null +++ b/compare_akinci/render.jl @@ -0,0 +1,293 @@ +using GPUSelect + +const DEVICE = GPUSelect.Backend(:Lava; fallback=false) + +using FileIO +using GeometryBasics +using Hikari +using Makie +using Meshing +using RayMakie +using Serialization + +const IMAGE_SIZE = (640, 480) +const FLUID_ISO_FRACTION = 0.35f0 +const SOLID_ISO_FRACTION = 0.45f0 +const WATER_MATERIAL = Hikari.Dielectric(; + Kr=Hikari.RGBSpectrum(1.0f0, 1.0f0, 1.0f0), + Kt=Hikari.RGBSpectrum(0.7f0, 0.85f0, 0.95f0), + roughness=0.01f0, + index=1.33f0) +const GROUND_MATERIAL = Hikari.Plastic(; color=(0.38f0, 0.40f0, 0.43f0), + roughness=0.3f0) + +include("cases.jl") + +function load_snapshot(config) + project = joinpath(@__DIR__, "simulation") + simulator = joinpath(@__DIR__, "simulate.jl") + + return mktemp() do path, io + close(io) + run(`$(Base.julia_cmd()) --project=$project $simulator $(config.name) $path`) + open(deserialize, path) + end +end + +function system_coordinates(frame, system_index) + return frame.systems[system_index].coordinates +end + +function solution_bounds(frame, system_indices) + minimum_corner = fill(Inf32, 3) + maximum_corner = fill(-Inf32, 3) + + for system_index in system_indices + coordinates = system_coordinates(frame, system_index) + for dimension in axes(coordinates, 1) + minimum_corner[dimension] = min(minimum_corner[dimension], + minimum(coordinates[dimension, :])) + maximum_corner[dimension] = max(maximum_corner[dimension], + maximum(coordinates[dimension, :])) + end + end + + center = Makie.Point3f((minimum_corner + maximum_corner) / 2) + extent = maximum(maximum_corner - minimum_corner) + return center, max(extent, 0.05f0) +end + +function system_color(config, system_index) + if hasproperty(config, :system_colors) + color_index = findfirst(pair -> first(pair) == system_index, config.system_colors) + !isnothing(color_index) && + return Makie.RGBf(last(config.system_colors[color_index])...) + end + + system_index == 1 && return Makie.RGBf(0.015, 0.24, 0.82) + return Makie.RGBf(0.48, 0.52, 0.58) +end + +function particle_surface(coordinates, particle_spacing; fluid=false, + iso_fraction=fluid ? FLUID_ISO_FRACTION : SOLID_ISO_FRACTION) + spacing = Float32(particle_spacing) + sigma = (fluid ? 1.1f0 : 0.7f0) * spacing + grid_step = 0.5f0 * spacing + cutoff = 3.0f0 * sigma + + minimum_corner = Float32.(vec(minimum(coordinates; dims=2))) .- cutoff + maximum_corner = Float32.(vec(maximum(coordinates; dims=2))) .+ cutoff + grid_size = ntuple(dimension -> max(4, + ceil(Int, + (maximum_corner[dimension] - + minimum_corner[dimension]) / grid_step) + 1), + 3) + ranges = ntuple(dimension -> range(minimum_corner[dimension], maximum_corner[dimension]; + length=grid_size[dimension]), + 3) + field = zeros(Float32, grid_size) + cutoff_squared = cutoff^2 + inverse_two_sigma_squared = inv(2.0f0 * sigma^2) + + for particle in axes(coordinates, 2) + position = Float32.(view(coordinates, :, particle)) + index_ranges = ntuple(3) do dimension + lower = max(1, + searchsortedfirst(ranges[dimension], + position[dimension] - cutoff)) + upper = min(grid_size[dimension], + searchsortedlast(ranges[dimension], + position[dimension] + cutoff)) + lower:upper + end + + for k in index_ranges[3], j in index_ranges[2], i in index_ranges[1] + distance_squared = (ranges[1][i] - position[1])^2 + + (ranges[2][j] - position[2])^2 + + (ranges[3][k] - position[3])^2 + distance_squared > cutoff_squared && continue + field[i, j, k] += exp(-distance_squared * inverse_two_sigma_squared) + end + end + + field[[1, end], :, :] .= 0 + field[:, [1, end], :] .= 0 + field[:, :, [1, end]] .= 0 + interior_value = (sqrt(2.0f0 * Float32(pi)) * sigma / spacing)^3 + target_iso = Float32(iso_fraction) * interior_value + iso = min(target_iso, 0.5f0 * maximum(field)) + + points, faces = Meshing.isosurface(field, Meshing.MarchingTetrahedra(; iso), + ranges...) + mesh = GeometryBasics.Mesh(GeometryBasics.Point3f.(points), + GeometryBasics.GLTriangleFace.(faces)) + return GeometryBasics.normal_mesh(mesh) +end + +function system_material(color) + return Hikari.Plastic(; color=Tuple(color), roughness=0.24f0) +end + +function particle_colors(system, config, panel) + color_field = panel_setting(panel, config, :particle_color, :constant) + color_field == :constant && return Makie.RGBf(0.88, 0.52, 0.48) + color_field == :pressure || error("unsupported particle color field: $color_field") + isnothing(system.pressure) && error("snapshot does not contain fluid pressure") + + pressure_max = max(maximum(system.pressure), eps(eltype(system.pressure))) + return map(system.pressure) do pressure + fraction = Float32(clamp(pressure / pressure_max, 0, 1)) + Makie.RGBf(0.92f0, 0.78f0 * (1 - fraction), 0.76f0 * (1 - fraction)) + end +end + +function panel_setting(panel, config, name) + return hasproperty(panel, name) ? getproperty(panel, name) : getproperty(config, name) +end + +function panel_setting(panel, config, name, default) + hasproperty(panel, name) && return getproperty(panel, name) + hasproperty(config, name) && return getproperty(config, name) + return default +end + +function snapshot_frame(snapshot, time) + frame_index = argmin(abs.(snapshot.times .- time)) + isapprox(snapshot.times[frame_index], time; atol=1.0e-10, rtol=1.0e-10) || + error("snapshot does not contain requested time $time") + return snapshot.frames[frame_index] +end + +function render_props!(axis, panel, config) + props = panel_setting(panel, config, :props, ()) + for prop in props + prop.kind == :box || error("unsupported scene prop kind: $(prop.kind)") + geometry = GeometryBasics.Rect3f(Makie.Vec3f(prop.origin), Makie.Vec3f(prop.widths)) + color = Makie.RGBf(prop.color...) + material = Hikari.Plastic(; color=Tuple(color), roughness=0.24f0) + Makie.mesh!(axis, geometry; color, material) + end + return axis +end + +function render_system!(axis, config, panel, system, system_index) + is_fluid = system.kind == :fluid + color = is_fluid ? Makie.RGBf(0.6, 0.8, 0.95) : + system_color(config, system_index) + material = is_fluid ? WATER_MATERIAL : system_material(color) + style = panel_setting(panel, config, :render_style, :surface) + + if style == :particles && is_fluid + coordinates = system.coordinates + points = [Makie.Point3f(coordinates[:, particle]) + for particle in axes(coordinates, 2)] + marker = GeometryBasics.Sphere(Makie.Point3f(0), 0.5f0) + scale = panel_setting(panel, config, :particle_scale, 0.82f0) + particle_color = particle_colors(system, config, panel) + Makie.meshscatter!(axis, points; marker, + markersize=Float32(scale * system.particle_spacing), + color=particle_color, + material=system_material(Makie.RGBf(1.0, 1.0, 1.0))) + return axis + elseif style != :surface + error("unsupported render style: $style") + end + + iso_fraction = if is_fluid + panel_setting(panel, config, :fluid_iso_fraction, FLUID_ISO_FRACTION) + else + panel_setting(panel, config, :solid_iso_fraction, SOLID_ISO_FRACTION) + end + surface = particle_surface(system.coordinates, system.particle_spacing; + fluid=is_fluid, iso_fraction) + Makie.mesh!(axis, surface; color, material) + return axis +end + +function render_panel(config, snapshot, panel, integrator) + frame = snapshot_frame(snapshot, panel.time) + focus_indices = panel_setting(panel, config, :focus_indices) + center, extent = solution_bounds(frame, focus_indices) + if hasproperty(panel, :view_center) + center = Makie.Point3f(panel.view_center) + elseif hasproperty(config, :view_center) + center = Makie.Point3f(config.view_center) + end + if hasproperty(panel, :view_extent) + extent = Float32(panel.view_extent) + elseif hasproperty(config, :view_extent) + extent = Float32(config.view_extent) + end + + direction = Makie.Vec3f(panel_setting(panel, config, :camera)) + direction /= sqrt(sum(abs2, direction)) + eye = center + panel_setting(panel, config, :camera_scale) * extent * direction + + lights = [Makie.SunSkyLight(Makie.Vec3f(0.4, -0.3, 0.8); + intensity=1.0f0, turbidity=3.0f0, + ground_enabled=false)] + figure = Makie.Figure(; size=IMAGE_SIZE) + axis = Makie.LScene(figure[1, 1]; show_axis=false, + scenekw=(; lights, + backgroundcolor=Makie.RGBf(0.035, 0.045, 0.065))) + + for system_index in panel_setting(panel, config, :system_indices) + system = frame.systems[system_index] + isempty(system.coordinates) && continue + render_system!(axis, config, panel, system, system_index) + end + + render_props!(axis, panel, config) + + show_ground = panel_setting(panel, config, :show_ground, true) + if show_ground + ground_size = 3.0f0 * extent + ground_height = center[3] - 0.75f0 * extent + ground = GeometryBasics.Rect3f(Makie.Vec3f(center[1] - ground_size / 2, + center[2] - ground_size / 2, + ground_height), + Makie.Vec3f(ground_size, ground_size, + max(0.01f0 * extent, 1.0f-4))) + Makie.mesh!(axis, ground; color=Makie.RGBf(0.38, 0.40, 0.43), + material=GROUND_MATERIAL) + end + + camera = Makie.cameracontrols(axis.scene) + camera.eyeposition[] = eye + camera.lookat[] = center + camera.upvector[] = Makie.Vec3f(0, 0, 1) + camera.fov[] = panel_setting(panel, config, :fov) + camera.near[] = max(extent / 100, 1.0f-4) + camera.far[] = 100.0f0 * extent + Makie.update_cam!(axis.scene, camera) + + output = isabspath(panel.output) ? panel.output : joinpath(@__DIR__, panel.output) + mkpath(dirname(output)) + image = Makie.colorbuffer(figure; backend=RayMakie, device=DEVICE, integrator, + tonemap=:aces, gamma=2.2f0, update=false) + FileIO.save(output, image) + println("Wrote $(config.title), t=$(panel.time), to $output") + return output +end + +function render(config; snapshot_path=nothing) + snapshot = isnothing(snapshot_path) ? load_snapshot(config) : + open(deserialize, snapshot_path) + samples = parse(Int, get(ENV, "TRIXIPARTICLES_RAY_SAMPLES", "128")) + max_depth = parse(Int, get(ENV, "TRIXIPARTICLES_RAY_MAX_DEPTH", "12")) + integrator = Hikari.VolPath(; samples, max_depth, hw_accel=true, + max_component_value=10.0f0, regularize=true) + RayMakie.activate!(; device=DEVICE, integrator, tonemap=:aces, gamma=2.2f0) + + panels = hasproperty(config, :panels) ? config.panels : + ((; time=last(snapshot.times), output=config.output),) + return map(panel -> render_panel(config, snapshot, panel, integrator), panels) +end + +if abspath(PROGRAM_FILE) == @__FILE__ + 1 <= length(ARGS) <= 2 || + error("pass one comparison case and optionally a snapshot path: " * + join(getproperty.(CASES, :name), ", ")) + snapshot_path = length(ARGS) == 2 ? ARGS[2] : nothing + render(case_config(ARGS[1]); snapshot_path) +end diff --git a/compare_akinci/render_all.jl b/compare_akinci/render_all.jl new file mode 100644 index 0000000000..f94c6a5cca --- /dev/null +++ b/compare_akinci/render_all.jl @@ -0,0 +1,11 @@ +include("cases.jl") + +project = dirname(Base.active_project()) +renderer = joinpath(@__DIR__, "render.jl") + +for config in CASES + hasproperty(config, :panels) && isempty(config.panels) && continue + run(`$(Base.julia_cmd()) --project=$project $renderer $(config.name)`) +end + +run(`$(Base.julia_cmd()) --project=$project $(joinpath(@__DIR__, "make_overview.jl"))`) diff --git a/compare_akinci/resolution_invariant_study.jl b/compare_akinci/resolution_invariant_study.jl new file mode 100644 index 0000000000..f3b5fecffa --- /dev/null +++ b/compare_akinci/resolution_invariant_study.jl @@ -0,0 +1,149 @@ +using Printf +using TrixiParticles + +include(joinpath(@__DIR__, "surface_tension_calibration.jl")) + +function resolution_study_model(model_name, coefficient, calibration_factor) + if model_name == "legacy" + return SurfaceTensionAkinci(; surface_tension_coefficient=coefficient) + elseif model_name == "cohesion" + return CohesionForceAkinci(; surface_tension_coefficient=coefficient) + elseif model_name == "cohesion_physical" + return SurfaceTensionAkinciCohesionPhysical(; + surface_tension_coefficient=coefficient, + reference_density=1000.0) + elseif model_name == "invariant" + reference_spacing = cbrt(1.0e-6 / 750) + reference_support_radius = 2.8reference_spacing + return SurfaceTensionAkinciResolutionInvariant(; + surface_tension_coefficient=coefficient, + calibration_factor, + reference_support_radius) + end + throw(ArgumentError("unknown model `$model_name`")) +end + +function run_resolution_laplace_study(model_name, target_particle_count; + coefficient=1.0, + calibration_factor=1.0, + final_time=0.02) + model = resolution_study_model(model_name, coefficient, calibration_factor) + result = laplace_pressure_series(model; final_time, + base_target_particle_count=target_particle_count) + particle_spacing = cbrt(1.0e-6 / target_particle_count) + support_radius = 2.8particle_spacing + internal_coefficient = if model isa SurfaceTensionAkinciResolutionInvariant + coefficient * akinci_resolution_scale(model, support_radius) + elseif model isa SurfaceTensionAkinciCohesionPhysical + TrixiParticles.akinci_physical_cohesion_coefficient(model, support_radius) + else + coefficient + end + @printf("resolution model=%s target_n=%d dx=%.8g H=%.8g internal_gamma=%.8g sigma=%.8g p_bulk=%.8g residual=%.8g\n", + model_name, target_particle_count, particle_spacing, support_radius, + internal_coefficient, result.surface_tension, result.bulk_pressure, + result.residual_rms) + return result +end + +function run_resolution_stiffness_study(target_particle_count) + cohesion = rayleigh_stiffness(CohesionForceAkinci(; + surface_tension_coefficient=1.0); + target_particle_count) + full = rayleigh_stiffness(SurfaceTensionAkinci(; + surface_tension_coefficient=1.0); + target_particle_count) + particle_spacing = cbrt(1.0e-6 / target_particle_count) + support_radius = 2.8particle_spacing + normal = full.inferred_surface_tension - cohesion.inferred_surface_tension + @printf("stiffness target_n=%d dx=%.8g H=%.8g R_over_H=%.8g cohesion=%.8g normal=%.8g full=%.8g\n", + target_particle_count, particle_spacing, support_radius, + full.equivalent_radius / support_radius, + cohesion.inferred_surface_tension, normal, + full.inferred_surface_tension) + return (; cohesion, normal, full) +end + +@inline function cohesion_potential_shape_akinci(radius_ratio) + x = radius_ratio + polynomial(y) = y^4 / 4 - 3y^5 / 5 + y^6 / 2 - y^7 / 7 + integral = if x > 0.5 + 1 / 140 - polynomial(x) + else + 1 / 140 + polynomial(0.5) - 0.5 / 64 - + 2polynomial(x) + x / 64 + end + return 32 / pi * integral +end + +function cohesion_surface_energy(target_particle_count) + initial_condition = deformed_drop(; stretch=1.0, target_particle_count) + coordinates = initial_condition.coordinates + particle_spacing = initial_condition.particle_spacing + support_radius = 2.8particle_spacing + support_ratio = support_radius / particle_spacing + particle_mass = first(initial_condition.mass) + origin = coordinates[:, 1] + particle_at = Dict{NTuple{3, Int}, Bool}() + for particle in axes(coordinates, 2) + key = ntuple(dimension -> round(Int, + (coordinates[dimension, particle] - + origin[dimension]) / particle_spacing), 3) + particle_at[key] = true + end + + limit = ceil(Int, support_ratio) + offsets = NTuple{3, Int}[] + for i in (-limit):limit, j in (-limit):limit, k in (-limit):limit + iszero(i) && iszero(j) && iszero(k) && continue + # One representative of each undirected lattice bond. + (i > 0 || (iszero(i) && j > 0) || + (iszero(i) && iszero(j) && k > 0)) || continue + distance_lattice = sqrt(i^2 + j^2 + k^2) + distance_lattice < support_ratio || continue + push!(offsets, (i, j, k)) + end + + n_particles = size(coordinates, 2) + excess_energy = 0.0 + for offset in offsets + existing_pairs = 0 + for key in keys(particle_at) + neighbor_key = ntuple(dimension -> key[dimension] + offset[dimension], 3) + existing_pairs += haskey(particle_at, neighbor_key) + end + distance_ratio = sqrt(sum(abs2, offset)) / support_ratio + potential = -particle_mass^2 / support_radius^2 * + cohesion_potential_shape_akinci(distance_ratio) + excess_energy += (n_particles - existing_pairs) * -potential + end + + volume = sum(initial_condition.mass) / 1000.0 + radius = cbrt(3volume / (4pi)) + area = 4pi * radius^2 + surface_tension = excess_energy / area + moment = surface_tension / (1000.0^2 * support_radius^2) + @printf("cohesion_energy target_n=%d actual_n=%d R_over_H=%.8g sigma=%.8g moment=%.12g lattice_moment=%.12g\n", + target_particle_count, n_particles, radius / support_radius, + surface_tension, moment, AKINCI_COHESION_LATTICE_MOMENT_3D_H28) + return (; surface_tension, moment, radius, support_radius, n_particles) +end + +if abspath(PROGRAM_FILE) == @__FILE__ + if length(ARGS) == 2 && ARGS[1] == "stiffness" + run_resolution_stiffness_study(parse(Int, ARGS[2])) + elseif length(ARGS) == 2 && ARGS[1] == "cohesion_energy" + cohesion_surface_energy(parse(Int, ARGS[2])) + else + length(ARGS) in (3, 4, 5) || + error("usage: resolution_invariant_study.jl MODEL TARGET_PARTICLE_COUNT " * + "FINAL_TIME [COEFFICIENT [CALIBRATION_FACTOR]]") + model_name = ARGS[1] + target_particle_count = parse(Int, ARGS[2]) + final_time = parse(Float64, ARGS[3]) + coefficient = length(ARGS) >= 4 ? parse(Float64, ARGS[4]) : 1.0 + calibration_factor = length(ARGS) == 5 ? parse(Float64, ARGS[5]) : 1.0 + run_resolution_laplace_study(model_name, target_particle_count; + coefficient, calibration_factor, final_time) + end +end diff --git a/compare_akinci/simulate.jl b/compare_akinci/simulate.jl new file mode 100644 index 0000000000..48ab8b4f42 --- /dev/null +++ b/compare_akinci/simulate.jl @@ -0,0 +1,324 @@ +using OrdinaryDiffEqLowStorageRK +using Serialization +using Statistics +using TrixiParticles + +include("cases.jl") +include("figure_02_packing.jl") +isdefined(@__MODULE__, :FreeSurfaceDensityDiffusionAntuono) || + include("wcsph_variants.jl") + +function css_smoothing_kernel(mode) + mode == :wendland_c2 && return WendlandC2Kernel{3}() + mode == :wendland_c4 && return WendlandC4Kernel{3}() + mode == :wendland_c6 && return WendlandC6Kernel{3}() + throw(ArgumentError("unknown CSS smoothing kernel mode '$mode'")) +end + +function simulation_kwargs(config; surface_tension_coefficient=nothing, + particle_spacing=nothing, css_hydrodynamics=:modern) + if !hasproperty(config, :css) + isnothing(surface_tension_coefficient) || + throw(ArgumentError("a CSS coefficient override requires a CSS case")) + return isnothing(particle_spacing) ? config.kwargs : + merge(config.kwargs, (; particle_spacing)) + end + + css = config.css + coefficient = something(surface_tension_coefficient, + css.surface_tension_coefficient) + spacing = something(particle_spacing, css.particle_spacing) + coefficient > 0 || + throw(ArgumentError("the CSS surface-tension coefficient must be positive")) + spacing > 0 || throw(ArgumentError("the particle spacing must be positive")) + css.smoothing_length_ratio > 0 || + throw(ArgumentError("the CSS smoothing-length ratio must be positive")) + smoothing_kernel = css_smoothing_kernel(css.smoothing_kernel_mode) + smoothing_length = css.smoothing_length_ratio * spacing + contact_model = isnothing(css.contact_angle) ? nothing : + WettedAreaContactAngle(css.contact_angle) + boundary_contact_threshold = isnothing(contact_model) ? Inf : 0.1 + surface_tension, + surface_normal_method = if css.surface_tension_mode == :css + (SurfaceTensionMomentumMorris(; surface_tension_coefficient=coefficient), + ColorfieldSurfaceNormal(; boundary_contact_threshold, + interface_threshold=0.01, + ideal_density_threshold=0.95, + normal_smoothing=css.normal_smoothing, + contact_model)) + elseif css.surface_tension_mode == :c_csf + isnothing(contact_model) || + throw(ArgumentError("C-CSF boundary contact is not implemented")) + (SurfaceTensionMorris(; surface_tension_coefficient=coefficient), + CorrectedCSFSurfaceNormal(; contact_angle=css.ccsf_contact_angle)) + else + throw(ArgumentError("unknown CSS surface tension mode '$(css.surface_tension_mode)'")) + end + + if css_hydrodynamics == :akinci_baseline + isnothing(contact_model) || + throw(ArgumentError("Akinci-baseline CSS hydrodynamics do not support contact")) + css.surface_tension_mode == :css || + throw(ArgumentError("Akinci-baseline hydrodynamics require CSS surface tension")) + return merge(config.kwargs, + (; particle_spacing=spacing, + surface_tension, + surface_normal_method)) + elseif css_hydrodynamics != :modern + throw(ArgumentError("unknown CSS hydrodynamics '$css_hydrodynamics'")) + end + + shifting_technique = if css.shifting_mode == :none + nothing + elseif css.shifting_mode == :sun2017 + ParticleShiftingTechniqueSun2017(; + free_surface_treatment=FreeSurfaceTangentialShifting()) + elseif css.shifting_mode == :consistent_sun2019 + ConsistentShiftingSun2019(; + v_max_factor=css.shifting_v_max_factor, + sound_speed_factor=css.shifting_sound_speed_factor, + free_surface_treatment=FreeSurfaceTangentialShifting()) + else + throw(ArgumentError("unknown CSS shifting mode '$(css.shifting_mode)'")) + end + pressure_acceleration, + fluid_clip_negative_pressure = if css.pressure_stabilization == :none + (nothing, true) + elseif css.pressure_stabilization == :interface_tic + (InterfaceAwareTensileInstabilityControl(; strength=css.tic_strength), false) + else + throw(ArgumentError("unknown CSS pressure stabilization '$(css.pressure_stabilization)'")) + end + update_callback = css.shifting_mode == :sun2017 ? UpdateCallback() : nothing + density_diffusion = if css.density_diffusion_mode == :none + nothing + elseif css.density_diffusion_mode == :antuono + DensityDiffusionAntuono(; delta=css.density_diffusion_delta) + elseif css.density_diffusion_mode == :free_surface_antuono + FreeSurfaceDensityDiffusionAntuono(; delta=css.density_diffusion_delta, + reference_density=1000.0) + else + throw(ArgumentError("unknown CSS density diffusion mode '$(css.density_diffusion_mode)'")) + end + equivalent_nu = css.artificial_viscosity_alpha * smoothing_length * 40.0 / 10 + viscosity = if css.viscosity_mode == :artificial_monaghan + ArtificialViscosityMonaghan(; alpha=css.artificial_viscosity_alpha, beta=0.0) + elseif css.viscosity_mode == :morris + ViscosityMorris(; nu=equivalent_nu) + elseif css.viscosity_mode == :adami + ViscosityAdami(; nu=equivalent_nu) + else + throw(ArgumentError("unknown CSS viscosity mode '$(css.viscosity_mode)'")) + end + + return merge(config.kwargs, + (; particle_spacing=spacing, + smoothing_kernel, + smoothing_length, + provide_boundary_surface_geometry=!isnothing(contact_model) || + !isnothing(css.ccsf_contact_angle), + density_calculator=ContinuityDensity(), + density_diffusion, + correction=nothing, + pressure_acceleration, + fluid_clip_negative_pressure, + update_callback, + viscosity, + shifting_technique, + surface_tension, + surface_normal_method)) +end + +function run_simulation(config; surface_tension_coefficient=nothing, + particle_spacing=nothing, css_hydrodynamics=:modern) + module_name = Symbol("AkinciComparison_", config.name) + simulation_module = Module(module_name) + Core.eval(simulation_module, :(using TrixiParticles)) + example = joinpath(examples_dir(), "fluid", config.example) + kwargs = simulation_kwargs(config; surface_tension_coefficient, particle_spacing, + css_hydrodynamics) + packing_diagnostics = missing + if config.example == "akinci_cube_to_sphere_3d.jl" && hasproperty(config, :css) + distribution = config.css.initial_particle_distribution + if distribution == :packed + drop_initial_condition, + packing_diagnostics = packed_cube_initial_condition(; + particle_spacing=kwargs.particle_spacing, + relative_jitter=config.css.packing_relative_jitter, + seed=config.css.packing_seed, + maxiters=config.css.packing_maxiters) + kwargs = merge(kwargs, (; drop_initial_condition)) + elseif distribution == :jittered + cube_side_length = 0.01 + n_cube = ntuple(_ -> round(Int, cube_side_length / kwargs.particle_spacing), 3) + cube_min = (-cube_side_length / 2, -cube_side_length / 2, 0.0025) + lattice = RectangularShape(kwargs.particle_spacing, n_cube, cube_min; + density=1000.0) + drop_initial_condition = jittered_initial_condition(lattice; + relative_amplitude=config.css.packing_relative_jitter, + seed=config.css.packing_seed) + packing_diagnostics = (; relative_jitter=config.css.packing_relative_jitter, + seed=config.css.packing_seed, maxiters=0) + kwargs = merge(kwargs, (; drop_initial_condition)) + elseif distribution != :lattice + throw(ArgumentError("unknown initial particle distribution '$distribution'")) + end + end + accepted_dt = Float64[] + if config.example == "akinci_cube_to_sphere_3d.jl" + timestep_diagnostic_callback = DiscreteCallback((_, time, _) -> time > 0, + integrator -> begin + push!(accepted_dt, + abs(integrator.t - + integrator.tprev)) + u_modified!(integrator, false) + end; + save_positions=(false, false)) + kwargs = merge(kwargs, (; timestep_diagnostic_callback)) + end + trixi_include(simulation_module, example; kwargs...) + solution = Base.invokelatest(Core.eval, simulation_module, :sol) + return solution, accepted_dt, packing_diagnostics +end + +function snapshot_frame(state, semi, time) + v_ode, u_ode = state.x + TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, time) + + systems = map(eachindex(semi.systems)) do system_index + system = semi.systems[system_index] + particles = collect(eachparticle(system)) + v = TrixiParticles.wrap_v(v_ode, system, semi) + u = TrixiParticles.wrap_u(u_ode, system, semi) + coordinates = Array(TrixiParticles.active_coordinates(u, system)) + spacing = isempty(particles) ? nothing : + TrixiParticles.particle_spacing(system, first(particles)) + kind = if system isa TrixiParticles.AbstractFluidSystem + :fluid + elseif system isa TrixiParticles.AbstractBoundarySystem + :boundary + elseif system isa TrixiParticles.AbstractStructureSystem + :structure + else + :other + end + pressure = kind == :fluid ? + [TrixiParticles.current_pressure(v, system, particle) + for particle in particles] : nothing + density = kind == :fluid ? + [TrixiParticles.current_density(v, system, particle) + for particle in particles] : nothing + velocity = kind == :fluid ? + Array(TrixiParticles.current_velocity(v, system))[:, particles] : nothing + return (; coordinates, particle_spacing=spacing, kind, pressure, density, velocity) + end + + return (; systems) +end + +function solver_stats(solution) + stats = hasproperty(solution, :stats) ? solution.stats : solution.destats + accepted_steps = hasproperty(stats, :naccept) ? stats.naccept : missing + rejected_steps = hasproperty(stats, :nreject) ? stats.nreject : missing + return (; accepted_steps, rejected_steps) +end + +function timestep_stats(accepted_dt) + length(accepted_dt) > 6 || + return (; samples=length(accepted_dt), p01=missing, median=missing, + tail_to_head=missing) + samples = accepted_dt[6:(end - 1)] + window = max(1, floor(Int, 0.2 * length(samples))) + return (; samples=length(samples), p01=quantile(samples, 0.01), + median=median(samples), + tail_to_head=median(samples[(end - window + 1):end]) / + median(samples[1:window])) +end + +function write_snapshot(config, output; surface_tension_coefficient=nothing, + particle_spacing=nothing, css_hydrodynamics=:modern) + result = nothing + runtime = @elapsed result = run_simulation(config; surface_tension_coefficient, + particle_spacing, css_hydrodynamics) + solution, accepted_dt, packing_diagnostics = result + semi = solution.prob.p.semi + frames = map(snapshot_frame, solution.u, Iterators.repeated(semi), solution.t) + model = hasproperty(config, :css) ? :css : :akinci + coefficient = hasproperty(config, :css) ? + something(surface_tension_coefficient, + config.css.surface_tension_coefficient) : missing + configured_spacing = hasproperty(config, :css) ? + something(particle_spacing, config.css.particle_spacing) : missing + artificial_viscosity_alpha = hasproperty(config, :css) ? + config.css.artificial_viscosity_alpha : missing + surface_tension_mode = hasproperty(config, :css) ? + config.css.surface_tension_mode : missing + smoothing_kernel_mode = hasproperty(config, :css) ? + config.css.smoothing_kernel_mode : missing + smoothing_length_ratio = hasproperty(config, :css) ? + config.css.smoothing_length_ratio : missing + normal_smoothing = hasproperty(config, :css) ? config.css.normal_smoothing : missing + contact_angle = hasproperty(config, :css) ? config.css.contact_angle : missing + ccsf_contact_angle = hasproperty(config, :css) ? config.css.ccsf_contact_angle : missing + viscosity_mode = hasproperty(config, :css) ? config.css.viscosity_mode : missing + density_diffusion_mode = hasproperty(config, :css) ? + config.css.density_diffusion_mode : missing + density_diffusion_delta = hasproperty(config, :css) ? + config.css.density_diffusion_delta : missing + initial_particle_distribution = hasproperty(config, :css) ? + config.css.initial_particle_distribution : missing + pressure_stabilization = hasproperty(config, :css) && + css_hydrodynamics == :modern ? + config.css.pressure_stabilization : missing + tic_strength = hasproperty(config, :css) && + css_hydrodynamics == :modern ? + config.css.tic_strength : missing + shifting_mode = hasproperty(config, :css) && + css_hydrodynamics == :modern ? + config.css.shifting_mode : missing + shifting_v_max_factor = hasproperty(config, :css) && + css_hydrodynamics == :modern ? + config.css.shifting_v_max_factor : missing + shifting_sound_speed_factor = hasproperty(config, :css) && + css_hydrodynamics == :modern ? + config.css.shifting_sound_speed_factor : missing + snapshot = (; case_name=config.name, model, + surface_tension_coefficient=coefficient, + particle_spacing=configured_spacing, + artificial_viscosity_alpha, + surface_tension_mode, + smoothing_kernel_mode, + smoothing_length_ratio, + normal_smoothing, + contact_angle, + ccsf_contact_angle, + viscosity_mode, + density_diffusion_mode, + density_diffusion_delta, + initial_particle_distribution, + packing_diagnostics, + pressure_stabilization, + tic_strength, + shifting_mode, + shifting_v_max_factor, + shifting_sound_speed_factor, + css_hydrodynamics=hasproperty(config, :css) ? css_hydrodynamics : missing, + runtime, solver_stats=solver_stats(solution), + timestep_stats=timestep_stats(accepted_dt), + times=collect(solution.t), frames) + + open(output, "w") do io + serialize(io, snapshot) + end + return output +end + +if abspath(PROGRAM_FILE) == @__FILE__ + length(ARGS) in (2, 3, 4) || + error("pass a comparison case, output path, optional CSS coefficient, " * + "and optional particle spacing") + coefficient = length(ARGS) >= 3 ? parse(Float64, ARGS[3]) : nothing + particle_spacing = length(ARGS) == 4 ? parse(Float64, ARGS[4]) : nothing + write_snapshot(case_config(ARGS[1]), ARGS[2]; + surface_tension_coefficient=coefficient, particle_spacing) +end diff --git a/compare_akinci/simulate_delta_sph_wetting.jl b/compare_akinci/simulate_delta_sph_wetting.jl new file mode 100644 index 0000000000..bafd978012 --- /dev/null +++ b/compare_akinci/simulate_delta_sph_wetting.jl @@ -0,0 +1,77 @@ +using TrixiParticles + +include(joinpath(@__DIR__, "boundary_volume.jl")) +include(joinpath(@__DIR__, "simulate.jl")) +isdefined(@__MODULE__, :FreeSurfaceDensityDiffusionAntuono) || + include(joinpath(@__DIR__, "wcsph_variants.jl")) + +function delta_sph_config(case_name, final_time; delta=0.1, + free_surface_diffusion=false, + akinci_support_factor=nothing, + curvature_factor=1.0, + target_particle_count=750) + base = case_config(case_name) + target_particle_count > 0 || + throw(ArgumentError("`target_particle_count` must be positive")) + particle_spacing = cbrt(1.0e-6 / target_particle_count) + plate_size = base.kwargs.plate_size + n_plate = round.(Int, plate_size ./ particle_spacing) + smoothing_kernel = WendlandC2Kernel{3}() + smoothing_length = 1.4particle_spacing + plate = RectangularShape(particle_spacing, (n_plate..., 3), + (-plate_size[1] / 2, -plate_size[2] / 2, + -3particle_spacing); + density=1000.0) + boundary_hydrodynamic_mass = akinci_boundary_hydrodynamic_mass(plate, + smoothing_kernel, + smoothing_length, + 1000.0) + save_times = collect(range(0.0, final_time; step=0.1)) + isapprox(last(save_times), final_time) || push!(save_times, final_time) + density_diffusion = if free_surface_diffusion + FreeSurfaceDensityDiffusionAntuono(; delta, reference_density=1000.0) + else + DensityDiffusionAntuono(; delta) + end + surface_options = (;) + if !isnothing(akinci_support_factor) || curvature_factor != 1 + support_factor = something(akinci_support_factor, 2.8) + support_radius = support_factor * particle_spacing + surface_tension = SurfaceTensionAkinciWCSPH(; + surface_tension_coefficient=base.paper.surface_tension_coefficient, + support_radius, curvature_factor) + normal_kernel = isnothing(akinci_support_factor) ? smoothing_kernel : + SchoenbergCubicSplineKernel{3}() + normal_smoothing_length = isnothing(akinci_support_factor) ? smoothing_length : + support_radius / 2 + surface_normal_method = WCSPHAkinciSurfaceNormal(normal_kernel, + normal_smoothing_length) + surface_options = (; surface_tension, surface_normal_method) + end + kwargs = merge(base.kwargs, + (; smoothing_kernel, smoothing_length, sound_speed=100.0, + target_particle_count, + fluid_density_calculator=ContinuityDensity(), + fluid_density_diffusion=density_diffusion, + boundary_hydrodynamic_mass, tspan=(0.0, final_time), + solution_saveat=Tuple(save_times)), surface_options) + name = base.name * "_delta_sph_n" * string(target_particle_count) + return merge(base, (; name, kwargs)) +end + +if abspath(PROGRAM_FILE) == @__FILE__ + length(ARGS) in (3, 7) || + error("usage: simulate_delta_sph_wetting.jl CASE OUTPUT.jls FINAL_TIME " * + "[DELTA FREE_SURFACE SUPPORT_FACTOR CURVATURE_FACTOR]") + options = if length(ARGS) == 3 + (;) + else + support_factor = ARGS[6] == "nothing" ? nothing : parse(Float64, ARGS[6]) + (; delta=parse(Float64, ARGS[4]), + free_surface_diffusion=parse(Bool, ARGS[5]), + akinci_support_factor=support_factor, + curvature_factor=parse(Float64, ARGS[7])) + end + config = delta_sph_config(ARGS[1], parse(Float64, ARGS[3]); options...) + write_snapshot(config, ARGS[2]) +end diff --git a/compare_akinci/simulation/.gitignore b/compare_akinci/simulation/.gitignore new file mode 100644 index 0000000000..b067eddee4 --- /dev/null +++ b/compare_akinci/simulation/.gitignore @@ -0,0 +1 @@ +/Manifest.toml diff --git a/compare_akinci/simulation/Project.toml b/compare_akinci/simulation/Project.toml new file mode 100644 index 0000000000..8410eb26af --- /dev/null +++ b/compare_akinci/simulation/Project.toml @@ -0,0 +1,21 @@ +[deps] +CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" +DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" +JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" +LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +OrdinaryDiffEqLowStorageRK = "b0944070-b475-4768-8dec-fb6eb410534d" +Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" +Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" +SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" +Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" +TrixiParticles = "66699cd8-9c01-4e9d-a059-b96c86d16b3a" + +[sources] +TrixiParticles = {path = "../.."} + +[compat] +CSV = "0.10" +DataFrames = "1" +JSON = "1" +OrdinaryDiffEqLowStorageRK = "3" +julia = "1.10" diff --git a/compare_akinci/surface_model_variants.jl b/compare_akinci/surface_model_variants.jl new file mode 100644 index 0000000000..8da4a12c45 --- /dev/null +++ b/compare_akinci/surface_model_variants.jl @@ -0,0 +1,378 @@ +using LinearAlgebra +using TrixiParticles + +# Preserve Akinci's published coefficients while removing their quadratic support-radius +# dependence. `calibration_factor` is the coefficient multiplier at +# `reference_support_radius`; both fluid-fluid and fluid-wall forces receive the same +# `(reference_support_radius / support_radius)^2` correction. +struct SurfaceTensionAkinciResolutionInvariant{T} <: + TrixiParticles.AkinciTypeSurfaceTension + surface_tension_coefficient::T + calibration_factor::T + reference_support_radius::T + + function SurfaceTensionAkinciResolutionInvariant(; + surface_tension_coefficient, + calibration_factor=1, + reference_support_radius) + values = promote(surface_tension_coefficient, calibration_factor, + reference_support_radius) + coefficient, calibration, reference_support = values + for (name, value) in (("surface_tension_coefficient", coefficient), + ("calibration_factor", calibration)) + isfinite(value) && value >= 0 || + throw(ArgumentError("`$name` must be finite and non-negative")) + end + isfinite(reference_support) && reference_support > 0 || + throw(ArgumentError("`reference_support_radius` must be finite and positive")) + new{typeof(coefficient)}(coefficient, calibration, reference_support) + end +end + +@inline function akinci_resolution_scale(surface_tension, support_radius) + return surface_tension.calibration_factor * + (surface_tension.reference_support_radius / support_radius)^2 +end + +function TrixiParticles.default_surface_normal_method(::SurfaceTensionAkinciResolutionInvariant, + ::Nothing) + return ColorfieldSurfaceNormal(; boundary_contact_threshold=Inf) +end + +@inline function TrixiParticles.surface_tension_force!(dv_particle, + surface_tension_a::SurfaceTensionAkinciResolutionInvariant, + surface_tension_b::SurfaceTensionAkinciResolutionInvariant, + particle_system::TrixiParticles.AbstractFluidSystem, + neighbor_system::TrixiParticles.AbstractFluidSystem, + particle, neighbor, pos_diff, + distance, rho_a, rho_b, grad_kernel, + surface_tension_correction) + support_radius = TrixiParticles.compact_support(TrixiParticles.system_smoothing_kernel(particle_system), + TrixiParticles.smoothing_length(particle_system, + particle)) + coefficient = surface_tension_a.surface_tension_coefficient * + akinci_resolution_scale(surface_tension_a, support_radius) + standard_model = SurfaceTensionAkinci(; + surface_tension_coefficient=coefficient) + return TrixiParticles.surface_tension_force!(dv_particle, standard_model, + standard_model, particle_system, + neighbor_system, particle, neighbor, + pos_diff, distance, rho_a, rho_b, + grad_kernel, + surface_tension_correction) +end + +@inline function TrixiParticles.adhesion_force!(dv_particle, + surface_tension::SurfaceTensionAkinciResolutionInvariant, + particle_system::TrixiParticles.AbstractFluidSystem, + neighbor_system::TrixiParticles.AbstractBoundarySystem, + particle, neighbor, pos_diff, distance) + adhesion_coefficient = neighbor_system.adhesion_coefficient + iszero(adhesion_coefficient) && return dv_particle + + support_radius = TrixiParticles.compact_support(TrixiParticles.system_smoothing_kernel(particle_system), + TrixiParticles.smoothing_length(particle_system, + particle)) + distance >= support_radius && return dv_particle + distance^2 < eps(support_radius^2) && return dv_particle + + scaled_adhesion = adhesion_coefficient * + akinci_resolution_scale(surface_tension, support_radius) + mass_b = TrixiParticles.hydrodynamic_mass(neighbor_system, neighbor) + dv_particle[] += TrixiParticles.adhesion_force_akinci(surface_tension, support_radius, + mass_b, pos_diff, distance, + scaled_adhesion, + Val(TrixiParticles.ndims(particle_system))) + return dv_particle +end + +# Full Akinci fluid-fluid forces with wall attraction evaluated by the cohesion kernel. +# `wall_cohesion_coefficient` is absolute, rather than a ratio to the fluid coefficient, +# because the normal-difference term also contributes to the fluid-vacuum surface energy. +struct SurfaceTensionAkinciWallCohesion{T} <: TrixiParticles.AkinciTypeSurfaceTension + surface_tension_coefficient::T + wall_cohesion_coefficient::T + + function SurfaceTensionAkinciWallCohesion(; surface_tension_coefficient, + wall_cohesion_coefficient) + values = promote(surface_tension_coefficient, wall_cohesion_coefficient) + fluid_coefficient, wall_coefficient = values + isfinite(fluid_coefficient) && fluid_coefficient >= 0 || + throw(ArgumentError("`surface_tension_coefficient` must be finite and non-negative")) + isfinite(wall_coefficient) && wall_coefficient >= 0 || + throw(ArgumentError("`wall_cohesion_coefficient` must be finite and non-negative")) + new{typeof(fluid_coefficient)}(fluid_coefficient, wall_coefficient) + end +end + +function TrixiParticles.default_surface_normal_method(::SurfaceTensionAkinciWallCohesion, + ::Nothing) + return ColorfieldSurfaceNormal(; boundary_contact_threshold=Inf) +end + +@inline function TrixiParticles.surface_tension_force!(dv_particle, + surface_tension_a::SurfaceTensionAkinciWallCohesion, + surface_tension_b::SurfaceTensionAkinciWallCohesion, + particle_system::TrixiParticles.AbstractFluidSystem, + neighbor_system::TrixiParticles.AbstractFluidSystem, + particle, neighbor, pos_diff, + distance, rho_a, rho_b, grad_kernel, + surface_tension_correction) + standard_model = SurfaceTensionAkinci(; + surface_tension_coefficient=surface_tension_a.surface_tension_coefficient) + return TrixiParticles.surface_tension_force!(dv_particle, standard_model, + standard_model, particle_system, + neighbor_system, particle, neighbor, + pos_diff, distance, rho_a, rho_b, + grad_kernel, + surface_tension_correction) +end + +@inline function wall_cohesion_force!(dv_particle, wall_cohesion_coefficient, + particle_system, neighbor_system, particle, + neighbor, pos_diff, distance) + iszero(wall_cohesion_coefficient) && return dv_particle + + support_radius = TrixiParticles.compact_support(TrixiParticles.system_smoothing_kernel(particle_system), + TrixiParticles.smoothing_length(particle_system, + particle)) + distance >= support_radius && return dv_particle + distance^2 < eps(support_radius^2) && return dv_particle + + wall_model = CohesionForceAkinci(; + surface_tension_coefficient=wall_cohesion_coefficient) + mass_b = TrixiParticles.hydrodynamic_mass(neighbor_system, neighbor) + dv_particle[] += TrixiParticles.cohesion_force_akinci(wall_model, support_radius, + mass_b, pos_diff, distance, + Val(TrixiParticles.ndims(particle_system))) + return dv_particle +end + +@inline function TrixiParticles.adhesion_force!(dv_particle, + surface_tension::SurfaceTensionAkinciWallCohesion, + particle_system::TrixiParticles.AbstractFluidSystem, + neighbor_system::TrixiParticles.AbstractBoundarySystem, + particle, neighbor, pos_diff, distance) + return wall_cohesion_force!(dv_particle, + surface_tension.wall_cohesion_coefficient, + particle_system, neighbor_system, particle, neighbor, + pos_diff, distance) +end + +# Full Akinci fluid-fluid forces with a tangential CSF contact-line force. This variant +# is specialized to the horizontal Figure 8 plate. Unlike pairwise wall attraction, it +# has no wall-normal component and therefore does not compete with pressure support. +struct SurfaceTensionAkinciContactLine{T, V} <: + TrixiParticles.AkinciTypeSurfaceTension + surface_tension_coefficient::T + contact_line_surface_tension::T + contact_angle_cos::T + reference_density::T + wall_normal::V + + function SurfaceTensionAkinciContactLine(; surface_tension_coefficient, + contact_line_surface_tension, + contact_angle, + reference_density=1000.0, + wall_normal=(0.0, 0.0, 1.0)) + values = promote(surface_tension_coefficient, + contact_line_surface_tension, + reference_density, wall_normal...) + fluid_coefficient, line_tension, density = values[1:3] + wall_normal_ = SVector(values[4:end]) + isfinite(fluid_coefficient) && fluid_coefficient >= 0 || + throw(ArgumentError("`surface_tension_coefficient` must be finite and non-negative")) + isfinite(line_tension) && line_tension >= 0 || + throw(ArgumentError("`contact_line_surface_tension` must be finite and non-negative")) + isfinite(contact_angle) && 0 <= contact_angle <= 180 || + throw(ArgumentError("`contact_angle` must be in [0, 180] degrees")) + isfinite(density) && density > 0 || + throw(ArgumentError("`reference_density` must be finite and positive")) + norm(wall_normal_) > eps() || + throw(ArgumentError("`wall_normal` must be nonzero")) + normal = wall_normal_ / norm(wall_normal_) + contact_angle_cos = oftype(fluid_coefficient, cosd(contact_angle)) + new{typeof(fluid_coefficient), typeof(normal)}(fluid_coefficient, + line_tension, + contact_angle_cos, + density, normal) + end +end + +function TrixiParticles.default_surface_normal_method(::SurfaceTensionAkinciContactLine, + ::Nothing) + return ColorfieldSurfaceNormal(; boundary_contact_threshold=Inf) +end + +@inline function TrixiParticles.surface_tension_force!(dv_particle, + surface_tension_a::SurfaceTensionAkinciContactLine, + surface_tension_b::SurfaceTensionAkinciContactLine, + particle_system::TrixiParticles.AbstractFluidSystem, + neighbor_system::TrixiParticles.AbstractFluidSystem, + particle, neighbor, pos_diff, + distance, rho_a, rho_b, grad_kernel, + surface_tension_correction) + standard_model = SurfaceTensionAkinci(; + surface_tension_coefficient=surface_tension_a.surface_tension_coefficient) + return TrixiParticles.surface_tension_force!(dv_particle, standard_model, + standard_model, particle_system, + neighbor_system, particle, neighbor, + pos_diff, distance, rho_a, rho_b, + grad_kernel, + surface_tension_correction) +end + +@inline function TrixiParticles.adhesion_force!(dv_particle, + surface_tension::SurfaceTensionAkinciContactLine, + particle_system::TrixiParticles.AbstractFluidSystem, + neighbor_system::TrixiParticles.AbstractBoundarySystem, + particle, neighbor, pos_diff, distance) + coefficient = surface_tension.contact_line_surface_tension + iszero(coefficient) && return dv_particle + + support_radius = TrixiParticles.compact_support(TrixiParticles.system_smoothing_kernel(particle_system), + TrixiParticles.smoothing_length(particle_system, + particle)) + distance >= support_radius && return dv_particle + distance^2 < eps(support_radius^2) && return dv_particle + + color_gradient = TrixiParticles.surface_normal(particle_system, particle) + surface_delta = norm(color_gradient) + surface_delta < eps(surface_delta) && return dv_particle + outward_normal = -color_gradient / surface_delta + wall_normal = surface_tension.wall_normal + dynamic_angle_cos = clamp(dot(outward_normal, wall_normal), -one(surface_delta), + one(surface_delta)) + tangent = outward_normal - dynamic_angle_cos * wall_normal + tangent_norm = norm(tangent) + tangent_norm < sqrt(eps(tangent_norm)) && return dv_particle + + mass_b = TrixiParticles.hydrodynamic_mass(neighbor_system, neighbor) + boundary_volume = mass_b / surface_tension.reference_density + grad_kernel = TrixiParticles.smoothing_kernel_grad(particle_system, pos_diff, + distance, particle) + wall_delta = boundary_volume * abs(dot(grad_kernel, wall_normal)) + contact_line_acceleration = coefficient / surface_tension.reference_density * + (surface_tension.contact_angle_cos - + dynamic_angle_cos) * surface_delta * wall_delta * + tangent / tangent_norm + dv_particle[] += contact_line_acceleration + return dv_particle +end + +# Dimensionally normalized Morris CSF with an optional pairwise Akinci cohesion component. +struct SurfaceTensionMorrisAkinci{T} <: TrixiParticles.AbstractSurfaceTension + surface_tension_coefficient::T + cohesion_coefficient::T + wall_cohesion_coefficient::T + + function SurfaceTensionMorrisAkinci(; surface_tension_coefficient, + cohesion_coefficient=0, + wall_cohesion_coefficient=0) + values = promote(surface_tension_coefficient, cohesion_coefficient, + wall_cohesion_coefficient) + coefficient, cohesion, wall = values + for (name, value) in (("surface_tension_coefficient", coefficient), + ("cohesion_coefficient", cohesion), + ("wall_cohesion_coefficient", wall)) + isfinite(value) && value >= 0 || + throw(ArgumentError("`$name` must be finite and non-negative")) + end + new{typeof(coefficient)}(coefficient, cohesion, wall) + end +end + +@inline function TrixiParticles.accumulate_surface_divergence_correction!(system, + ::SurfaceTensionMorrisAkinci, + particle, volume, + pos_diff, + grad_kernel) + value = -volume * dot(pos_diff, grad_kernel) / TrixiParticles.ndims(system) + @inbounds system.cache.support_moment[particle] += value + return system +end + +@inline function TrixiParticles.reset_surface_divergence_correction!(system, + ::SurfaceTensionMorrisAkinci) + TrixiParticles.set_zero!(system.cache.support_moment) + return system +end + +@inline function TrixiParticles.surface_interface_activity(::SurfaceTensionMorrisAkinci, + system, particle) + return @inbounds system.cache.interface_activity[particle] +end + +function TrixiParticles.create_cache_surface_tension(surface_tension::SurfaceTensionMorrisAkinci, + ELTYPE, NDIMS, nparticles) + morris = SurfaceTensionMorris(; + surface_tension_coefficient=surface_tension.surface_tension_coefficient) + return TrixiParticles.create_cache_surface_tension(morris, ELTYPE, NDIMS, nparticles) +end + +function TrixiParticles.remove_invalid_normals!(system::TrixiParticles.AbstractFluidSystem, + surface_tension::SurfaceTensionMorrisAkinci, + surface_normal_method::ColorfieldSurfaceNormal) + morris = SurfaceTensionMorris(; + surface_tension_coefficient=surface_tension.surface_tension_coefficient) + return TrixiParticles.remove_invalid_normals!(system, morris, + surface_normal_method) +end + +function TrixiParticles.compute_curvature!(system::TrixiParticles.AbstractFluidSystem, + surface_tension::SurfaceTensionMorrisAkinci, + v, u, v_ode, u_ode, semi, t) + morris = SurfaceTensionMorris(; + surface_tension_coefficient=surface_tension.surface_tension_coefficient) + return TrixiParticles.compute_curvature!(system, morris, v, u, v_ode, u_ode, + semi, t) +end + +@inline function TrixiParticles.surface_tension_force!(dv_particle, + surface_tension_a::SurfaceTensionMorrisAkinci, + surface_tension_b::SurfaceTensionMorrisAkinci, + particle_system::TrixiParticles.AbstractFluidSystem, + neighbor_system::TrixiParticles.AbstractFluidSystem, + particle, neighbor, pos_diff, + distance, rho_a, rho_b, grad_kernel, + surface_tension_correction) + distance^2 < eps(TrixiParticles.initial_smoothing_length(particle_system)^2) && + return dv_particle + + if !iszero(surface_tension_a.cohesion_coefficient) + cohesion_model = CohesionForceAkinci(; + surface_tension_coefficient=surface_tension_a.cohesion_coefficient) + support_radius = TrixiParticles.compact_support(TrixiParticles.system_smoothing_kernel(particle_system), + TrixiParticles.smoothing_length(particle_system, + particle)) + mass_b = TrixiParticles.hydrodynamic_mass(neighbor_system, neighbor) + dv_particle[] += surface_tension_correction * + TrixiParticles.cohesion_force_akinci(cohesion_model, + support_radius, mass_b, + pos_diff, + distance, + Val(TrixiParticles.ndims(particle_system))) + end + + return dv_particle +end + +@inline function TrixiParticles.surface_tension_acceleration(surface_tension::SurfaceTensionMorrisAkinci, + particle_system, particle, + rho_a, vector_template) + morris = SurfaceTensionMorris(; + surface_tension_coefficient=surface_tension.surface_tension_coefficient) + return TrixiParticles.surface_tension_acceleration(morris, particle_system, particle, + rho_a, vector_template) +end + +@inline function TrixiParticles.adhesion_force!(dv_particle, + surface_tension::SurfaceTensionMorrisAkinci, + particle_system::TrixiParticles.AbstractFluidSystem, + neighbor_system::TrixiParticles.AbstractBoundarySystem, + particle, neighbor, pos_diff, distance) + return wall_cohesion_force!(dv_particle, + surface_tension.wall_cohesion_coefficient, + particle_system, neighbor_system, particle, neighbor, + pos_diff, distance) +end diff --git a/compare_akinci/surface_tension_calibration.jl b/compare_akinci/surface_tension_calibration.jl new file mode 100644 index 0000000000..d541e7fa86 --- /dev/null +++ b/compare_akinci/surface_tension_calibration.jl @@ -0,0 +1,342 @@ +using LinearAlgebra +using OrdinaryDiffEqLowStorageRK +using Printf +using Statistics +using TrixiParticles + +include(joinpath(@__DIR__, "wcsph_variants.jl")) +include(joinpath(@__DIR__, "surface_model_variants.jl")) + +function deformed_drop(; stretch=1.1, drop_volume=1.0e-6, target_particle_count=750, + reference_density=1000.0) + particle_spacing = cbrt(drop_volume / target_particle_count) + drop_radius = cbrt(3 * drop_volume / (4pi)) + sampling_radius = drop_radius + particle_spacing / 2 + sphere = SphereShape(particle_spacing, sampling_radius, (0.0, 0.0, 0.0), + reference_density; sphere_type=VoxelSphere()) + + coordinates = copy(sphere.coordinates) + coordinates[1, :] .*= stretch + coordinates[2, :] ./= sqrt(stretch) + coordinates[3, :] ./= sqrt(stretch) + initial_condition = InitialCondition(; particle_spacing, coordinates, + velocity=sphere.velocity, mass=sphere.mass, + density=sphere.density, + pressure=sphere.pressure) + return initial_condition +end + +function rayleigh_stiffness(surface_tension; stretch=1.1, reference_density=1000.0, + drop_volume=1.0e-6, target_particle_count=750) + initial_condition = deformed_drop(; stretch, reference_density, drop_volume, + target_particle_count) + particle_spacing = initial_condition.particle_spacing + smoothing_kernel = WendlandC2Kernel{3}() + smoothing_length = 1.4particle_spacing + state_equation = StateEquationCole(; sound_speed=100.0, + reference_density, exponent=7, + clip_negative_pressure=true) + surface_normal_method = if TrixiParticles.requires_surface_normal(surface_tension) + ColorfieldSurfaceNormal(; boundary_contact_threshold=Inf, + interface_threshold=0.01, + ideal_density_threshold=0.95) + else + nothing + end + fluid_system = WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel, + smoothing_length, + density_calculator=ContinuityDensity(), + density_diffusion=DensityDiffusionAntuono(; + delta=0.1), + state_equation, + surface_tension, surface_normal_method, + correction=AkinciFreeSurfaceCorrection(reference_density), + reference_particle_spacing=particle_spacing) + semi = Semidiscretization(fluid_system; parallelization_backend=SerialBackend()) + ode = semidiscretize(semi, (0.0, 1.0e-3)) + v_ode, u_ode = ode.u0.x + dv_ode = zero(v_ode) + # The first pass initializes all surface interaction caches. + TrixiParticles.kick!(dv_ode, v_ode, u_ode, ode.p, 0.0) + TrixiParticles.kick!(dv_ode, v_ode, u_ode, ode.p, 0.0) + + v = TrixiParticles.wrap_v(v_ode, fluid_system, semi) + u = TrixiParticles.wrap_u(u_ode, fluid_system, semi) + dv = TrixiParticles.wrap_v(dv_ode, fluid_system, semi) + coordinates = Array(TrixiParticles.current_coordinates(u, fluid_system)) + velocity = Array(TrixiParticles.current_velocity(v, fluid_system)) + acceleration = Array(dv[1:3, :]) + mass = fluid_system.mass + center = vec(sum(coordinates .* reshape(mass, 1, :); dims=2) / sum(mass)) + relative_coordinates = coordinates .- center + + quadrupole = mean(relative_coordinates[1, :] .^ 2 .- + (relative_coordinates[2, :] .^ 2 .+ + relative_coordinates[3, :] .^ 2) / 2) + quadrupole_acceleration = 2mean(relative_coordinates[1, :] .* acceleration[1, :] .- + (relative_coordinates[2, :] .* acceleration[2, :] .+ + relative_coordinates[3, :] .* acceleration[3, :]) / 2) + actual_volume = sum(mass) / reference_density + equivalent_radius = cbrt(3actual_volume / (4pi)) + inferred_surface_tension = -reference_density * equivalent_radius^3 * + quadrupole_acceleration / (8quadrupole) + center_of_mass_acceleration = vec(sum(acceleration .* reshape(mass, 1, :); + dims=2) / sum(mass)) + torque_per_mass = [ + sum(mass .* (relative_coordinates[2, :] .* acceleration[3, :] .- + relative_coordinates[3, :] .* acceleration[2, :])), + sum(mass .* (relative_coordinates[3, :] .* acceleration[1, :] .- + relative_coordinates[1, :] .* acceleration[3, :])), + sum(mass .* (relative_coordinates[1, :] .* acceleration[2, :] .- + relative_coordinates[2, :] .* acceleration[1, :]))] / + sum(mass) + + return (; inferred_surface_tension, equivalent_radius, + particle_count=size(coordinates, 2), quadrupole, + quadrupole_acceleration, + center_of_mass_acceleration=norm(center_of_mass_acceleration), + torque_per_mass=norm(torque_per_mass), + acceleration_rms=sqrt(mean(abs2, acceleration)), + velocity_rms=sqrt(mean(abs2, velocity))) +end + +function calibration_models() + return (("cohesion gamma=1", CohesionForceAkinci(; + surface_tension_coefficient=1.0)), + ("Akinci gamma=1", SurfaceTensionAkinci(; + surface_tension_coefficient=1.0)), + ("Morris sigma=1", SurfaceTensionMorris(; + surface_tension_coefficient=1.0)), + ("momentum Morris sigma=1", + SurfaceTensionMomentumMorris(; + surface_tension_coefficient=1.0)), + ("distributed Morris sigma=1", + SurfaceTensionMorrisAkinci(; + surface_tension_coefficient=1.0))) +end + +function print_calibration(label, result) + @printf("%-29s sigma_eff=%10.6f N/m |a_cm|=%9.3e m/s^2 |tau|/m=%9.3e m^2/s^2 a_rms=%9.3e m/s^2\n", + label, result.inferred_surface_tension, + result.center_of_mass_acceleration, result.torque_per_mass, + result.acceleration_rms) +end + +function run_calibration_suite() + for (label, model) in calibration_models() + print_calibration(label, rayleigh_stiffness(model)) + end + + particle_spacing = cbrt(1.0e-6 / 750) + support_radius = 2.8particle_spacing + sigma_cohesion = akinci_cohesion_surface_tension(1.0, 1000.0, + support_radius) + @printf("virial cohesion prediction sigma=%10.6f N/m\n", sigma_cohesion) + @printf("published adhesion work ratio I_A/I_C=%10.6f\n", + AKINCI_ADHESION_TO_COHESION_WORK_3D) +end + +function calibration_model(model_name, coefficient; cohesion_coefficient=0.0) + if model_name == "akinci" + return SurfaceTensionAkinci(; surface_tension_coefficient=coefficient) + elseif model_name == "cohesion" + return CohesionForceAkinci(; surface_tension_coefficient=coefficient) + elseif model_name == "morris" + return SurfaceTensionMorris(; surface_tension_coefficient=coefficient) + elseif model_name == "momentum_morris" + return SurfaceTensionMomentumMorris(; surface_tension_coefficient=coefficient) + elseif model_name == "hybrid" + return SurfaceTensionMorrisAkinci(; + surface_tension_coefficient=coefficient, + cohesion_coefficient) + end + throw(ArgumentError("unknown model `$model_name`")) +end + +function laplace_pressure_calibration(surface_tension; final_time=0.1, + reference_density=1000.0, + drop_volume=1.0e-6, + target_particle_count=750, + viscosity_alpha=0.1, + ideal_density_threshold=0.95, + interface_taper_start=0.8, + support_taper_width=0.025, + record_steps=false, + cfl_number=0.65) + initial_condition = deformed_drop(; stretch=1.0, reference_density, + drop_volume, target_particle_count) + particle_spacing = initial_condition.particle_spacing + smoothing_kernel = WendlandC2Kernel{3}() + smoothing_length = 1.4particle_spacing + support_radius = TrixiParticles.compact_support(smoothing_kernel, smoothing_length) + state_equation = StateEquationCole(; sound_speed=100.0, + reference_density, exponent=7, + clip_negative_pressure=true) + surface_normal_method = if TrixiParticles.requires_surface_normal(surface_tension) + ColorfieldSurfaceNormal(; boundary_contact_threshold=Inf, + interface_threshold=0.01, + ideal_density_threshold, + interface_taper_start, + support_taper_width) + else + nothing + end + viscosity = ArtificialViscosityMonaghan(; alpha=viscosity_alpha, beta=0.0) + fluid_system = WeaklyCompressibleSPHSystem(initial_condition; smoothing_kernel, + smoothing_length, + density_calculator=ContinuityDensity(), + density_diffusion=DensityDiffusionAntuono(; + delta=0.1), + state_equation, + viscosity, surface_tension, + surface_normal_method, + correction=AkinciFreeSurfaceCorrection(reference_density), + reference_particle_spacing=particle_spacing) + semi = Semidiscretization(fluid_system) + ode = semidiscretize(semi, (0.0, final_time)) + dtmax = 5.0e-4 + initial_v_ode, initial_u_ode = ode.u0.x + dt_reference = min(dtmax, + TrixiParticles.calculate_dt(initial_v_ode, initial_u_ode, cfl_number, + fluid_system, semi)) + callback = record_steps ? StepsizeCallback(; cfl=cfl_number) : nothing + solution = nothing + runtime = @elapsed solution = solve(ode, RDPK3SpFSAL35(); abstol=1.0e-7, + reltol=1.0e-4, dtmax, + save_everystep=record_steps, + saveat=record_steps ? () : (final_time,), callback) + v_ode, u_ode = last(solution.u).x + TrixiParticles.update_systems_and_nhs(v_ode, u_ode, semi, final_time) + v = TrixiParticles.wrap_v(v_ode, fluid_system, semi) + u = TrixiParticles.wrap_u(u_ode, fluid_system, semi) + coordinates = Array(TrixiParticles.current_coordinates(u, fluid_system)) + velocity = Array(TrixiParticles.current_velocity(v, fluid_system)) + density = collect(TrixiParticles.current_density(v, fluid_system)) + pressure = [TrixiParticles.current_pressure(v, fluid_system, particle) + for particle in TrixiParticles.eachparticle(fluid_system)] + mass = fluid_system.mass + center = vec(sum(coordinates .* reshape(mass, 1, :); dims=2) / sum(mass)) + radii = vec(sqrt.(sum(abs2, coordinates .- center; dims=1))) + volume = sum(mass ./ density) + equivalent_radius = cbrt(3volume / (4pi)) + interior = findall(<=(max(equivalent_radius - support_radius, 0.0)), radii) + if length(interior) < 8 + interior = sortperm(radii)[1:min(20, length(radii))] + end + interior_pressure = pressure[interior] + pressure_jump = median(interior_pressure) + inferred_surface_tension = pressure_jump * equivalent_radius / 2 + speed = sqrt.(vec(sum(abs2, velocity; dims=1))) + accepted_steps = solution.stats.naccept + rejected_steps = solution.stats.nreject + eta_p01 = NaN + eta_median = NaN + eta_tail_head = NaN + if record_steps && length(solution.t) > 7 + accepted_dt = diff(solution.t) + sample = accepted_dt[6:(end - 1)] ./ dt_reference + eta_p01 = quantile(sample, 0.01) + eta_median = median(sample) + segment_length = max(1, floor(Int, 0.2length(sample))) + eta_tail_head = median(last(sample, segment_length)) / + median(first(sample, segment_length)) + end + has_activity = surface_tension isa SurfaceTensionMorris || + surface_tension isa SurfaceTensionMomentumMorris + activity = has_activity ? fluid_system.cache.interface_activity : Float64[] + support_moment = surface_tension isa SurfaceTensionMorris ? + fluid_system.cache.support_moment : + surface_tension isa SurfaceTensionMomentumMorris ? + fluid_system.cache.divergence_correction : Float64[] + return (; inferred_surface_tension, pressure_jump, + pressure_mean=mean(interior_pressure), + pressure_range=extrema(interior_pressure), equivalent_radius, + interior_particles=length(interior), density_range=extrema(density), + speed_rms=sqrt(mean(abs2, speed)), speed_max=maximum(speed), + particle_count=length(density), runtime, accepted_steps, rejected_steps, + dt_reference, eta_p01, eta_median, eta_tail_head, + active_particles=count(>(0), activity), + transition_particles=count(value -> 0 < value < 1, activity), + activity_range=isempty(activity) ? (NaN, NaN) : extrema(activity), + support_moment_range=isempty(support_moment) ? (NaN, NaN) : + extrema(support_moment)) +end + +function print_laplace_calibration(model_name, coefficient, result) + @printf("Laplace %-16s coefficient=%10.5g sigma_eff=%10.6f N/m dp=%10.3f Pa R=%8.5f m interior=%d rho=[%.3f, %.3f] vrms=%.4e vmax=%.4e\n", + model_name, coefficient, result.inferred_surface_tension, + result.pressure_jump, result.equivalent_radius, + result.interior_particles, result.density_range..., + result.speed_rms, result.speed_max) + @printf(" runtime=%.2f s steps=%d/%d dt_ref=%.3e eta[p01,median,tail/head]=[%.3f, %.3f, %.3f] active=%d transition=%d q=[%.4f, %.4f]\n", + result.runtime, result.accepted_steps, result.rejected_steps, + result.dt_reference, result.eta_p01, result.eta_median, + result.eta_tail_head, result.active_particles, result.transition_particles, + result.support_moment_range...) +end + +function laplace_pressure_series(surface_tension; final_time=0.02, + volume_factors=(0.5, 1.0, 2.0), + base_target_particle_count=750, + viscosity_alpha=0.1, + ideal_density_threshold=0.95, + interface_taper_start=0.8, + support_taper_width=0.025, + record_steps=false) + results = map(volume_factors) do factor + drop_volume = factor * 1.0e-6 + target_particle_count = round(Int, factor * base_target_particle_count) + laplace_pressure_calibration(surface_tension; final_time, drop_volume, + target_particle_count, viscosity_alpha, + ideal_density_threshold, + interface_taper_start, support_taper_width, + record_steps) + end + inverse_radius = [2 / result.equivalent_radius for result in results] + pressure = [result.pressure_jump for result in results] + mean_inverse_radius = mean(inverse_radius) + mean_pressure = mean(pressure) + surface_tension = sum((inverse_radius .- mean_inverse_radius) .* + (pressure .- mean_pressure)) / + sum(abs2, inverse_radius .- mean_inverse_radius) + bulk_pressure = mean_pressure - surface_tension * mean_inverse_radius + fitted_pressure = bulk_pressure .+ surface_tension .* inverse_radius + residual_rms = sqrt(mean(abs2, pressure .- fitted_pressure)) + return (; results, volume_factors, surface_tension, bulk_pressure, residual_rms) +end + +function print_laplace_series(model_name, coefficient, series) + for (factor, result) in zip(series.volume_factors, series.results) + @printf(" V=%4.1f cm^3 n=%4d R=%8.5f m dp=%10.3f Pa vrms=%9.3e m/s runtime=%6.2f steps=%d/%d eta01=%6.3f\n", + factor, result.particle_count, result.equivalent_radius, + result.pressure_jump, result.speed_rms, result.runtime, + result.accepted_steps, result.rejected_steps, result.eta_p01) + end + @printf("Laplace slope %-10s coefficient=%10.5g sigma_eff=%10.6f N/m p_bulk=%10.3f Pa residual_rms=%8.3f Pa\n", + model_name, coefficient, series.surface_tension, + series.bulk_pressure, series.residual_rms) +end + +if abspath(PROGRAM_FILE) == @__FILE__ + if isempty(ARGS) + run_calibration_suite() + else + length(ARGS) in (4, 5) || + error("usage: surface_tension_calibration.jl laplace MODEL COEFFICIENT " * + "FINAL_TIME [COHESION_COEFFICIENT]") + ARGS[1] in ("laplace", "laplace_series") || + error("unknown calibration `$(ARGS[1])`") + model_name = ARGS[2] + coefficient = parse(Float64, ARGS[3]) + final_time = parse(Float64, ARGS[4]) + cohesion_coefficient = length(ARGS) == 5 ? parse(Float64, ARGS[5]) : 0.0 + model = calibration_model(model_name, coefficient; cohesion_coefficient) + if ARGS[1] == "laplace" + result = laplace_pressure_calibration(model; final_time) + print_laplace_calibration(model_name, coefficient, result) + else + series = laplace_pressure_series(model; final_time) + print_laplace_series(model_name, coefficient, series) + end + end +end diff --git a/compare_akinci/wcsph_variants.jl b/compare_akinci/wcsph_variants.jl new file mode 100644 index 0000000000..ee357812da --- /dev/null +++ b/compare_akinci/wcsph_variants.jl @@ -0,0 +1,244 @@ +using TrixiParticles + +# Experimental controls used to separate the WCSPH pressure discretization from the +# Akinci surface model. They intentionally live in the comparison workbench until the +# Figure 8 study establishes whether either change is useful. + +# Three-dimensional continuum constants for planar interfaces. For a central pair force +# `-gamma * m_a * m_b * C(r) * r_hat`, the Akinci cohesion kernel gives +# +# sigma = pi / 8 * gamma * rho^2 * integral(r^4 C(r), r=0..H) +# = AKINCI_COHESION_VIRIAL_3D * gamma * rho^2 * H^2. +# +# The second constant is the ratio of the corresponding planar work integral for the +# published adhesion kernel A to that of C. It explains why beta/gamma values below one +# produce only weak wetting when the two kernels are used together. +const AKINCI_COHESION_VIRIAL_3D = 21 / 7040 +const AKINCI_ADHESION_TO_COHESION_WORK_3D = 0.10743711881286003 +# Exact planar cleavage moment for the Akinci cohesion potential on a cubic lattice +# with `support_radius / particle_spacing = 2.8`. +const AKINCI_COHESION_LATTICE_MOMENT_3D_H28 = 0.0026426355182533943 + +function akinci_cohesion_surface_tension(coefficient, reference_density, support_radius) + values = promote(coefficient, reference_density, support_radius) + coefficient_, density_, support_ = values + isfinite(coefficient_) && coefficient_ >= 0 || + throw(ArgumentError("`coefficient` must be finite and non-negative")) + isfinite(density_) && density_ > 0 || + throw(ArgumentError("`reference_density` must be finite and positive")) + isfinite(support_) && support_ > 0 || + throw(ArgumentError("`support_radius` must be finite and positive")) + return AKINCI_COHESION_VIRIAL_3D * coefficient_ * density_^2 * support_^2 +end + +function akinci_cohesion_coefficient(surface_tension, reference_density, support_radius) + unit_surface_tension = akinci_cohesion_surface_tension(one(surface_tension), + reference_density, + support_radius) + isfinite(surface_tension) && surface_tension >= 0 || + throw(ArgumentError("`surface_tension` must be finite and non-negative")) + return surface_tension / unit_surface_tension +end + +function akinci_wall_cohesion_coefficient(surface_tension, contact_angle, + reference_density, support_radius) + isfinite(contact_angle) && 0 <= contact_angle <= 180 || + throw(ArgumentError("`contact_angle` must be in [0, 180] degrees")) + cohesion_coefficient = akinci_cohesion_coefficient(surface_tension, + reference_density, + support_radius) + return cohesion_coefficient * (1 + cosd(contact_angle)) / 2 +end + +struct FreeSurfaceDensityDiffusionAntuono{T} <: TrixiParticles.AbstractDensityDiffusion + delta::T + reference_density::T + lower_density_ratio::T + upper_density_ratio::T + + function FreeSurfaceDensityDiffusionAntuono(; delta, reference_density, + lower_density_ratio=0.6, + upper_density_ratio=0.9) + values = promote(delta, reference_density, lower_density_ratio, + upper_density_ratio) + delta_, reference_density_, lower_, upper_ = values + isfinite(delta_) && delta_ >= 0 || + throw(ArgumentError("`delta` must be finite and non-negative")) + isfinite(reference_density_) && reference_density_ > 0 || + throw(ArgumentError("`reference_density` must be positive")) + 0 <= lower_ < upper_ <= 1 || + throw(ArgumentError("density ratios must satisfy `0 <= lower < upper <= 1`")) + new{typeof(delta_)}(delta_, reference_density_, lower_, upper_) + end +end + +function Base.show(io::IO, diffusion::FreeSurfaceDensityDiffusionAntuono) + print(io, "FreeSurfaceDensityDiffusionAntuono(", diffusion.delta, ", ", + diffusion.lower_density_ratio, "-", diffusion.upper_density_ratio, ")") +end + +function TrixiParticles.create_cache_density_diffusion(initial_condition, + diffusion::FreeSurfaceDensityDiffusionAntuono) + base = DensityDiffusionAntuono(; delta=diffusion.delta) + base_cache = TrixiParticles.create_cache_density_diffusion(initial_condition, base) + free_surface_summation_density = similar(initial_condition.density) + return (; base_cache..., free_surface_summation_density) +end + +function TrixiParticles.update!(diffusion::FreeSurfaceDensityDiffusionAntuono, + v, u, system, semi) + base = DensityDiffusionAntuono(; delta=diffusion.delta) + TrixiParticles.update!(base, v, u, system, semi) + summation_density = system.cache.free_surface_summation_density + TrixiParticles.set_zero!(summation_density) + coordinates = TrixiParticles.current_coordinates(u, system) + points = TrixiParticles.each_integrated_particle(system) + TrixiParticles.foreach_point_neighbor(system, system, coordinates, coordinates, semi; + points) do particle, neighbor, pos_diff, distance + mass = TrixiParticles.hydrodynamic_mass(system, neighbor) + summation_density[particle] += mass * + TrixiParticles.smoothing_kernel(system, distance, + particle) + end + return diffusion +end + +@inline function free_surface_diffusion_weight(diffusion, summation_density) + ratio = summation_density / diffusion.reference_density + return clamp((ratio - diffusion.lower_density_ratio) / + (diffusion.upper_density_ratio - diffusion.lower_density_ratio), 0, 1) +end + +@inline function TrixiParticles.density_diffusion_psi(diffusion::FreeSurfaceDensityDiffusionAntuono, + rho_a, rho_b, pos_diff, distance, + system, particle, neighbor) + summation_density = system.cache.free_surface_summation_density + weight_a = free_surface_diffusion_weight(diffusion, summation_density[particle]) + weight_b = free_surface_diffusion_weight(diffusion, summation_density[neighbor]) + base = DensityDiffusionAntuono(; delta=diffusion.delta) + psi = TrixiParticles.density_diffusion_psi(base, rho_a, rho_b, pos_diff, distance, + system, particle, neighbor) + return min(weight_a, weight_b) * psi +end + +struct WCSPHAkinciSurfaceNormal{K, T} + smoothing_kernel::K + smoothing_length::T +end + +function TrixiParticles.create_cache_surface_normal(::WCSPHAkinciSurfaceNormal, + ELTYPE, NDIMS, nparticles) + surface_normal = Array{ELTYPE, 2}(undef, NDIMS, nparticles) + neighbor_count = Array{ELTYPE, 1}(undef, nparticles) + return (; surface_normal, neighbor_count) +end + +function TrixiParticles.compute_surface_normal!(system::TrixiParticles.AbstractFluidSystem, + method::WCSPHAkinciSurfaceNormal, + v, u, v_ode, u_ode, semi, t) + cache = system.cache + TrixiParticles.set_zero!(cache.surface_normal) + TrixiParticles.set_zero!(cache.neighbor_count) + system_coordinates = TrixiParticles.current_coordinates(u, system) + + for neighbor_system in semi.systems + neighbor_system isa TrixiParticles.AbstractFluidSystem || continue + v_neighbor = TrixiParticles.wrap_v(v_ode, neighbor_system, semi) + u_neighbor = TrixiParticles.wrap_u(u_ode, neighbor_system, semi) + neighbor_coordinates = TrixiParticles.current_coordinates(u_neighbor, + neighbor_system) + support_radius = TrixiParticles.compact_support(method.smoothing_kernel, + method.smoothing_length) + points = TrixiParticles.each_integrated_particle(system) + + TrixiParticles.foreach_point_neighbor(system, neighbor_system, + system_coordinates, neighbor_coordinates, + semi; + points) do particle, neighbor, pos_diff, + distance + distance >= support_radius && return + mass_b = TrixiParticles.hydrodynamic_mass(neighbor_system, neighbor) + density_b = TrixiParticles.current_density(v_neighbor, neighbor_system, + neighbor) + gradient = TrixiParticles.kernel_grad(method.smoothing_kernel, pos_diff, + distance, method.smoothing_length) + for dimension in 1:TrixiParticles.ndims(system) + cache.surface_normal[dimension, + particle] += mass_b / density_b * + gradient[dimension] + end + cache.neighbor_count[particle] += 1 + end + end + + TrixiParticles.remove_invalid_normals!(system, + TrixiParticles.surface_tension_model(system), + method) + return system +end + +struct SurfaceTensionAkinciWCSPH{T} <: TrixiParticles.AkinciTypeSurfaceTension + surface_tension_coefficient::T + support_radius::T + curvature_factor::T + + function SurfaceTensionAkinciWCSPH(; surface_tension_coefficient, support_radius, + curvature_factor=1) + values = promote(surface_tension_coefficient, support_radius, curvature_factor) + coefficient, support, curvature = values + isfinite(coefficient) && coefficient >= 0 || + throw(ArgumentError("`surface_tension_coefficient` must be finite and non-negative")) + isfinite(support) && support > 0 || + throw(ArgumentError("`support_radius` must be finite and positive")) + isfinite(curvature) && curvature >= 0 || + throw(ArgumentError("`curvature_factor` must be finite and non-negative")) + new{typeof(coefficient)}(coefficient, support, curvature) + end +end + +TrixiParticles.requires_surface_normal(::SurfaceTensionAkinciWCSPH) = true + +@inline function TrixiParticles.surface_tension_force!(dv_particle, + surface_tension_a::SurfaceTensionAkinciWCSPH, + surface_tension_b::SurfaceTensionAkinciWCSPH, + particle_system::TrixiParticles.AbstractFluidSystem, + neighbor_system::TrixiParticles.AbstractFluidSystem, + particle, neighbor, pos_diff, + distance, rho_a, rho_b, grad_kernel, + surface_tension_correction) + support_radius = surface_tension_a.support_radius + distance >= support_radius && return dv_particle + distance^2 < eps(support_radius^2) && return dv_particle + + mass_b = TrixiParticles.hydrodynamic_mass(neighbor_system, neighbor) + normal_a = support_radius * TrixiParticles.surface_normal(particle_system, particle) + normal_b = support_radius * TrixiParticles.surface_normal(neighbor_system, neighbor) + dimensions = Val(TrixiParticles.ndims(particle_system)) + + dv_particle[] += surface_tension_correction * + TrixiParticles.cohesion_force_akinci(surface_tension_a, + support_radius, mass_b, + pos_diff, distance, dimensions) + dv_particle[] -= surface_tension_correction * + surface_tension_a.surface_tension_coefficient * + surface_tension_a.curvature_factor * (normal_a - normal_b) + return dv_particle +end + +@inline function TrixiParticles.adhesion_force!(dv_particle, + surface_tension::SurfaceTensionAkinciWCSPH, + particle_system::TrixiParticles.AbstractFluidSystem, + neighbor_system::TrixiParticles.AbstractBoundarySystem, + particle, neighbor, pos_diff, distance) + adhesion_coefficient = neighbor_system.adhesion_coefficient + abs(adhesion_coefficient) < eps() && return dv_particle + distance^2 < eps(surface_tension.support_radius^2) && return dv_particle + + mass_b = TrixiParticles.hydrodynamic_mass(neighbor_system, neighbor) + dv_particle[] += TrixiParticles.adhesion_force_akinci(surface_tension, + surface_tension.support_radius, + mass_b, pos_diff, distance, + adhesion_coefficient, + Val(TrixiParticles.ndims(particle_system))) + return dv_particle +end diff --git a/compare_akinci/wetting_diagnostics.jl b/compare_akinci/wetting_diagnostics.jl new file mode 100644 index 0000000000..5ebbc005c0 --- /dev/null +++ b/compare_akinci/wetting_diagnostics.jl @@ -0,0 +1,73 @@ +using CairoMakie +using Serialization +using Statistics + +function center_slice(snapshot_path; system_index=1) + snapshot = open(deserialize, snapshot_path) + frame = last(snapshot.frames) + system = frame.systems[system_index] + coordinates = system.coordinates + spacing = system.particle_spacing + slice_center = median(coordinates[2, :]) + mask = abs.(coordinates[2, :] .- slice_center) .<= 0.75 * spacing + width = max(maximum(coordinates[1, :]) - minimum(coordinates[1, :]), + maximum(coordinates[2, :]) - minimum(coordinates[2, :])) + spacing + height = maximum(coordinates[3, :]) - minimum(coordinates[3, :]) + spacing + below = coordinates[3, :] .< 0 + below_wall = count(below) + return (; coordinates=coordinates[:, mask], spacing, time=last(snapshot.times), width, + height, below_wall, below_coordinates=coordinates[:, below], + particle_count=size(coordinates, 2)) +end + +function wetting_diagnostics(entries, output_path) + slices = map(entry -> center_slice(entry.snapshot), entries) + x_min = minimum(minimum(slice.coordinates[1, :]) for slice in slices) + x_max = maximum(maximum(slice.coordinates[1, :]) for slice in slices) + z_min = minimum(minimum(slice.coordinates[3, :]) for slice in slices) + z_max = maximum(maximum(slice.coordinates[3, :]) for slice in slices) + margin = 2 * maximum(slice.spacing for slice in slices) + + figure = Figure(; size=(360 * length(entries), 340), fontsize=17) + for (index, (entry, slice)) in enumerate(zip(entries, slices)) + (; coordinates, time, width, height, below_wall, below_coordinates, + particle_count) = slice + status = if iszero(below_wall) + "h/w=$(round(height / width; digits=2))" + else + "invalid: $below_wall/$particle_count below plane" + end + axis = Axis(figure[1, index]; title=entry.label, + subtitle="t=$(round(time; digits=3)) s, $status", + xlabel="x [m]", ylabel=index == 1 ? "z [m]" : "", + yticks=WilkinsonTicks(5), aspect=DataAspect()) + scatter!(axis, coordinates[1, :], coordinates[3, :]; color=:dodgerblue3, + markersize=3) + if !isempty(below_coordinates) + scatter!(axis, below_coordinates[1, :], below_coordinates[3, :]; color=:crimson, + markersize=5) + end + hlines!(axis, 0; color=:gray35, linewidth=2) + limits!(axis, x_min - margin, x_max + margin, z_min - margin, z_max + margin) + + println(entry.label, ": t=", round(time; digits=3), " s, width=", + round(1.0e3 * width; digits=3), " mm, height=", + round(1.0e3 * height; digits=3), " mm, h/w=", + round(height / width; digits=4), ", below plane=", below_wall, "/", + particle_count) + end + + save(output_path, figure) + println("Wrote wetting diagnostics to $output_path") + return output_path +end + +if abspath(PROGRAM_FILE) == @__FILE__ + length(ARGS) >= 3 || + error("pass an output path followed by at least two label=snapshot entries") + entries = map(ARGS[2:end]) do argument + label, snapshot = split(argument, '='; limit=2) + return (; label, snapshot) + end + wetting_diagnostics(entries, ARGS[1]) +end diff --git a/docs/literate/src/tut_2d_geometry.jl b/docs/literate/src/tut_2d_geometry.jl new file mode 100644 index 0000000000..3028e53527 --- /dev/null +++ b/docs/literate/src/tut_2d_geometry.jl @@ -0,0 +1,155 @@ +# # [Setting up a 2D simulation from geometry files](@id tut_2d_geometry) + +# In this tutorial, we build two genuine 2D setups from geometry files: +# 1. a curved pipe, where one geometry file defines the outer wall envelope and a second +# one defines the empty channel cut out of it, +# 2. a dam-break basin with a coastline profile, where one geometry file defines the +# filled coastline wall together with the seawall on the right. +# +# For a real 2D setup, we use 2D geometry formats such as `.asc` or `.dxf`. +# STL files are surface meshes and therefore naturally lead to thin 3D setups instead. + +# First, we import TrixiParticles.jl together with +# `OrdinaryDiffEqLowStorageRK` of +# [OrdinaryDiffEq.jl](https://github.com/SciML/OrdinaryDiffEq.jl) +# and [Plots.jl](https://docs.juliaplots.org/stable/). +using TrixiParticles +using OrdinaryDiffEqLowStorageRK +using Plots + +# ## Resolution + +# We use the same particle spacing for the fluid and for the wall geometries. +particle_spacing = 0.03 +fluid_density = 1000.0 +gravity = 9.81 +sound_speed = 10.0 +state_equation = StateEquationCole(; sound_speed, reference_density=fluid_density, + exponent=7) +nothing # hide + +# ## Loading 2D geometry files + +# The following helper loads a closed 2D geometry file and samples particles in its interior: +# 1. load the polygon with [`load_geometry`](@ref), +# 2. fill the polygon with [`ComplexShape`](@ref). +# +# This creates a true 2D solid region instead of a hollow shell around the polygon edges. +function solid_from_geometry_file(file; particle_spacing, density) + geometry = load_geometry(file) + solid = ComplexShape(geometry; particle_spacing, density, + grid_offset=0.5particle_spacing) + + return (; geometry, solid) +end + +# ## A curved pipe from two filled geometries + +# The pipe wall is a solid L-shaped region with a channel cut out of it: +# 1. one geometry file describes the outer pipe envelope, +# 2. one geometry file describes the empty channel, +# 3. the `setdiff` operation subtracts the channel from the solid envelope. +pipe_outer_file = pkgdir(TrixiParticles, "examples", "preprocessing", "data", + "curved_pipe_outer_2d.asc") +pipe_channel_file = pkgdir(TrixiParticles, "examples", "preprocessing", "data", + "curved_pipe_channel_2d.asc") + +pipe_outer = solid_from_geometry_file(pipe_outer_file; particle_spacing, + density=fluid_density) +pipe_channel = load_geometry(pipe_channel_file) + +pipe_setup = (; wall=setdiff(pipe_outer.solid, pipe_channel), + outer_geometry=pipe_outer.geometry, + channel_geometry=pipe_channel) + +# ## A dam-break basin with a coastline profile + +# In the second setup, a single 2D geometry file defines a filled coastline wall: +# the beach profile on top, a finite wall thickness below it, and the seawall on the right. +coast_file = pkgdir(TrixiParticles, "examples", "preprocessing", "data", + "coastline_profile_2d.asc") +coast = solid_from_geometry_file(coast_file; particle_spacing, density=fluid_density) + +# The geometry file gives the coastline bed and the right wall as a solid region. +# We add the left wall explicitly as a rectangular particle block and place a +# 1.5x taller rectangular dam-break water column next to it. +left_wall = RectangularShape(particle_spacing, (5, 50), (0.0, -0.12), + density=fluid_density) +reservoir = RectangularShape(particle_spacing, (28, 42), (0.15, 0.03), + acceleration=(0.0, -gravity), + state_equation=state_equation) +coast_setup = (; geometry=coast.geometry, + wall=union(coast.solid, left_wall), + fluid=setdiff(reservoir, coast.geometry)) + +p_pipe = plot(pipe_setup.wall, label="wall", title="Curved pipe", + markerstrokewidth=0, markersize=4) +plot!(p_pipe, showaxis=false, aspect_ratio=:equal, + xlims=(-0.03, 1.23), ylims=(-0.03, 1.23)) + +p_coast = plot(coast_setup.fluid, coast_setup.wall, + labels=["fluid" "wall"], title="Coastline dam break", + markerstrokewidth=0, markersize=3) +plot!(p_coast, showaxis=false, aspect_ratio=:equal, + xlims=(0.0, 2.75), ylims=(-0.15, 1.35)) + +plot(p_pipe, p_coast, layout=(1, 2), size=(900, 360)) +savefig("tut_2d_geometry_plot.png"); # hide +# ![2D geometry based initial conditions](tut_2d_geometry_plot.png) + +# ## Building the simulation systems + +# To keep the example focused, we continue with the coastline setup. +# From this point on, the simulation setup is the same as in other 2D simulation files. +setup = coast_setup +tspan = (0.0, 0.03) +nothing # hide + +# We define the state equation, smoothing kernel, and viscosity for a +# weakly compressible SPH simulation. +smoothing_length = 1.2 * particle_spacing +smoothing_kernel = SchoenbergCubicSplineKernel{2}() +viscosity = ArtificialViscosityMonaghan(alpha=0.02, beta=0.0) + +fluid_density_calculator = ContinuityDensity() +density_diffusion = DensityDiffusionMolteniColagrossi(delta=0.1) + +fluid_system = WeaklyCompressibleSPHSystem(setup.fluid; + density_calculator=fluid_density_calculator, + state_equation, smoothing_kernel, + smoothing_length, viscosity=viscosity, + density_diffusion=density_diffusion, + acceleration=(0.0, -gravity)) +nothing # hide + +# For the wall, we reuse the combined solid wall particles created above. The high-level +# constructor obtains the smoothing kernel, smoothing length, and state equation from the fluid. +boundary_model = BoundaryModelDummyParticles(setup.wall; fluid_system) +boundary_system = WallBoundarySystem(setup.wall, boundary_model) +nothing # hide + +# ## Semidiscretization + +# With fluid and wall particles defined, we can build the +# [`Semidiscretization`](@ref TrixiParticles.Semidiscretization) exactly as in other tutorials. +semi = Semidiscretization(fluid_system, boundary_system) +ode = semidiscretize(semi, tspan) +nothing # hide + +# ## Time integration + +# The setup is now complete. +# To start the simulation, run for example +# ```julia +# callbacks = CallbackSet(InfoCallback(interval=10)) +# sol = solve(ode, RDPK3SpFSAL35(), save_everystep=false, callback=callbacks) +# ``` +# This is the same final step as in [the basic setup tutorial](@ref tut_setup). +callbacks = CallbackSet(InfoCallback(interval=10)) +nothing # hide + +sol = solve(ode, RDPK3SpFSAL35(), save_everystep=false, callback=callbacks) #!md + +# For more accurate body-fitted particles around sharper features, you can also +# apply the [particle packing workflow](@ref tut_packing) to the 2D geometry files +# before starting the simulation. diff --git a/docs/literate/src/tut_packing.jl b/docs/literate/src/tut_packing.jl index d5b9610283..9ae98729d2 100644 --- a/docs/literate/src/tut_packing.jl +++ b/docs/literate/src/tut_packing.jl @@ -75,8 +75,8 @@ plot!(right_margin=5Plots.mm) #hide # ## Creating an initial configuration of boundary particles # To create the initial configuration of the boundary particles, -# we use the sampled points of the SDF whose signed distance lies between 0 -# and `boundary_thickness`. +# we use the sampled points of the SDF whose signed distance lies between the +# geometry offset implied by `place_on_shell` and `boundary_thickness`. # Here, we need to specify the `density` of the boundary particles. # As an example, we choose `1.0` for all particles. # This gives us an [`InitialCondition`](@ref InitialCondition) for the boundary particles. @@ -125,7 +125,8 @@ plot!(geometry, linestyle=:dash, label=nothing, showaxis=false, color=:black, # ## Particle packing # In the following, we will essentially follow the same steps described in the fluid tutorials. -# That means we will generate systems that are then passed to the [`Semidiscretization`](@ref). +# That means we will generate systems that are then passed to the +# [`Semidiscretization`](@ref TrixiParticles.Semidiscretization). # The difference from a typical physical simulation is that we use [`ParticlePackingSystem`](@ref), # which does not represent any physical law. Instead, we only use the simulation framework to time-integrate # the packing process. @@ -211,7 +212,7 @@ plot!(geometry, seriestype=:path, color=:black, label=nothing, linewidth=2) boundary_system = ParticlePackingSystem(boundary_sampled; is_boundary=true, smoothing_kernel, smoothing_length, boundary_compress_factor=0.7, signed_distance_field, - background_pressure) + boundary_thickness, background_pressure) # We can now couple the boundary system with the interior system: semi = Semidiscretization(packing_system, boundary_system) @@ -251,10 +252,12 @@ fixed_system = ParticlePackingSystem(packed_ic; smoothing_kernel, smoothing_leng # Now we define a rectangular domain that we want to pack. # In practice, you could create any `InitialCondition` that encloses your complex geometry. -tank_domain = RectangularTank(particle_spacing, (4, 4), (0, 0), min_coordinates=(-1, -2), - density) +domain_size = (4, 4) +n_particles_per_dimension = round.(Int, domain_size ./ particle_spacing) +tank_domain = RectangularShape(particle_spacing, n_particles_per_dimension, (-1, -2); + density) -sampled_outer_domain = setdiff(tank_domain.fluid, packed_ic) +sampled_outer_domain = setdiff(tank_domain, packed_ic) # If we plot these two `InitialCondition`s, we can see # that the geometry interface is not properly represented yet. diff --git a/docs/literate/src/tut_rigid_body_fsi.jl b/docs/literate/src/tut_rigid_body_fsi.jl index e87a17e88e..ea09268ed5 100644 --- a/docs/literate/src/tut_rigid_body_fsi.jl +++ b/docs/literate/src/tut_rigid_body_fsi.jl @@ -165,12 +165,8 @@ nothing # hide # See [the docs on dummy particles](@ref boundary_models) for a definition for these terms. boundary_density_calculator = AdamiPressureExtrapolation() -tank_boundary_model = BoundaryModelDummyParticles(tank.boundary.density, - tank.boundary.mass, - boundary_density_calculator, - fluid_smoothing_kernel, - fluid_smoothing_length; - state_equation) +tank_boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system, + boundary_density_calculator) boundary_system = WallBoundarySystem(tank.boundary, tank_boundary_model) nothing # hide diff --git a/docs/literate/src/tut_setup.jl b/docs/literate/src/tut_setup.jl index b546c5eb89..2372e45b04 100644 --- a/docs/literate/src/tut_setup.jl +++ b/docs/literate/src/tut_setup.jl @@ -131,20 +131,18 @@ nothing # hide # To model the boundary, we use particle-based boundary conditions, in which particles # are sampled in the boundary that interact with the fluid particles to avoid penetration. -# In order to define a boundary system, we first have to choose a boundary model, -# which defines how the fluid interacts with boundary particles. -# We will use the [`BoundaryModelDummyParticles`](@ref) with [`AdamiPressureExtrapolation`](@ref). -# See [here](@ref boundary_models) for a comprehensive overview over boundary models. -boundary_model = BoundaryModelDummyParticles(tank.boundary.density, tank.boundary.mass, - AdamiPressureExtrapolation(), - smoothing_kernel, smoothing_length; - state_equation) +# Here, we explicitly choose the dummy-particle boundary model and use its high-level +# builder to infer kernel and equation-of-state-related settings from the adjacent +# fluid system. See [here](@ref boundary_models) for a comprehensive overview over +# boundary models. +boundary_model = BoundaryModelDummyParticles(tank.boundary; fluid_system=fluid_system) boundary_system = WallBoundarySystem(tank.boundary, boundary_model) nothing # hide # ## [Semidiscretization](@id tut_setup_semi) -# The key component of every simulation is the [`Semidiscretization`](@ref), +# The key component of every simulation is the +# [`Semidiscretization`](@ref TrixiParticles.Semidiscretization), # which couples all systems of the simulation. # All simulation methods in TrixiParticles.jl are semidiscretizations, which discretize # the equations in space to provide an ordinary differential equation that still diff --git a/docs/make.jl b/docs/make.jl index ee7d9f9c8e..237e4978ba 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -42,6 +42,8 @@ Literate.markdown(joinpath("docs", "literate", "src", "tut_rigid_body_fsi.jl"), joinpath("docs", "src", "tutorials")) Literate.markdown(joinpath("docs", "literate", "src", "tut_packing.jl"), joinpath("docs", "src", "tutorials")) +Literate.markdown(joinpath("docs", "literate", "src", "tut_2d_geometry.jl"), + joinpath("docs", "src", "tutorials")) copy_file("AUTHORS.md", "in the [LICENSE.md](LICENSE.md) file" => "under [License](@ref)") @@ -94,6 +96,8 @@ makedocs(sitename="TrixiParticles.jl", "tut_rigid_body_fsi.md") ], "Preprocessing" => [ + "Setting up a 2D simulation from geometry files" => joinpath("tutorials", + "tut_2d_geometry.md"), "Particle packing tutorial" => joinpath("tutorials", "tut_packing.md") ] diff --git a/docs/src/development.md b/docs/src/development.md index 2e1e81fefe..ba237d4170 100644 --- a/docs/src/development.md +++ b/docs/src/development.md @@ -1,7 +1,6 @@ # [Development](@id development) - -## Preview of the documentation +## Preview of the Documentation To build the documentation, first instantiate the `docs` environment by running the following command from the TrixiParticles.jl root directory: @@ -23,30 +22,30 @@ python3 -m http.server -d docs/build ``` and open `localhost:8000` in your web browser. -## Release management +## Release Management To create a new release for TrixiParticles.jl, perform the following steps: -1) Make sure that all PRs and changes that you want to go into the release are merged to +1. Make sure that all PRs and changes that you want to go into the release are merged to `main` and that the latest commit on `main` has passed all CI tests. -2) Determine the currently released version of TrixiParticles.jl, e.g., on the +2. Determine the currently released version of TrixiParticles.jl, e.g., on the [release page](https://github.com/trixi-framework/TrixiParticles.jl/releases). For this manual, we will assume that the latest release was `v0.2.3`. -3) Decide on the next version number. We follow [semantic versioning](https://semver.org/), +3. Decide on the next version number. We follow [semantic versioning](https://semver.org/), thus each version is of the form `vX.Y.Z` where `X` is the major version, `Y` the minor version, and `Z` the patch version. In this manual, we assume that the major version is always `0`, thus the decision process on the new version is as follows: - * If the new release contains *breaking changes* (i.e., user code might not work as + - If the new release contains *breaking changes* (i.e., user code might not work as before without modifications), increase the *minor* version by one and set the *patch* version to zero. In our example, the new version should thus be `v0.3.0`. - * If the new release only contains minor modifications and/or bug fixes, the *minor* + - If the new release only contains minor modifications and/or bug fixes, the *minor* version is kept as-is and the *patch* version is increased by one. In our example, the new version should thus be `v0.2.4`. -4) Review and update the `NEWS.md` file to ensure all relevant changes, features, and bugfixes +4. Review and update the `NEWS.md` file to ensure all relevant changes, features, and bugfixes are documented for this release under the appropriate version header. -5) Edit the `version` string in the +5. Edit the `version` string in the [`Project.toml`](https://github.com/trixi-framework/TrixiParticles.jl/blob/main/Project.toml) and set it to the new version. Push/merge this change to `main`. -6) Go to GitHub and add a comment to the commit that you would like to become the new +6. Go to GitHub and add a comment to the commit that you would like to become the new release (typically this will be the commit where you just updated the version). You can comment on a commit by going to the [commit overview](https://github.com/trixi-framework/TrixiParticles.jl/commits/main/) and clicking @@ -54,14 +53,14 @@ To create a new release for TrixiParticles.jl, perform the following steps: ``` @JuliaRegistrator register ``` -7) Wait for the magic to happen! Specifically, JuliaRegistrator will create a new PR to the +7. Wait for the magic to happen. Specifically, JuliaRegistrator will create a new PR to the Julia registry with the new release information. After a grace period of ~15 minutes, this PR will be merged automatically. A short while after, [TagBot](https://github.com/trixi-framework/TrixiParticles.jl/blob/main/.github/workflows/TagBot.yml) will create a new release of TrixiParticles.jl in our GitHub repository. -8) Once the new release has been created, the new version can be obtained through the Julia +8. Once the new release has been created, the new version can be obtained through the Julia package manager as usual. -9) To make sure people do not mistake the latest state of `main` as the latest release, we +9. To make sure people do not mistake the latest state of `main` as the latest release, we set the version in the `Project.toml` to a *development* version. The development version should be the latest released version, with the patch version incremented by one, and the `-dev` suffix added. For example, if you just released `v0.3.0`, the new development diff --git a/docs/src/general/neighborhood_search.md b/docs/src/general/neighborhood_search.md index 93019eaffc..f2e0d47d2d 100644 --- a/docs/src/general/neighborhood_search.md +++ b/docs/src/general/neighborhood_search.md @@ -8,7 +8,8 @@ different implementations. !!! note "Usage" To run a simulation with a neighborhood search implementation, pass a neighborhood - search template to the constructor of the [`Semidiscretization`](@ref). + search template to the constructor of the + [`Semidiscretization`](@ref TrixiParticles.Semidiscretization). A template is just an empty neighborhood search with search radius `0.0`. See [`copy_neighborhood_search`](@ref) and the examples below for more details. ```jldoctest semi_example; output=false, setup = :(using TrixiParticles; trixi_include(@__MODULE__, joinpath(examples_dir(), "fluid", "hydrostatic_water_column_2d.jl"), sol=nothing); system1 = fluid_system; system2 = boundary_system) diff --git a/docs/src/getting_started.md b/docs/src/getting_started.md index ce41637552..fae83fd48b 100644 --- a/docs/src/getting_started.md +++ b/docs/src/getting_started.md @@ -1,4 +1,5 @@ -# [Getting started](@id getting_started) +# [Getting Started](@id getting_started) + If you have not installed TrixiParticles.jl yet, please follow the instructions in [Installation](@ref installation). This page provides a short introduction. For a broader introduction, take a look at our [Tutorials](tutorial.md). @@ -28,14 +29,14 @@ This will open a new window with a 2D visualization of the final solution: For more information about visualization, see [Visualization](visualization.md). -## Running other Examples +## Running Other Examples You can find more predefined examples under [Examples](examples.md). Run them from the Julia REPL by replacing `subfolder` and `example_name`: ```julia julia> trixi_include(joinpath(examples_dir(), "subfolder", "example_name.jl")) ``` -## Modifying an example +## Modifying an Example You can pass keyword arguments to the function `trixi_include` to overwrite assignments in the file. With `trixi_include`, we can overwrite variables defined in the example file to run a different simulation without modifying the file itself. diff --git a/docs/src/gpu.md b/docs/src/gpu.md index a7bdd6c3af..4d23e5c206 100644 --- a/docs/src/gpu.md +++ b/docs/src/gpu.md @@ -23,7 +23,7 @@ FullGridCellList{PointNeighbors.DynamicVectorOfVectors{...}(...) ``` We then need to pass this cell list to the neighborhood search and the neighborhood search -to the [`Semidiscretization`](@ref). +to the [`Semidiscretization`](@ref TrixiParticles.Semidiscretization). ```jldoctest gpu; output=false semi = Semidiscretization(fluid_system, boundary_system, neighborhood_search=GridNeighborhoodSearch{2}(; cell_list)) @@ -67,7 +67,7 @@ All data is transferred to the GPU during initialization and all loops over part and their neighbors are executed on the GPU as kernels generated by KernelAbstractions.jl. Data is only copied to the CPU for saving VTK files via the [`SolutionSavingCallback`](@ref). -## Run an existing example file on the GPU +## Run an Existing Example File on the GPU The example file `examples/fluid/dam_break_2d_gpu.jl` demonstrates how to run an existing example file on a GPU. @@ -102,7 +102,7 @@ trixi_include_changeprecision(Float32, coordinates_eltype=Float32) ``` -## [Single precision simulations](@id single_precision) +## [Single Precision Simulations](@id single_precision) All GPU-supported features can also be used with single precision, which is significantly faster on most GPUs and required for many Apple GPUs. diff --git a/docs/src/index.md b/docs/src/index.md index 1b16a8606f..8b0e9a699f 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -28,18 +28,22 @@ Its main features include:
-
2D Dam Break
+
+
2D Dam Break
-
Moving Wall
+
+
Moving Wall
-
Oscillating Beam
+
+
Oscillating Beam
-
Dam Break with Elastic Plate
+
+
Dam Break with Elastic Plate
diff --git a/docs/src/install.md b/docs/src/install.md index 533fd4fc5f..be5945afe4 100644 --- a/docs/src/install.md +++ b/docs/src/install.md @@ -1,11 +1,11 @@ # [Installation](@id installation) -## Setting up Julia +## Setting Up Julia If you have not installed Julia yet, please [follow the instructions on the official website](https://julialang.org/downloads/). TrixiParticles.jl works with Julia v1.10 and newer. We recommend using the latest stable release of Julia. -## For users +## For Users TrixiParticles.jl is a registered Julia package. You can install TrixiParticles.jl, time integration sub-packages of [OrdinaryDiffEq.jl](https://github.com/SciML/OrdinaryDiffEq.jl) @@ -18,7 +18,7 @@ julia> using Pkg julia> Pkg.add(["TrixiParticles", "OrdinaryDiffEqLowStorageRK", "OrdinaryDiffEqSymplecticRK", "Plots"]) ``` -## [For developers](@id for-developers) +## [For Developers](@id for-developers) If you plan on editing TrixiParticles.jl itself, you can download TrixiParticles.jl to a local folder and use the code from the cloned directory: ```bash @@ -41,19 +41,19 @@ related packages (e.g., sub-packages of OrdinaryDiffEq.jl, see above) to the pro in the `run` folder and always have a reproducible environment at hand to share with others. -## Optional software/packages +## Optional Software/Packages - [OrdinaryDiffEq.jl](https://github.com/SciML/OrdinaryDiffEq.jl) -- A Julia package of ordinary differential equation solvers; examples in TrixiParticles.jl use sub-packages such as `OrdinaryDiffEqLowStorageRK` and `OrdinaryDiffEqSymplecticRK` -- [Plots.jl](https://github.com/JuliaPlots/Plots.jl) -- Julia Plotting library that is used in some examples +- [Plots.jl](https://github.com/JuliaPlots/Plots.jl) -- Julia plotting library used in some examples - [PythonPlot.jl](https://github.com/JuliaPy/PythonPlot.jl) -- Plotting library that can be used instead of Plots.jl - [ParaView](https://www.paraview.org/) -- Visualization software for simulation results -## [Common issues](@id installation-issues) +## [Common Issues](@id installation-issues) If you followed the [installation instructions for developers](@ref for-developers) and run into package issues after pulling the latest version of TrixiParticles.jl, start Julia with the project in the `run` folder, ```bash - julia --project=run +julia --project=run ``` then update packages, resolve dependency conflicts, and install new dependencies: ```julia diff --git a/docs/src/preprocessing/preprocessing.md b/docs/src/preprocessing/preprocessing.md index d65fa462b2..beb56b3211 100644 --- a/docs/src/preprocessing/preprocessing.md +++ b/docs/src/preprocessing/preprocessing.md @@ -31,9 +31,9 @@ triangle = [125.0 375.0 250.0 125.0; 175.0 175.0 350.0 175.0] # Delete all edges but one -edge1 = deleteat!(TrixiParticles.Polygon(triangle), [2, 3]) -edge2 = deleteat!(TrixiParticles.Polygon(triangle), [1, 3]) -edge3 = deleteat!(TrixiParticles.Polygon(triangle), [1, 2]) +edge1 = delete_faces(TrixiParticles.Polygon(triangle), [2, 3]) +edge2 = delete_faces(TrixiParticles.Polygon(triangle), [1, 3]) +edge3 = delete_faces(TrixiParticles.Polygon(triangle), [1, 2]) algorithm = WindingNumberJacobson() @@ -269,6 +269,15 @@ For example: 0.0 1.0 ``` It is the userโ€™s responsibility to ensure the points are ordered correctly. +For 2D `.asc` and `.dxf` files, `load_geometry` appends the first point by default +when it is not already repeated. This is only a convenience for complete, ordered +boundaries that omit the final duplicate point; it does not repair missing +segments, gaps, self-intersections, or incorrectly ordered points. Use +`load_geometry(file; close_curve=false)` for intentional open curves. Operations +that sample or classify a region, such as [`ComplexShape`](@ref), `intersect`, +and `setdiff`, require closed geometries. Boundary packing with +[`SignedDistanceField`](@ref) also requires a closed geometry, since it needs a +well-defined outside region. This format is easy to generate and inspect manually. ## DXF Format (.dxf) โ€“ recommended @@ -308,6 +317,11 @@ Modules = [TrixiParticles] Pages = [joinpath("preprocessing", "geometries", "triangle_mesh.jl")] ``` +```@docs +TrixiParticles.is_closed_geometry +delete_faces +``` + # [Particle Packing](@id particle_packing) To obtain a body-fitted and isotropic particle distribution, an initial configuration (see [Sampling of Geometries](@ref sampling_of_geometries)) is first generated. This configuration is then packed using a [`ParticlePackingSystem`](@ref) following the steps introduced in [Neher2026](@cite). @@ -325,7 +339,7 @@ The second step involves generating the SDF (see [`SignedDistanceField`](@ref)), The SDF is illustrated in Fig. 2, where the distances to the surface of the geometry are visualized as a color map. As shown, the SDF is computed only within a narrow band around the geometryโ€™s surface, enabling a face-based neighborhood search (NHS) to be used exclusively during this step. In the third step, the initial configuration of the boundary particles is generated (orange particles in Fig. 3). -Boundary particles are created by copying the positions of SDF points located outside the geometry but within a predefined boundary thickness (see [`sample_boundary`](@ref)). +Boundary particles are created by copying the positions of SDF points located outside the geometry, starting at the offset implied by `place_on_shell` and ending at a predefined boundary thickness (see [`sample_boundary`](@ref)). In the fourth step, the initial configuration of the interior particles (green particles in Fig. 4) is generated using the hierarchical winding number approach (see [Hierarchical Winding](@ref hierarchical_winding)). After steps **1** through **4**, the initial configuration of both interior and boundary particles is obtained, as illustrated in Fig. 5. The interface of the geometry surface is not well resolved with the initial particle configuration. diff --git a/docs/src/refs.bib b/docs/src/refs.bib index c479e59210..11b15055e0 100644 --- a/docs/src/refs.bib +++ b/docs/src/refs.bib @@ -847,6 +847,17 @@ @Article{Valizadeh2015 publisher = {Elsevier BV}, } +@Article{Vergnaud2022, + author = {Vergnaud, A. and Oger, G. and Le Touz{\'e}, D. and DeLeffe, M. and Chiron, L.}, + title = {{C-CSF}: Accurate, robust and efficient surface tension and contact angle models for single-phase flows using {SPH}}, + journal = {Computer Methods in Applied Mechanics and Engineering}, + year = {2022}, + volume = {389}, + pages = {114292}, + doi = {10.1016/j.cma.2021.114292}, + publisher = {Elsevier BV}, +} + @Article{Wang2024, author = {Zhentong Wang and Bo Zhang and Oskar J. Haidn and Xiangyu Hu}, title = {A fourth-order kernel for improving numerical accuracy and stability in Eulerian SPH for fluids and total Lagrangian SPH for solids}, diff --git a/docs/src/systems/boundary.md b/docs/src/systems/boundary.md index cfab770268..414d069bef 100644 --- a/docs/src/systems/boundary.md +++ b/docs/src/systems/boundary.md @@ -25,7 +25,7 @@ dummy particles need to have a mass corresponding to the fluid's rest density, w "hydrodynamic mass", as opposed to mass corresponding to the material density of a [`TotalLagrangianSPHSystem`](@ref). -Here, `initial_density` and `hydrodynamic_mass` are vectors that contains the initial density +Here, `initial_density` and `hydrodynamic_mass` are vectors that contain the initial density and the hydrodynamic mass respectively for each boundary particle. Note that when used with [`SummationDensity`](@ref) (see below), this is only used to determine the element type and the number of boundary particles. @@ -37,14 +37,18 @@ This should be the same as for the adjacent fluid system with the largest smooth In the literature, this kind of boundary particles is referred to as "dummy particles" ([Adami et al., 2012](@cite Adami2012) and [Valizadeh & Monaghan, 2015](@cite Valizadeh2015)), -"frozen fluid particles" ([Akinci et al., 2012](@cite Akinci2012)) or "dynamic boundaries [Crespo et al., 2007](@cite Crespo2007). +"frozen fluid particles" ([Akinci et al., 2012](@cite Akinci2012)) or "dynamic boundaries" ([Crespo et al., 2007](@cite Crespo2007)). The key detail of this boundary condition and the only difference between the boundary models in these references is the way the density and pressure of boundary particles is computed. -Since boundary particles are treated like fluid particles, the force -on fluid particle ``a`` due to boundary particle ``b`` is given by +Since boundary particles are treated like fluid particles, their density and pressure enter +the pressure-acceleration operator selected by the interacting fluid system. For the +summation-density pressure operator, the pressure force on fluid particle ``a`` due to +boundary particle ``b`` is ```math -f_{ab} = m_a m_b \left( \frac{p_a}{\rho_a^2} + \frac{p_b}{\rho_b^2} \right) \nabla_{r_a} W(\Vert r_a - r_b \Vert, h). +\bm{f}_{ab}^{p} += -m_a m_b \left( \frac{p_a}{\rho_a^2} + \frac{p_b}{\rho_b^2} \right) +\nabla_{r_a} W(\Vert r_a - r_b \Vert, h). ``` The quantities to be defined here are the density ``\rho_b`` and pressure ``p_b`` of the boundary particle ``b``. @@ -99,13 +103,21 @@ where the sum is over all fluid particles, ``\rho_f`` and ``p_f`` denote the den ``` #### 2. [`BernoulliPressureExtrapolation`](@ref) -Identical to the pressure ``p_b `` calculated via [`AdamiPressureExtrapolation`](@ref), but it adds the dynamic pressure component of the Bernoulli equation: +Identical to the pressure ``p_b`` calculated via [`AdamiPressureExtrapolation`](@ref), +but with an added dynamic pressure term: ```math -p_b = \frac{\sum_f (p_f + \frac{1}{2} \, \rho_{\text{neighbor}} \left( \frac{ (\mathbf{v}_f - \mathbf{v}_{\text{body}}) \cdot (\mathbf{x}_f - \mathbf{x}_{\text{neighbor}}) }{ \left\| \mathbf{x}_f - \mathbf{x}_{\text{neighbor}} \right\| } \right)^2 \times \text{factor} +\rho_f (\bm{g} - \bm{a}_b) \cdot \bm{r}_{bf}) W(\Vert r_{bf} \Vert, h)}{\sum_f W(\Vert r_{bf} \Vert, h)} +p_b = \frac{\sum_f (p_f + p_{f,\mathrm{dyn}} + \rho_f (\bm{g} - \bm{a}_b) \cdot \bm{r}_{bf}) W(\Vert r_{bf} \Vert, h)}{\sum_f W(\Vert r_{bf} \Vert, h)}, ``` -where ``\mathbf{v}_f`` is the velocity of the fluid and ``\mathbf{v}_{\text{body}}`` is the velocity of the body. -This adjustment provides a higher boundary pressure for bodies moving with a relative velocity to the fluid to prevent penetration. -This modification is original and not derived from any literature source. +with +```math +p_{f,\mathrm{dyn}} = \frac{1}{2} \, \text{factor} \, \rho_f +\left( +\frac{(\bm{v}_b - \bm{v}_f) \cdot \bm{r}_{bf}}{\Vert \bm{r}_{bf} \Vert} +\right)^2, +``` +where ``\bm{v}_f`` is the fluid velocity and ``\bm{v}_b`` is the boundary velocity. +This implementation-specific term raises the boundary pressure based on the normal +component of the relative boundary-fluid velocity and is not taken from a literature formula. ```@docs BernoulliPressureExtrapolation @@ -123,17 +135,17 @@ reference pressure (the corresponding pressure to the reference density by the s #### 6. [`PressureMirroring`](@ref) Instead of calculating density and pressure for each boundary particle, we modify the -momentum equation, +boundary pressure used in the pressure-acceleration operator. For the summation-density +pressure operator, this corresponds to modifying the pressure force ```math -\frac{\mathrm{d}v_a}{\mathrm{d}t} = -\sum_b m_b \left( \frac{p_a}{\rho_a^2} + \frac{p_b}{\rho_b^2} \right) \nabla_a W_{ab} +\bm{F}_a^{p} = -m_a \sum_b m_b \left( \frac{p_a}{\rho_a^2} + \frac{p_b}{\rho_b^2} \right) \nabla_a W_{ab}, ``` -to replace the unknown density $\rho_b$ if $b$ is a boundary particle by the reference density -and the unknown pressure $p_b$ if $b$ is a boundary particle by the pressure $p_a$ of the -interacting fluid particle. -The momentum equation therefore becomes +to replace the unknown density ``\rho_b`` if ``b`` is a boundary particle by the reference density +and the unknown pressure ``p_b`` if ``b`` is a boundary particle by the pressure ``p_a`` of the +interacting fluid particle. The force therefore becomes ```math -\frac{\mathrm{d}v_a}{\mathrm{d}t} = -\sum_f m_f \left( \frac{p_a}{\rho_a^2} + \frac{p_f}{\rho_f^2} \right) \nabla_a W_{af} --\sum_b m_b \left( \frac{p_a}{\rho_a^2} + \frac{p_a}{\rho_0^2} \right) \nabla_a W_{ab}, +\bm{F}_a^{p} = -m_a \sum_f m_f \left( \frac{p_a}{\rho_a^2} + \frac{p_f}{\rho_f^2} \right) \nabla_a W_{af} +-m_a \sum_b m_b \left( \frac{p_a}{\rho_a^2} + \frac{p_a}{\rho_0^2} \right) \nabla_a W_{ab}, ``` where the first sum is over all fluid particles and the second over all boundary particles. @@ -169,21 +181,25 @@ condition is applied. ## Repulsive Particles Boundaries modeled as boundary particles which exert forces on the fluid particles ([Monaghan, Kajtar, 2009](@cite Monaghan2009)). -The force on fluid particle ``a`` due to boundary particle ``b`` is given by +The force on fluid particle ``a`` due to boundary particle ``b`` is ```math -f_{ab} = m_a \left(\tilde{f}_{ab} - m_b \Pi_{ab} \nabla_{r_a} W(\Vert r_a - r_b \Vert, h)\right) +\bm{f}_{ab} = m_a \left(\tilde{\bm{f}}_{ab} - m_b \Pi_{ab} +\nabla_{r_a} W(\Vert r_a - r_b \Vert, h)\right) ``` with ```math -\tilde{f}_{ab} = \frac{K}{\beta^{n-1}} \frac{r_{ab}}{\Vert r_{ab} \Vert (\Vert r_{ab} \Vert - d)} \Phi(\Vert r_{ab} \Vert, h) -\frac{2 m_b}{m_a + m_b}, +\tilde{\bm{f}}_{ab} = +\frac{K}{\beta^{n-1}} \frac{\bm{r}_{ab}} +{\Vert \bm{r}_{ab} \Vert (\Vert \bm{r}_{ab} \Vert - d)} +\Phi(\Vert \bm{r}_{ab} \Vert, h), ``` where ``m_a`` and ``m_b`` are the masses of fluid particle ``a`` and boundary particle ``b`` -respectively, ``r_{ab} = r_a - r_b`` is the difference of the coordinates of particles +respectively, ``\bm{r}_{ab} = \bm{r}_a - \bm{r}_b`` is the difference of the coordinates of particles ``a`` and ``b``, ``d`` denotes the boundary particle spacing and ``n`` denotes the number of dimensions (see [Monaghan & Kajtar, 2009](@cite Monaghan2009), Equation (3.1) and [Valizadeh & Monaghan, 2015](@cite Valizadeh2015)). -Note that the repulsive acceleration $\tilde{f}_{ab}$ does not depend on the masses of -the boundary particles. +The implemented repulsive acceleration ``\tilde{\bm{f}}_{ab}`` does not depend on the particle masses. +The denominator ``\Vert \bm{r}_{ab} \Vert - d`` is clipped from below by ``d/100`` in the +implementation to avoid the singularity at ``\Vert \bm{r}_{ab} \Vert = d``. Here, ``\Phi`` denotes the 1D Wendland C4 kernel, normalized to ``1.77`` for ``q=0`` ([Monaghan & Kajtar, 2009](@cite Monaghan2009), Section 4), with ``\Phi(r, h) = w(r/h)`` and ```math @@ -206,8 +222,8 @@ In [Monaghan & Kajtar (2009)](@cite Monaghan2009), a value of ``gD`` is used for where ``g`` is the gravitational acceleration and ``D`` is the depth of the fluid. The viscosity ``\Pi_{ab}`` is calculated according to the viscosity used in the -simulation, where the density of the boundary particle if needed is assumed to be -identical to the density of the fluid particle. +simulation. When a boundary density is needed, it is computed from the boundary +hydrodynamic mass and boundary particle spacing as ``m_b / d^n``. ### No-slip condition diff --git a/docs/src/systems/entropically_damped_sph.md b/docs/src/systems/entropically_damped_sph.md index 96acbad352..dd9dc8ccac 100644 --- a/docs/src/systems/entropically_damped_sph.md +++ b/docs/src/systems/entropically_damped_sph.md @@ -3,44 +3,57 @@ As opposed to the [weakly compressible SPH scheme](weakly_compressible_sph.md), which uses an equation of state, this scheme uses a pressure evolution equation to calculate the pressure ```math -\frac{\mathrm{d} p_a}{\mathrm{d}t} = - \rho c_s^2 \nabla \cdot v + \nu \nabla^2 p, +\frac{\mathrm{d} p_a}{\mathrm{d}t} = - \rho_a c_s^2 (\nabla \cdot v)_a + \nu_{\mathrm{EDAC}} (\nabla^2 p)_a, ``` which is derived by [Clausen (2013)](@cite Clausen2013). This equation is similar to the continuity equation (first term, see -[`ContinuityDensity`](@ref)), but also contains a pressure damping term (second term, similar to density diffusion +[`ContinuityDensity`](@ref)), but also contains a pressure damping term (second term, similar to density diffusion, see [`AbstractDensityDiffusion`](@ref TrixiParticles.AbstractDensityDiffusion)), which reduces acoustic pressure waves through an entropy-generation mechanism. -The pressure evolution is discretized with the SPH method by [Ramachandran (2019)](@cite Ramachandran2019) as following: +The pressure evolution is discretized with the SPH method by [Ramachandran (2019)](@cite Ramachandran2019) as follows: The first term is equivalent to the classical artificial compressible methods, which are commonly motivated by assuming the artificial equation of state ([`StateEquationCole`](@ref) with `exponent=1`) and is discretized as ```math -- \rho c_s^2 \nabla \cdot v = \sum_{b} m_b \frac{\rho_a}{\rho_b} c_s^2 v_{ab} \cdot \nabla_{r_a} W(\Vert r_a - r_b \Vert, h), +\left.- \rho c_s^2 \nabla \cdot v \right|_a += \sum_{b} m_b \frac{\rho_a}{\rho_b} c_s^2 v_{ab} \cdot \nabla_{r_a} W(\Vert r_a - r_b \Vert, h), ``` where ``\rho_a``, ``\rho_b``, ``r_a``, ``r_b``, denote the density and coordinates of particles ``a`` and ``b`` respectively, ``c_s`` is the speed of sound and ``v_{ab} = v_a - v_b`` is the difference in the velocity. The second term smooths the pressure through the introduction of entropy and is discretized as ```math -\nu \nabla^2 p = \frac{V_a^2 + V_b^2}{m_a} \tilde{\eta}_{ab} \frac{p_{ab}}{\Vert r_{ab}^2 \Vert + \eta h_{ab}^2} \nabla_{r_a} -W(\Vert r_a - r_b \Vert, h) \cdot r_{ab}, +\left.\nu_{\mathrm{EDAC}} \nabla^2 p \right|_a += \sum_b \frac{V_a^2 + V_b^2}{m_a}\, +\tilde{\eta}_{ab}\, +\frac{p_{ab}}{\Vert r_{ab} \Vert^2 + 0.01 h_{ab}^2}\, +\nabla_{r_a} W(\Vert r_a - r_b \Vert, h) \cdot r_{ab}, ``` -where ``V_a``, ``V_b`` denote the volume of particles ``a`` and ``b`` respectively and ``p_{ab}= p_a -p_b`` is the difference in the pressure. +where ``V_a``, ``V_b`` denote the particle volumes, ``p_{ab}= p_a - p_b``, +``r_{ab} = r_a - r_b``, and ``h_{ab} = \frac{1}{2}(h_a + h_b)``. -The viscosity parameter ``\eta_a`` for a particle ``a`` is given as +The dynamic EDAC viscosity for particle ``a`` is ```math -\eta_a = \rho_a \frac{\alpha h c_s}{8}, +\eta_a = \rho_a \nu_{\mathrm{EDAC}}, ``` -where it is found in the numerical experiments of [Ramachandran (2019)](@cite Ramachandran2019) that ``\alpha = 0.5`` +with +```math +\nu_{\mathrm{EDAC}} = \frac{\alpha h c_s}{8}, +``` +and the harmonic mean +```math +\tilde{\eta}_{ab} = \frac{2 \eta_a \eta_b}{\eta_a + \eta_b}. +``` +It is found in the numerical experiments of [Ramachandran (2019)](@cite Ramachandran2019) that ``\alpha = 0.5`` is a good choice for a wide range of Reynolds numbers (0.0125 to 10000). !!! note - > The EDAC formulation keeps the density constant and this eliminates the need for the continuity equation - > or the use of a summation density to ๏ฌnd the pressure. However, in SPH discretizations, ``m/\rho`` - > is typically used as a proxy for the particle volume. The density of the ๏ฌ‚uids can - > therefore be computed using the summation density approach. [Ramachandran2019](@cite) - + The EDAC formulation keeps the density constant and therefore eliminates the need for + the continuity equation or the use of a summation density to find the pressure. + However, in SPH discretizations, ``m/\rho`` is typically used as a proxy for the + particle volume. The density of the fluids can therefore still be computed using the + summation-density approach [Ramachandran2019](@cite). ```@autodocs Modules = [TrixiParticles] diff --git a/docs/src/systems/fluid.md b/docs/src/systems/fluid.md index c9b9860ab9..0cb0058fa5 100644 --- a/docs/src/systems/fluid.md +++ b/docs/src/systems/fluid.md @@ -55,38 +55,36 @@ by Balsara ([Balsara1995](@cite)) or Morris ([Morris1997](@cite)). ##### Mathematical Formulation -The force exerted by particle ``b`` on particle ``a`` due to artificial viscosity is given by: +The acceleration contribution from particle ``b`` to particle ``a`` is ```math -F_{ab}^{\text{AV}} = - m_a m_b \Pi_{ab} \nabla W_{ab} +\left.\frac{\mathrm{d}\bm{v}_a}{\mathrm{d}t}\right|_{ab}^{\text{AV}} = +\begin{cases} + m_b \frac{\alpha c \mu_{ab} + \beta \mu_{ab}^2}{\bar{\rho}_{ab}} + \nabla_a W_{ab}, & \text{if } \bm{v}_{ab} \cdot \bm{r}_{ab} < 0, \\ + 0, & \text{otherwise}. +\end{cases} ``` where: -- ``\Pi_{ab}`` is the artificial viscosity term defined as: - ```math - \Pi_{ab} = - \begin{cases} - -\frac{\alpha c \mu_{ab} + \beta \mu_{ab}^2}{\bar{\rho}_{ab}} & \text{if } \mathbf{v}_{ab} \cdot \mathbf{r}_{ab} < 0, \\ - 0 & \text{otherwise} - \end{cases} - ``` - ``\alpha`` and ``\beta`` are viscosity parameters, - ``c`` is the local speed of sound, - ``\bar{\rho}_{ab}`` is the arithmetic mean of the densities of particles ``a`` and ``b``. -The term ``\mu_{ab}`` is defined as: +The term ``\mu_{ab}`` is defined as ```math -\mu_{ab} = \frac{h \, v_{ab} \cdot r_{ab}}{\Vert r_{ab} \Vert^2 + \epsilon h^2}, +\mu_{ab} = \frac{h \, \bm{v}_{ab} \cdot \bm{r}_{ab}} + {\Vert \bm{r}_{ab} \Vert^2 + \epsilon h^2}, ``` with: - ``h`` being the smoothing length, - ``\epsilon`` a small parameter to prevent singularities, -- ``r_{ab} = r_a - r_b`` representing the difference of the coordinate vectors, -- ``v_{ab} = v_a - v_b`` representing the relative velocity between particles. +- ``\bm{r}_{ab} = \bm{r}_a - \bm{r}_b`` representing the difference of the coordinate vectors, +- ``\bm{v}_{ab} = \bm{v}_a - \bm{v}_b`` representing the relative velocity between particles. ##### Resolution Dependency and Effective Viscosity @@ -109,19 +107,21 @@ This results in a more realistic representation of flow dynamics in weakly compr ##### Mathematical Formulation -An additional force term ``\tilde{f}_{ab}`` is introduced to the pressure gradient force ``f_{ab}`` between particles ``a`` and ``b``: +An additional force term ``\tilde{\bm{F}}_{ab}`` is introduced in the momentum equation: ```math -\tilde{f}_{ab} = m_a m_b \frac{(\mu_a + \mu_b)\, r_{ab} \cdot \nabla W_{ab}}{\rho_a \rho_b (\Vert r_{ab} \Vert^2 + \epsilon h^2)}\, v_{ab}, +\tilde{\bm{F}}_{ab} = +m_a m_b \frac{(\mu_a + \mu_b)\, \bm{r}_{ab} \cdot \nabla_a W_{ab}} +{\rho_a \rho_b (\Vert \bm{r}_{ab} \Vert^2 + \epsilon h^2)}\, \bm{v}_{ab}, ``` where: -- ``\mu_a = \rho_a \nu`` and ``\mu_b = \rho_b \nu`` represent the dynamic viscosities of particles ``a``and ``b`` (with ``\nu`` being the kinematic viscosity), -- ``r_{ab} = r_a - r_b`` represents the difference of the coordinate vectors, -- ``v_{ab} = v_a - v_b`` represents the relative velocity between particles. +- ``\mu_a = \rho_a \nu`` and ``\mu_b = \rho_b \nu`` represent the dynamic viscosities of particles ``a`` and ``b`` (with ``\nu`` being the kinematic viscosity), +- ``\bm{r}_{ab} = \bm{r}_a - \bm{r}_b`` represents the difference of the coordinate vectors, +- ``\bm{v}_{ab} = \bm{v}_a - \bm{v}_b`` represents the relative velocity between particles, - `` h `` is the smoothing length, -- `` \nabla W_{ab} `` is the gradient of the smoothing kernel, +- `` \nabla_a W_{ab} `` is the gradient of the smoothing kernel, - `` \epsilon `` is a small parameter to prevent singularities. #### ViscosityAdami @@ -132,19 +132,24 @@ while minimizing compressibility effects. This results in accurate laminar flow ##### Mathematical Formulation -The viscous interaction is modeled through a shear force for incompressible flows: +The viscous interaction is modeled through the following pairwise force: ```math -f_{ab} = \sum_w \bar{\eta}_{ab} \left( V_a^2 + V_b^2 \right) \frac{v_{ab}}{||r_{ab}||^2 + \epsilon h_{ab}^2} \, (\nabla W_{ab} \cdot r_{ab}), +\bm{F}_{ab}^{\nu} = +\left( V_a^2 + V_b^2 \right)\, +\bar{\eta}_{ab}\, +\frac{\nabla_a W_{ab} \cdot \bm{r}_{ab}} +{\Vert \bm{r}_{ab} \Vert^2 + \epsilon h_{ab}^2}\, +\bm{v}_{ab}. ``` where: -- `` r_{ab} = r_a - r_b `` is the difference of the coordinate vectors, -- `` v_{ab} = v_a - v_b `` is their relative velocity, +- `` \bm{r}_{ab} = \bm{r}_a - \bm{r}_b `` is the difference of the coordinate vectors, +- `` \bm{v}_{ab} = \bm{v}_a - \bm{v}_b `` is their relative velocity, - `` V_a = m_a / \rho_a`` and `` V_b = m_b / \rho_b`` are the particle volumes, -- `` h_{ab} `` is the smoothing length, -- `` \nabla W_{ab} `` is the gradient of the smoothing kernel, +- `` h_{ab} = \frac{1}{2}(h_a + h_b) `` is the arithmetic mean of the smoothing lengths, +- `` \nabla_a W_{ab} `` is the gradient of the smoothing kernel, - `` \epsilon `` is a small parameter that prevents singularities (see [Ramachandran (2019)](@cite Ramachandran2019)). The inter-particle-averaged shear stress is defined as: @@ -215,9 +220,9 @@ Pages = [joinpath("general", "corrections.jl")] ### Overview of surface normal calculation in SPH -Surface normals are essential for modeling surface tension as they provide the directionality -of forces acting at the fluid interface. They are calculated based on the particle properties and -their spatial distribution. +Surface normals provide the directionality of forces acting at the fluid interface. They are +used by the full Akinci model and both Morris models, but not by the cohesion-only Akinci model. +They are calculated based on the particle properties and their spatial distribution. #### Color field and gradient-based surface normals @@ -225,10 +230,10 @@ The surface normal at a particle is derived from the color field, a scalar field to distinguish between different fluid phases or between fluid and air. The color field gradients point towards the interface, and the normalized gradient defines the surface normal direction. -The simplest SPH formulation for a surface normal, ``n_a`` is given as +The unscaled colorfield gradient ``\bm{g}_a`` is ```math -n_a = \sum_b m_b \frac{c_b}{\rho_b} \nabla_a W_{ab}, +\bm{g}_a = \sum_b m_b \frac{c_b}{\rho_b} \nabla_a W_{ab}, ``` where: @@ -238,15 +243,36 @@ where: - ``\rho_b`` is the density of particle ``b``, - ``\nabla_a W_{ab}`` is the gradient of the smoothing kernel ``W_{ab}`` with respect to particle ``a``. -#### Normalization of surface normals +For single-fluid surface-normal calculations, ``c_b = 1`` for neighboring fluid particles. + +#### Model-specific scaling -The calculated normals are normalized to unit vectors: +The Morris models normalize the colorfield gradient: ```math -\hat{n}_a = \frac{n_a}{\Vert n_a \Vert}. +\hat{\bm{n}}_a = \frac{\bm{g}_a}{\Vert \bm{g}_a \Vert}. ``` -Normalization ensures that the magnitude of the normals does not bias the curvature calculations or the resulting surface tension forces. +The Akinci model instead uses the dimensionless normal from Equation 2 of +[Akinci et al. (2013)](@cite Akinci2013): + +```math +\bm{n}^{A}_a = h_c \bm{g}_a, +``` + +where ``h_c`` is the compact-support radius. TrixiParticles.jl stores ``\bm{g}_a`` so the same +cache can serve both model families and applies the model-specific normalization or scaling when +the force is evaluated. + +!!! note "Kernel correction and Akinci normals" + A normalized [`KernelCorrection`](@ref) gradient must not be substituted directly into the + Akinci colorfield sum. A correction constructed over the same neighbor set enforces partition + of unity and therefore ``\sum_b V_b \nabla \widetilde{W}_{ab}=0`` for a constant color field, + including at a free surface. The Akinci normal intentionally uses the nonzero + neighborhood-deficiency signal of + the uncorrected kernel gradient. First-order gradient correction and normal smoothing can + alter the direction, but also alter or redistribute the normal magnitude used by the Akinci + curvature force; they are not enabled implicitly. #### Handling noise and errors in normal calculation @@ -256,6 +282,27 @@ smoothing kernel's support radius. To mitigate this: 1. Normals below a threshold are excluded from further calculations. 2. Curvature calculations use a corrected formulation to reduce errors near interface fringes. +#### Extensions beyond the published formulation + +The published normal formulations, including Equation 2 of +[Akinci et al. (2013)](@cite Akinci2013), sum over fluid neighbors only. This is also the +default for `SurfaceTensionAkinci`. TrixiParticles.jl provides two optional or practical +extensions to the colorfield gradient: + +1. **Wall-contact augmentation.** When the smoothed colorfield of a boundary particle exceeds + the `boundary_contact_threshold` of [`ColorfieldSurfaceNormal`](@ref) relative to the maximum + boundary colorfield, boundary neighbors also contribute to the gradient. The contribution + reuses the fluid particle's own mass and density as a proxy for the missing fluid continuum. + Fluid particles resting on a wetted wall that continues the fluid lattice are thereby treated + like interior particles with near-zero normals, which suppresses spurious curvature forces + along walls. Fluid that is not in contact with the wall keeps its free-surface normal. + Set a finite `boundary_contact_threshold` explicitly to enable this extension for the Akinci + model. +2. **Neighbor-count filter.** Normals of particles with fewer than ``2^d + 1`` neighbors in + ``d`` dimensions are set to zero to remove noisy, underdefined gradients. For isolated + droplets or spray, this disables only the curvature contribution of the Akinci model; + the pairwise cohesion force remains active. + ```@autodocs Modules = [TrixiParticles] Pages = [joinpath("schemes", "fluid", "surface_normal_sph.jl")] @@ -269,7 +316,7 @@ Surface tension is a key phenomenon in fluid dynamics, influencing the behavior In SPH, surface tension is modeled as forces arising due to surface curvature and relative particle movement, ensuring realistic simulation of capillary effects, droplet coalescence, and fragmentation. -The surface tension coefficient ``\sigma`` is a physical parameter that quantifies the energy required to increase the surface area +The physical surface tension ``\sigma`` quantifies the energy required to increase the surface area of a fluid by a unit amount. A higher value of ``\sigma`` indicates that the fluid resists changes to its surface area more strongly, causing droplets or bubbles to assume shapes (often spherical) that minimize their surface. In practice, ``\sigma`` can be measured experimentally through techniques such as the pendant drop method, the Wilhelmy plate method, or the du Noรผy ring method, @@ -289,13 +336,81 @@ In the following table some values are shown for reference. The values marked wi | **Water** | 0.07288 [Lange](@cite Lange2005) | | **Mercury** | 0.486502 [Lange](@cite Lange2005) | +### Model configuration + +All surface tension coefficients must be finite and non-negative. A zero coefficient disables +the fluid-fluid surface force. `SurfaceTensionMorris`, `SurfaceTensionMomentumMorris`, and +`SurfaceTensionAkinciCohesionPhysical` accept physical values in N/m. The coefficients of +`CohesionForceAkinci` and the complete `SurfaceTensionAkinci` model retain the numerical meaning +defined in the original Akinci formulation. Wall adhesion is controlled independently by the +boundary's `adhesion_coefficient`. + +`CohesionForceAkinci` and `SurfaceTensionAkinciCohesionPhysical` only evaluate pairwise cohesion +and optional wall forces. They do not require surface normals or `reference_particle_spacing`. +The complete `SurfaceTensionAkinci` model and both Morris models require a surface-normal method. +The Morris models default to `ColorfieldSurfaceNormal()`. The complete Akinci model defaults to +`ColorfieldSurfaceNormal(boundary_contact_threshold=Inf)` so that its normal follows the +published fluid-only sum; wall adhesion remains a separate pair force. + +`ColorfieldSurfaceNormal(normal_smoothing=true)` applies one activity-weighted Shepard pass to the +unit-normal directions before Morris curvature or CSS stress evaluation. The interface activity and +surface delta remain those of the unsmoothed color gradient. Particle shifting also continues to use +the raw normal, so enabling capillary smoothing does not silently change the regularization model. +This is an explicit stabilization choice rather than a default. + +Three-dimensional counterparts of every simulation experiment in +[Akinci et al. (2013)](@cite Akinci2013) are provided. They use reduced particle counts and, +except for the reported dimensions in the water-crown setup, reduced dimensions. They are +demonstrations rather than quantitative reproductions of the rendered reference scenes. + +| Paper experiment | Runnable example | +|:-----------------|:-----------------| +| Figures 1 and 5, water crown | `examples/fluid/akinci_water_crown_3d.jl` | +| Figure 2, cube-to-sphere comparison | `examples/fluid/akinci_cube_to_sphere_3d.jl` | +| Figure 6, droplet impact on a plate | `examples/fluid/akinci_droplet_on_plate_3d.jl` | +| Figure 7, stream flowing over a sphere | `examples/fluid/akinci_stream_over_sphere_3d.jl` | +| Figure 8, wetting regimes | `examples/fluid/akinci_wetting_3d.jl` | +| Figure 9, splitting in an adhesive box | `examples/fluid/akinci_droplet_splitting_3d.jl` | +| Figure 10, rolling drop and two-way adhesion | `examples/fluid/akinci_rolling_droplet_3d.jl` | + +Set `wetting_case` in the wetting example to `"no_wetting"`, `"weak_wetting"`, +`"moderate_wetting"`, `"intermediate_wetting"`, `"strong_wetting"`, +`"near_perfect_wetting"`, or `"perfect_wetting"`. These presets follow the coefficient sequence +shown in the paper's companion video. +The rolling-drop example uses rigid figure-shaped bodies in place of the paper's articulated +ragdolls while preserving the adhesive/non-adhesive comparison. + +!!! note "Akinci kernels in two dimensions" + Akinci et al. published the cohesion and adhesion kernels for three dimensions. In two + dimensions, TrixiParticles.jl uses an integral-matching extension: each radial 2D kernel + has the same full-space integral as its published 3D counterpart. This convention is not + part of the original model, but gives both kernels dimensions of ``L^{-d}`` in ``d`` + dimensions. Their products with particle mass are therefore independent of resolution at + a fixed smoothing-length-to-spacing ratio. Akinci surface tension is supported in two and + three dimensions only. + + To preserve the pairwise cohesion and adhesion contributions from a previous 2D + configuration that used the 3D normalizations, scale the coefficients at its + compact-support radius ``h_c`` as + + ```math + \sigma_{\mathrm{new}} = \frac{627}{790h_c}\sigma_{\mathrm{old}}, \qquad + \beta_{\mathrm{new}} = \frac{42}{65h_c}\beta_{\mathrm{old}}. + ``` + + The migrated coefficients can then be held fixed when changing the resolution. Since + `SurfaceTensionAkinci` uses ``\sigma`` for both cohesion and the unchanged curvature term, + this migration also changes their relative weight; full-model configurations may require + additional calibration. + ### [Akinci-based intra-particle force surface tension and wall adhesion model](@id akinci_ipf) -The [Akinci](@cite Akinci2013) model divides surface tension into distinct force components: +The [Akinci](@cite Akinci2013) model divides surface tension into distinct force components, +which TrixiParticles.jl applies as acceleration contributions. -#### Cohesion force +#### Cohesion contribution -The cohesion force captures the attraction between particles at the fluid interface, creating the effect of surface tension. +The cohesion contribution captures the attraction between particles at the fluid interface, creating the effect of surface tension. It is defined by the distance between particles and the support radius ``h_c``, using a kernel-based formulation. **Key features:** @@ -303,53 +418,166 @@ It is defined by the distance between particles and the support radius ``h_c``, - Particles within half the support radius experience a repulsive force to prevent clustering. - Particles beyond half the radius but within the support radius experience an attractive force to simulate cohesion. -Mathematically: +In the acceleration form used by TrixiParticles.jl, the pairwise cohesion contribution is ```math -F_{\text{cohesion}} = -\sigma m_b C(r) \frac{r}{\Vert r \Vert}, +\left.\frac{\mathrm{d}\bm{v}_a}{\mathrm{d} t}\right|_{ab}^{\text{cohesion}} += -\sigma m_b C_d(r) \frac{\bm{r}}{\Vert \bm{r} \Vert}, ``` -where ``C(r)``, the cohesion kernel, is defined as: +where the dimension-dependent cohesion kernel is ```math -C(r)=\frac{32}{\pi h_c^9} +C_d(r)=\frac{K_d}{h_c^{d+6}} \begin{cases} (h_c-r)^3 r^3, & \text{if } 2r > h_c, \\ -2(h_c-r)^3 r^3 - \frac{h^6}{64}, & \text{if } r > 0 \text{ and } 2r \leq h_c, \\ +2(h_c-r)^3 r^3 - \frac{h_c^6}{64}, & \text{if } r > 0 \text{ and } 2r \leq h_c, \\ 0, & \text{otherwise.} \end{cases} +\qquad +K_2=\frac{25280}{627\pi}, \quad K_3=\frac{32}{\pi}. +``` + +The 3D constant is the published normalization. The 2D constant is chosen such that + +```math +\int_{\mathbb{R}^2} C_2(\Vert\bm{r}\Vert)\,\mathrm{d}A += \int_{\mathbb{R}^3} C_3(\Vert\bm{r}\Vert)\,\mathrm{d}V += \frac{79}{336}. ``` -#### Surface area minimization force +#### Physical cohesion-only normalization -The surface area minimization force models the curvature reduction effects, aligning particle motion to reduce the interface's total area. -It acts based on the difference in surface normals: +[`SurfaceTensionAkinciCohesionPhysical`](@ref) converts a physical surface tension ``\sigma`` +in N/m to the coefficient of the three-dimensional cohesion kernel. For a planar interface, ```math -F_{\text{curvature}} = -\sigma (n_a - n_b), +\int_0^{h_c} r^4 C_3(r)\,\mathrm{d}r = \frac{21h_c^2}{880\pi}, +\qquad +\sigma = \frac{\pi}{8}\gamma\rho_0^2 + \int_0^{h_c} r^4 C_3(r)\,\mathrm{d}r + = \frac{21}{7040}\gamma\rho_0^2h_c^2. ``` -where ``n_a`` and ``n_b`` are the surface normals of the interacting particles. +The internal coefficient is therefore + +```math +\gamma(h_c) = \frac{7040\sigma}{21\rho_0^2h_c^2}. +``` + +This support-radius scaling removes the resolution dependence of the continuum surface energy. +The model uses only central pair forces, needs no colorfield normals, and exactly conserves +pairwise linear and angular momentum. It is restricted to three dimensions because the physical +surface-energy conversion above is three-dimensional. Use [`CohesionForceAkinci`](@ref) for an +empirical coefficient or a two-dimensional simulation. + +For this model, a wall's `adhesion_coefficient` multiplies the same normalized cohesion kernel. +The Young-Dupre work-of-adhesion relation gives + +```math +\texttt{adhesion_coefficient} = \frac{1 + \cos\theta}{2}. +``` + +Thus, ratios `0`, `0.5`, and `1` target contact angles of 180, 90, and 0 degrees, respectively. +The ratio belongs to the boundary, so different walls can use different wetting properties. + +Automatic time-step selection applies the capillary condition + +```math +\Delta t_\sigma = \sqrt{\frac{\rho_0 h^3}{2\pi\sigma}}, +``` + +where ``h`` is the smoothing length. If [`AkinciFreeSurfaceCorrection`](@ref) is also configured, +its local density factor multiplies the pair force. In that case, ``\sigma`` still normalizes the +underlying continuum cohesion potential, while the corrected coarse-resolution force need not +equal an independently fitted Laplace-pressure coefficient. + +#### Surface area minimization contribution + +The surface area minimization contribution models curvature reduction and acts on the +difference in the dimensionless Akinci normals: +```math +\left.\frac{\mathrm{d}\bm{v}_a}{\mathrm{d} t}\right|_{ab}^{\text{curvature}} += -\sigma (\bm{n}^{A}_a - \bm{n}^{A}_b) += -\sigma h_c (\bm{g}_a - \bm{g}_b), +``` +for equal compact-support radii. Here ``h_c`` is the compact-support radius used in the Akinci +paper, not the smaller smoothing-length parameter accepted by kernels whose support spans a +multiple of that parameter. + +!!! warning "Constant smoothing length" + The pairwise force evaluates the support radius and both normals with the smoothing length + of the first particle of the pair. With a per-particle (variable) smoothing length, the + pair force would lose its antisymmetry and no longer conserve momentum. Use a constant + smoothing length with `SurfaceTensionAkinci`. + +#### Combined-force correction + +To compensate for particle-neighborhood deficiency at a free surface, the cohesion and +curvature contributions are multiplied by the symmetric factor + +```math +K_{ab} = \frac{2\rho_0}{\rho_a + \rho_b}. +``` + +[`AkinciFreeSurfaceCorrection`](@ref) implements this factor for the combined fluid-fluid +surface tension force. Section 4 of [Akinci et al. (2013)](@cite Akinci2013) also applies the +factor to viscosity for the same particle-deficiency reason. It does not modify pressure or wall +adhesion forces. + +The published correction assumes that the density estimate reflects missing neighbors. With +[`SummationDensity`](@ref), ``\rho_a`` and ``\rho_b`` in ``K_{ab}`` are the current densities. +For [`ContinuityDensity`](@ref) in a [`WeaklyCompressibleSPHSystem`](@ref), TrixiParticles.jl +reconstructs the auxiliary densities + +```math +\widetilde{\rho}_a = \sum_b m_b W_{ab} +``` -#### Wall adhesion force +and uses ``\widetilde{\rho}_a`` and ``\widetilde{\rho}_b`` only in ``K_{ab}``. Pressure and all +other density-dependent terms continue to use the integrated continuity density. The auxiliary +sum includes dummy boundary particles, so a wall that completes the particle neighborhood is not +misclassified as a free surface. This extension makes the correction independent of the selected +density calculator at the cost of one additional density-summation neighbor loop per update stage. -This force models the interaction between fluid and solid boundaries, simulating adhesion effects at walls. +#### Wall adhesion contribution + +This contribution models the interaction between fluid and solid boundaries, simulating adhesion effects at walls. It uses a custom kernel with a peak at 0.75 times the support radius: ```math -F_{\text{adhesion}} = -\beta m_b A(r) \frac{r}{\Vert r \Vert}, +\left.\frac{\mathrm{d}\bm{v}_a}{\mathrm{d} t}\right|_{ab}^{\text{adhesion}} += -\beta m_b A_d(r) \frac{\bm{r}}{\Vert \bm{r} \Vert}, ``` -where ``A(r)`` is the adhesion kernel: +where the dimension-dependent adhesion kernel is ```math -A(r) = \frac{0.007}{h_c^{3.25}} +A_d(r) = \frac{b_d}{h_c^{d+1/4}} \begin{cases} \sqrt[4]{-\frac{4r^2}{h_c} + 6r - 2h_c}, & \text{if } 2r > h_c \text{ and } r \leq h_c, \\ 0, & \text{otherwise.} \end{cases} +\qquad +b_2=\frac{13}{1200}, \quad b_3=0.007. +``` + +Again, ``b_3`` is the published value and ``b_2`` matches the full-space integrals. In terms +of the dimensionless radial moments + +```math +J_d = \int_{1/2}^{1} q^{d-1}\left[2(1-q)(2q-1)\right]^{1/4}\,\mathrm{d}q, ``` +the beta-function identities + +```math +J_2 = \frac{3}{8}B\!\left(\frac{5}{4},\frac{5}{4}\right), \qquad +J_3 = \frac{65}{224}B\!\left(\frac{5}{4},\frac{5}{4}\right) +``` + +give ``J_3/J_2=65/84`` and thus ``b_2=2b_3J_3/J_2=13/1200``. + --- ### [Morris surface tension model](@id morris_csf) @@ -358,55 +586,201 @@ The method described by [Morris](@cite Morris2000) estimates curvature by combin The computed curvature is then used to determine forces acting perpendicular to the interface. While this method provides accurate surface tension forces, it does not explicitly conserve momentum. -In the Morris model, surface tension is computed based on local interface curvature ``\kappa`` and the unit surface normal ``\hat{n}.`` -By estimating ``\hat{n}`` and ``\kappa`` at each particle near the interface, the surface tension force for particle a can be written as: +In the Morris model, surface tension is computed from local interface curvature ``\kappa``, the +unit surface normal ``\hat{\bm{n}}``, and the surface delta ``\delta_s``. The acceleration is a +particle-local source evaluated once per RHS: ```math -F_{\text{surface tension}} = - \sigma \frac{\kappa_a}{\rho_a}\hat{n}_a +\frac{\mathrm d\bm v_a}{\mathrm dt}\bigg|_\sigma += -\frac{\sigma}{\rho_a}\kappa_a\delta_{s,a}\hat{\bm n}_a. ``` -This formulation focuses directly on geometric properties of the interface, making it relatively straightforward to implement when a reliable interface detection -(e.g., a color function) is available. However, accurately estimating ``\kappa`` and ``n`` may require fine resolutions. +The factors have dimensions ``[\sigma]=kg/s^2``, ``[\kappa]=1/m``, +``[\delta_s]=1/m``, and ``[\rho]=kg/m^3``, giving acceleration in ``m/s^2``. Curvature uses +activity-weighted neighboring unit normals, so particles enter and leave the interface stencil +continuously. This formulation focuses directly on interface geometry but does not explicitly +conserve momentum; accurately estimating curvature still requires adequate resolution. + +[`CorrectedCSFSurfaceNormal`](@ref) selects the corrected continuous-surface-force (C-CSF) +interface geometry of [Vergnaud et al.](@cite Vergnaud2022) for [`SurfaceTensionMorris`](@ref). It +computes the outward normal from the renormalized gradient of the minimum eigenvalue of the +first-order kernel moment. Curvature uses a renormalized divergence and the published thin-jet +angular filter; the surface delta uses the published Shepard correction. + +```julia +surface_tension = SurfaceTensionMorris(surface_tension_coefficient=0.072) +surface_normal_method = CorrectedCSFSurfaceNormal() +``` + +This explicit opt-in supports one fluid system. Setting a finite contact angle enables the planar +boundary-integral moment, eigenvalue-gradient, color-gradient, and curvature terms together with the +distance-weighted contact-normal correction of Vergnaud et al.: + +```julia +surface_normal_method = CorrectedCSFSurfaceNormal(contact_angle=90.0) +``` + +Every participating wall must use dummy particles with explicit positive surface quadrature weights +on its physical face (`surface_measure` in `BoundaryModelDummyParticles`) and normal offset vectors +in its `InitialCondition`. The analytical half-space overlap and boundary terms currently require a +3D `WendlandC2Kernel`. General curved BIM faces and ghost-particle C-CSF geometry are not implemented. +These boundary integrals correct C-CSF interface geometry only; continuity, pressure, and viscosity +at the wall still use the selected dummy-particle boundary model rather than the hydrodynamic BIM +equations of Vergnaud et al. --- ### [Morris-based momentum-conserving surface tension model](@id moriss_css) -In addition to the simpler curvature-based formulation, [Morris](@cite Morris2000) introduced a momentum-conserving approach. -This method treats surface tension forces as arising from the divergence of a stress tensor, ensuring exact conservation -of linear momentum and offering more robust behavior for high-resolution or long-duration simulations -where accumulated numerical error can be significant. +[`SurfaceTensionMomentumMorris`](@ref) implements a balanced continuum-surface-stress (CSS) +formulation for one-phase free surfaces. It treats surface tension as the divergence of a +localized tangential stress, avoiding the explicit curvature pass required by +[`SurfaceTensionMorris`](@ref). #### Stress tensor formulation -The surface tension force can be seen as a divergence of a stress tensor ``S`` +The surface tension force can be written as the divergence of a stress tensor ``\bm{S}``: ```math -F_{\text{surface tension}} = \nabla \cdot S, +\bm{F}_{a}^{\sigma} = m_a \nabla \cdot \bm{S}, ``` -with ``S`` defined as +with ```math -S = \sigma \delta_s (I - \hat{n} \otimes \hat{n}), +\bm{S} = \sigma \delta_s (I - \hat{\bm{n}} \otimes \hat{\bm{n}}). ``` with: - ``\delta_s``: Surface delta function, -- ``\hat{n}``: Unit normal vector, +- ``\hat{\bm{n}}``: Unit normal vector, - ``I``: Identity matrix. -This divergence can be computed numerically in the SPH framework as +For a free surface represented only by fluid particles, the raw color gradient ``\bm{g}_a`` is +sampled over one half of the kernel-smoothed interface. TrixiParticles.jl therefore stores + +```math +\delta_{s,a} = 2\Vert\bm{g}_a\Vert\lambda_a, +\qquad +\hat{\bm{n}}_a = \frac{\bm{g}_a}{\Vert\bm{g}_a\Vert}. +``` + +The factor two makes the represented half-interface delta integrate to one. During the same +neighbor pass, the scalar consistency measure + +```math +q_a = -\frac{1}{d}\sum_b \frac{m_b}{\rho_b} + \bm{r}_{ab}\mathbin{\cdot}\nabla_a W_{ab} +``` + +is accumulated. The symmetric pair correction ``c_{ab}=2/(q_a+q_b)`` restores the linear +kernel-gradient scaling near truncated support while retaining an antisymmetric pair force. The +acceleration is evaluated directly, without constructing ``\bm{S}`` in memory: ```math -\sum_b \frac{m_b}{\rho_a \rho_b} (S_a + S_b) \nabla W_{ab} +\frac{\mathrm{d}\bm{v}_a}{\mathrm{d}t}\bigg|_\sigma += \sigma\sum_b\frac{m_b c_{ab}}{\rho_a\rho_b} +\left[ +\delta_{s,a}(I-\hat{\bm{n}}_a\otimes\hat{\bm{n}}_a) ++\delta_{s,b}(I-\hat{\bm{n}}_b\otimes\hat{\bm{n}}_b) +\right]\nabla_a W_{ab}. ``` -#### Advantages and limitations +For constant smoothing length, the coefficient multiplying each pair is symmetric, so the model +conserves linear momentum to roundoff. The Akinci free-surface correction is not applied to this +continuum stress. + +#### Smooth interface activity + +Morris CSF and CSS use the same C1 interface activity ``\lambda_a``. Let +``\gamma_a=h_c\Vert\bm g_a\Vert``, ``\epsilon_n`` be `interface_threshold`, and +``\alpha`` be `interface_taper_start` (default `0.8`). With + +```math +S(x)=\begin{cases} +0,&x\le0,\\ +3x^2-2x^3,&0 0`, activity remains one through ``q_a=\tau`` and tapers +to zero over `support_taper_width` (default `0.025`): + +```math +\lambda_{q,a}=1-S\!\left(\frac{q_a-\tau}{\Delta q}\right), +\qquad +\lambda_a=\lambda_{g,a}\lambda_{q,a}. +``` + +Setting `ideal_density_threshold=0` disables support filtering. This keyword previously compared +an integer neighbor count with an ideal count; it now represents a continuous fraction of complete +kernel support. Existing validation configurations using `0.9` migrate to `0.95`. Dummy boundary +particles complete ``q_a`` near walls, suppressing false wall-bulk interfaces without carrying +capillary stress themselves. + +#### Wetted-area contact angle + +Young's wall energy can be enabled explicitly through the normal method: + +```julia +normal_method = ColorfieldSurfaceNormal( + contact_model=WettedAreaContactAngle(60.0)) +surface_tension = SurfaceTensionMomentumMorris(surface_tension_coefficient=0.072) +``` -While momentum conservation makes this model attractive, it requires additional computational effort and stabilization -techniques to address instabilities in high-density regions. +The model discretizes ``E_w=-\sigma\cos(\theta)A_w`` from the fluid colorfield sampled on the +physical boundary surface. Its complete derivative contains an explicit fluid-wall kernel term and +a symmetric density-conjugate term consistent with `ContinuityDensity`. Fixed walls cache the +equal-and-opposite reaction; rigid-body reactions also enter `force_per_particle`, preserving force +and torque transfer. + +Each dummy boundary model must receive a nonnegative `surface_measure` vector. Positive entries are +the physical surface quadrature areas and zero entries mark deeper dummy-particle layers. The same +particles require `InitialCondition.normals`; the magnitude of each normal is the offset from the +dummy particle to the represented physical wall. Surface measures make the quadrature independent +of global orientation and can represent curved, prescribed-motion, and rigid surfaces. + +The validated initial configuration is intentionally strict: 3D WCSPH or EDAC, +`ContinuityDensity`, `SurfaceTensionMomentumMorris`, `WendlandC2Kernel{3}`, `h/dx=1.4`, one fluid, +and one or more dummy-particle wall or rigid-body systems. Each boundary must contain one connected, +disk-like wetted patch. Targets must lie strictly inside `(0, 180)` degrees; `90` degrees produces +exactly zero wall energy, force, and reaction. Unsupported dimensions, kernels, ratios, density +formulations, colors, or disconnected patches raise an `ArgumentError`. Constructing +`ColorfieldSurfaceNormal()` without a contact model remains the no-wetting default. + +#### Performance and limitations + +The model needs one color-gradient neighbor pass. The stress divergence and wetted-area density +force are fused into the existing fluid interaction, while the explicit derivative is fused into +the fluid-boundary interaction. Per particle, the base CSS model caches the normal, surface delta, +interface activity, and one scalar correction; the full tensor is reconstructed only for VTK +output. Wetted-area fluid caches hold the density conjugate and aggregate energy/area diagnostics. +Boundary caches hold the immutable quadrature, transient area weights, and thread-local reaction +reduction storage. This is cheaper and less noisy than explicit-curvature CSF but more expensive +than empirical cohesion forces. Particle regularization or sufficient physical viscosity is +recommended for long simulations because tangential surface stress can expose the usual SPH +tensile instability. Supported Morris/CSS free-surface simulations can opt into +[`InterfaceAwareTensileInstabilityControl`](@ref), which applies TIC in the fluid interior and +blends back to conservative pressure at the interface. + +The formulation currently targets one-phase free surfaces, not resolved two-phase interfaces. A +constant smoothing length is required for exact pairwise momentum conservation. Coarse droplets +with only a few support radii across the diameter can overpredict the dynamic Laplace pressure; +the error decreases under particle refinement. ### API diff --git a/docs/src/systems/implicit_incompressible_sph.md b/docs/src/systems/implicit_incompressible_sph.md index 0927aa8821..deccbfe4d4 100644 --- a/docs/src/systems/implicit_incompressible_sph.md +++ b/docs/src/systems/implicit_incompressible_sph.md @@ -28,14 +28,15 @@ difference yields The divergence in the right-hand side is discretized with the SPH discretization for particle ``i`` as ```math --\frac{1}{\rho_i} \sum_j m_j \bm{v}_{ij} \nabla W_{ij}, +-\frac{1}{\rho_i} \sum_j m_j \bm{v}_{ij} \cdot \nabla W_{ij}, ``` where ``\bm{v}_{ij} = \bm{v}_i - \bm{v}_j``. Together, the following discretized version of the continuity equation for a particle ``i`` is achieved: ```math -\frac{\rho_i(t + \Delta t) - \rho_i(t)}{\Delta t} = \sum_j m_j \bm{v}_{ij}(t+\Delta t) \nabla W_{ij}. +\frac{\rho_i(t + \Delta t) - \rho_i(t)}{\Delta t} += \sum_j m_j \bm{v}_{ij}(t+\Delta t) \cdot \nabla W_{ij}. ``` Note that the linear system is only solved for fluid particles, so ``i`` always represents @@ -50,12 +51,12 @@ Using the semi-implicit Euler method, we can obtain the velocity in the next tim ``` where ``\bm{F}_i^{\text{adv}}`` denotes all non-pressure forces such as gravity, viscosity, surface -tension and more, while ``\bm{F}_i^p``denotes the unknown pressure forces, which we +tension and more, while ``\bm{F}_i^p`` denotes the unknown pressure forces, which we want to solve for. Note that the IISPH is an incompressible method, which means that the density of the -fluid remain constant over time. By assuming a fixed reference density ``\rho_0`` for all -fluid particle over the whole time of the simulation, the density value at the next time +fluid remains constant over time. By assuming a fixed reference density ``\rho_0`` for all +fluid particles over the whole simulation, the density value at the next time step ``\rho_i(t + \Delta t)`` also has to be this rest density. So ``\rho_0`` can be plugged in for ``\rho_i(t + \Delta t)`` in the equation above. @@ -72,7 +73,7 @@ Using this predicted velocity and the continuity equation, a predicted density c in a similar way as ```math -\rho_i^{\text{adv}}(t + \Delta t)= \rho_i(t) + \Delta t \sum_j m_j \bm{v}_{ij}^{\text{adv}} \nabla W_{ij}(t). +\rho_i^{\text{adv}}(t + \Delta t)= \rho_i(t) + \Delta t \sum_j m_j \bm{v}_{ij}^{\text{adv}}(t+\Delta t) \cdot \nabla W_{ij}(t). ``` To achieve the rest density, the unknown pressure forces must counteract the compression @@ -81,7 +82,7 @@ the predicted density and the reference density. Therefore, the following equation needs to be fulfilled: ```math -\Delta t ^2 \sum_j m_j \left( \frac{\bm{F}_i^p(t)}{m_i} - \frac{\bm{F}_j^p(t)}{m_j} \right) \nabla W_{ij}(t) = \rho_0 - \rho_i^{\text{adv}}. +\Delta t ^2 \sum_j m_j \left( \frac{\bm{F}_i^p(t)}{m_i} - \frac{\bm{F}_j^p(t)}{m_j} \right) \cdot \nabla W_{ij}(t) = \rho_0 - \rho_i^{\text{adv}}. ``` This expression is derived by substituting the reference density ``\rho_0`` for @@ -130,10 +131,10 @@ The pressure acceleration is given by: The ``d_{ii}p_i`` value describes the displacement of particle ``i`` because of the particle ``i`` and ``d_{ij}p_j`` describes the influence from the neighboring particles ``j``. -Using this new values the linear system can be rewritten as +Using these values, the linear system can be rewritten as ```math -\rho_0 - \rho_i^{\text{adv}} = \sum_j m_j \left( d_{ii}p_i + \sum_k d_{ik}p_k - d_{jj}p_j - \sum_k d_{jk}p_k \right) \nabla W_{ij}, +\rho_0 - \rho_i^{\text{adv}} = \sum_j m_j \left( d_{ii}p_i + \sum_k d_{ik}p_k - d_{jj}p_j - \sum_k d_{jk}p_k \right) \cdot \nabla W_{ij}, ``` where the first sum over ``k`` loops over all neighbor particles of ``i`` and @@ -150,21 +151,21 @@ To separate this sum, it can be written as With this separation, the equation for the linear system can again be rewritten as ```math -\rho_0 - \rho_i^{\text{adv}} = p_i \sum_j m_j ( d_{ii} - d_{ji})\nabla W_{ij} + \sum_j m_j \left ( \sum_k d_{ik} p_k - d_{jj} p_j - \sum_{k \neq i} d_{jk}p_k \right) \nabla W_{ij}. +\rho_0 - \rho_i^{\text{adv}} = p_i \sum_j m_j ( d_{ii} - d_{ji}) \cdot \nabla W_{ij} + \sum_j m_j \left ( \sum_k d_{ik} p_k - d_{jj} p_j - \sum_{k \neq i} d_{jk}p_k \right) \cdot \nabla W_{ij}. ``` In this formulation all coefficients that are getting multiplied with the pressure value ``p_i`` are separated from the other. The diagonal elements ``a_{ii}`` can therefore be defined as: ```math -a_{ii} = \sum_j m_j ( d_{ii} - d_{ji})\nabla W_{ij}. +a_{ii} = \sum_j m_j ( d_{ii} - d_{ji}) \cdot \nabla W_{ij}. ``` The remaining part of the equation represents the influence of the other pressure values ``p_j``. -โ€‹Hence, the final relaxed Jacobi iteration takes the form: +Hence, the final relaxed Jacobi iteration takes the form: ```math -p_i^{l+1} = (1 - \omega) p_i^{l} + \omega \frac{1}{a_{ii}} \left( \rho_0 -\rho_i^{\text{adv}} - \sum_j m_j \left( \sum_k d_{ik} p_k^l - d_{jj} p_j^l - \sum_{k \neq i} d_{jk} p_k^l \right) \nabla W_{ij} \right). +p_i^{l+1} = (1 - \omega) p_i^{l} + \omega \frac{1}{a_{ii}} \left( \rho_0 -\rho_i^{\text{adv}} - \sum_j m_j \left( \sum_k d_{ik} p_k^l - d_{jj} p_j^l - \sum_{k \neq i} d_{jk} p_k^l \right) \cdot \nabla W_{ij} \right). ``` Because interactions are local, limited to particles within the kernel support defined by @@ -209,7 +210,7 @@ as only isolated or almost isolated particles are affected. ## Boundary Handling The previously introduced formulation did not distinguish between fluid and boundary -particles. To account boundary interactions correctly, a few modifications to the previous +particles. To account for boundary interactions correctly, a few modifications to the previous equations are required. First, the discretized form of the continuity equation must be adapted for the case in which @@ -220,7 +221,7 @@ neighboring fluid particles (indexed by ``f``) and neighboring boundary particle The updated discretized continuity equation becomes: ```math -\frac{\rho_i(t + \Delta t) - \rho_i(t)}{\Delta t} = \sum_f m_f \bm{v}_{if}(t+\Delta t) \nabla W_{if} + \sum_b m_b \bm{v}_{ib}(t+\Delta t) \nabla W_{ib}. +\frac{\rho_i(t + \Delta t) - \rho_i(t)}{\Delta t} = \sum_f m_f \bm{v}_{if}(t+\Delta t) \cdot \nabla W_{if} + \sum_b m_b \bm{v}_{ib}(t+\Delta t) \cdot \nabla W_{ib}. ``` Since boundary particles have zero velocity, the difference between the fluid @@ -229,13 +230,13 @@ particle's velocity ``\bm{v}_{ib}(t+\Delta t) = \bm{v}_{i}(t+\Delta t)``. Accordingly, the predicted density ``\rho^{\text{adv}}`` becomes: ```math -\rho_i^{\text{adv}} = \rho_i (t) + \Delta t \sum_f m_f \bm{v}_{if}^{\text{adv}} \nabla W_{if}(t) + \Delta t \sum_b m_b \bm{v}_{i}^{\text{adv}} \nabla W_{ib}(t). +\rho_i^{\text{adv}} = \rho_i (t) + \Delta t \sum_f m_f \bm{v}_{if}^{\text{adv}} \cdot \nabla W_{if}(t) + \Delta t \sum_b m_b \bm{v}_{i}^{\text{adv}} \cdot \nabla W_{ib}(t). ``` This leads to the following updated formulation of the linear system: ```math -\Delta t^2 \sum_f m_f \left( \frac{\bm{F}_i^p(t)}{m_i} - \frac{\bm{F}_f^p(t)}{m_f} \right) \nabla W_{if} + \Delta t^2 \sum_b m_b \frac{\bm{F}_i^p(t)}{m_i} \nabla W_{ib} = \rho_0 - \rho_i^{\text{adv}}. +\Delta t^2 \sum_f m_f \left( \frac{\bm{F}_i^p(t)}{m_i} - \frac{\bm{F}_f^p(t)}{m_f} \right) \cdot \nabla W_{if} + \Delta t^2 \sum_b m_b \frac{\bm{F}_i^p(t)}{m_i} \cdot \nabla W_{ib} = \rho_0 - \rho_i^{\text{adv}}. ``` Note that, since boundary particles are fixed, the force ``F_b^p`` is zero and does not appear @@ -244,12 +245,12 @@ in this equation. The pressure force acting on a fluid particle is computed as: ```math -\bm{F}_i^p(t) = -\sum_f m_f \left( \frac{p_i(t)}{\rho_i^2(t)} + \frac{p_f(t)}{\rho_f^2(t)} \right) \nabla W_{if}(t) - \sum_b m_b \left( \frac{p_i(t)}{\rho_i^2(t)} + \frac{p_b(t)}{\rho_b^2(t)} \right) \nabla W_{ib}(t). +\bm{F}_i^p(t) = -m_i \sum_f m_f \left( \frac{p_i(t)}{\rho_i^2(t)} + \frac{p_f(t)}{\rho_f^2(t)} \right) \nabla W_{if}(t) - m_i \sum_b m_b \left( \frac{p_i(t)}{\rho_i^2(t)} + \frac{p_b(t)}{\rho_b^2(t)} \right) \nabla W_{ib}(t). ``` This also leads to an updated version of the equation for the diagonal elements: ```math -a_{ii} = \sum_j m_j ( d_{ii} - d_{ji})\nabla W_{ij} + \sum_b m_b (-d_{bi}) \nabla W_{ib}. +a_{ii} = \sum_f m_f ( d_{ii} - d_{fi}) \cdot \nabla W_{if} + \sum_b m_b d_{ii} \cdot \nabla W_{ib}. ``` From this point forward, the computation of the coefficients required for the Jacobi scheme @@ -263,11 +264,12 @@ When using pressure mirroring, the pressure value ``p_b`` of a boundary particle above is defined to be equal to the pressure of the corresponding fluid particle ``p_i``. In other words, the boundary particle "mirrors" the pressure of the fluid particle interacting with it. As a result, the coefficient that describes the influence of a particle's own -pressure value ``p_i`` โ€‹must also include contributions from boundary particles. Therefore, -the equation for calculating the coefficient ``d_{ii}`` must be adjusted as follows: +pressure value ``p_i`` must include a doubled contribution from each boundary particle. +Therefore, ``d_{ii}`` becomes ```math -d_{ii} = -\Delta t^2 \sum_f \frac{m_f}{\rho_i^2} \nabla W_{if} - \Delta t^2 \sum_b \frac{m_b}{\rho_i^2} \nabla W_{ib}. +d_{ii} = -\Delta t^2 \sum_f \frac{m_f}{\rho_i^2} \nabla W_{if} + - 2\Delta t^2 \sum_b \frac{m_b}{\rho_i^2} \nabla W_{ib}. ``` The corresponding relaxed Jacobi iteration for pressure mirroring then becomes: @@ -275,30 +277,29 @@ The corresponding relaxed Jacobi iteration for pressure mirroring then becomes: ```math \begin{align*} p_i^{l+1} = (1 - \omega) p_i^l + \omega \frac{1}{a_{ii}} &\left( \rho_0 - \rho_i^{\text{adv}} - - \sum_f m_f \left( \sum_k d_{ik} p_k^l - d_{ff}p_f^l - \sum_{k \neq i} d_{fk} p_k^l \right) \nabla W_{if} \right. \\ -& \quad - \left. \sum_b m_b \sum_f d_{if} p_f^l \nabla W_{ib} \right). + - \sum_f m_f \left( \sum_k d_{ik} p_k^l - d_{ff}p_f^l - \sum_{k \neq i} d_{fk} p_k^l \right) \cdot \nabla W_{if} \right. \\ +& \quad - \left. \sum_b m_b \left( \sum_f d_{if} p_f^l \right) \cdot \nabla W_{ib} \right). \end{align*} ``` ### Pressure Zeroing If pressure zeroing is used instead, the pressure value of a boundary particle ``p_b`` -โ€‹is assumed to be zero. Consequently, boundary particles do not contribute to the pressure -forces acting on fluid particles. -In this case, the computation of the coefficient ``d_{ii}`` remains unchanged and is given by: +is assumed to be zero. In the linear system, this removes the boundary pressure unknowns, +but boundary particles still contribute through the ``p_i/\rho_i^2`` part of the pressure +acceleration. Therefore ``d_{ii}`` is ```math -d_{ii} = -\Delta t^2 \sum_f \frac{m_f}{\rho_i^2} \nabla W_{if}. +d_{ii} = -\Delta t^2 \sum_f \frac{m_f}{\rho_i^2} \nabla W_{if} + - \Delta t^2 \sum_b \frac{m_b}{\rho_i^2} \nabla W_{ib}. ``` -The equation for the relaxed Jacobi iteration remains the same as in the pressure mirroring -approach. However, the contribution from boundary particles vanishes due to their zero -pressure: +The corresponding relaxed Jacobi iteration reads ```math \begin{align*} p_i^{l+1} = (1 - \omega) p_i^l + \omega \frac{1}{a_{ii}} &\left( \rho_0 - \rho_i^{\text{adv}} - - \sum_f m_f \left( \sum_k d_{ik} p_k^l - d_{ff}p_f^l - \sum_{k \neq i} d_{fk} p_k^l \right) \nabla W_{if} \right. \\ -& \quad - \left. \sum_b m_b \sum_j d_{if} p_f^l \nabla W_{ib} \right). + - \sum_f m_f \left( \sum_k d_{ik} p_k^l - d_{ff}p_f^l - \sum_{k \neq i} d_{fk} p_k^l \right) \cdot \nabla W_{if} \right. \\ +& \quad - \left. \sum_b m_b \left( \sum_f d_{if} p_f^l \right) \cdot \nabla W_{ib} \right). \end{align*} ``` @@ -307,8 +308,8 @@ The density calculators [`AdamiPressureExtrapolation`](@ref) and [`BernoulliPres can also be used with IISPH. When using one of these pressure extrapolation methods the calculation of the PPE is exactly the same as when using pressure zeroing. -So within the linear systems the pressure values are equal to zero (``p_b=0``) and therefore -are not considered in the calculations. Only in the pressure acceleration, the extrapolated +So within the linear system the boundary pressures are treated as zero (``p_b=0``), exactly +as in pressure zeroing. Only in the pressure acceleration, the extrapolated pressure values are used for the boundary particles. For more information on these two methods, refer to the docs for the [boundary models](@ref boundary_models). @@ -318,7 +319,7 @@ The [`PressureBoundaries`](@ref) density calculator was introduced by only be used with IISPH. In the standard IISPH method the PPE is solved only for fluid particles. The pressure values for the boundary particles are then approximated, for example by using -pressure mirroing. +pressure mirroring. With `PressureBoundaries`, however, the linear system is extended to include the boundary particles as well. This means that the pressure values of both the fluid and the boundary particles are computed directly by solving the PPE. @@ -333,22 +334,22 @@ also solved as part of the linear system. This leads to the following condition for the boundary particles ``b``: ```math -\Delta t^2 \sum_f m_f \left( \frac{\bm{F}_i^p(t)}{m_i} - \frac{\bm{F}_f^p(t)}{m_f} \right) \nabla W_{if} + \Delta t^2 \sum_b m_b \left( \frac{\bm{F}_i^p(t)}{m_i} - \frac{\bm{F}_b^p(t)}{m_b} \right) \nabla W_{ib} = \rho_0 - \rho_i^{\text{adv}}. +\Delta t^2 \sum_f m_f \left( \frac{\bm{F}_i^p(t)}{m_i} - \frac{\bm{F}_f^p(t)}{m_f} \right) \cdot \nabla W_{if} + \Delta t^2 \sum_b m_b \left( \frac{\bm{F}_i^p(t)}{m_i} - \frac{\bm{F}_b^p(t)}{m_b} \right) \cdot \nabla W_{ib} = \rho_0 - \rho_i^{\text{adv}}. ``` -Note that in this case ``i`` is a boundary particle,``f`` are its fluid neighbors, and ``b`` +Note that in this case ``i`` is a boundary particle, ``f`` are its fluid neighbors, and ``b`` its boundary neighbors. Since the pressure force for boundary particles is zero (as mentioned before), and because in this case ``i`` and ``b`` are both boundary particles, the PPE simplifies to ```math -\Delta t^2 \sum_f m_f - \frac{\bm{F}_f^p(t)}{m_f} \nabla W_{if} = \rho_0 - \rho_i^{\text{adv}}. +-\Delta t^2 \sum_f m_f \frac{\bm{F}_f^p(t)}{m_f} \cdot \nabla W_{if} = \rho_0 - \rho_i^{\text{adv}}. ``` If we substitute the definition of the pressure force from above, we obtain ```math -\Delta t^2 \sum_f m_f \left( \sum_k m_k \left( \frac{p_f(t)}{\rho_j^2(t)} + \frac{p_k(t)}{\rho_k^2(t)} \right) \nabla W_{fk}\right) \nabla W_{if} = \rho_0 - \rho_i^{\text{adv}}. +\Delta t^2 \sum_f m_f \left( \sum_k m_k \left( \frac{p_f(t)}{\rho_f^2(t)} + \frac{p_k(t)}{\rho_k^2(t)} \right) \nabla W_{fk}\right) \cdot \nabla W_{if} = \rho_0 - \rho_i^{\text{adv}}. ``` where ``k`` represents all neighboring particles (fluid and boundary) of fluid particle ``f``. @@ -358,23 +359,23 @@ indirectly as neighbors of fluid particles), their ``d_{ii}`` values are zero. T diagonal elements simplify to ```math -a_{ii} = \sum_f \left( -d_{fi}\right) \nabla W_{if}. +a_{ii} = - \sum_f m_f d_{fi} \cdot \nabla W_{if}. ``` The off-diagonal term ``\sum_{j \neq i} a_{ij} p_j`` in the relaxed Jacobi iteration for boundary particles takes the following form ```math -\sum_{j \neq i} a_{ij} p_j = \sum_f m_f \left( d_{ff} - \sum_{f_j} d_{ff_j}p_{f_j}\right) \nabla W_{if}. +\sum_{j \neq i} a_{ij} p_j = \sum_f m_f \left( - d_{ff} p_f - \sum_{k \neq i} d_{fk}p_k \right) \cdot \nabla W_{if}. ``` But not only the addition of the boundary particles to the linear system changes when using pressure boundaries, also the PPE for the fluid particles is changing slightly. Since the boundary particles have now their own pressure values, ``p_b`` is no longer -eliminated (as in pressure zeroing or pressure extrapolation with ``p_b=0``) nor simply +eliminated (as in pressure zeroing or pressure extrapolation with ``p_b=0``) nor simply replaced by the fluid particle's pressure (as in pressure mirroring with ``p_b=p_i``). -Instead, ``p_b``remains part of the PPE. +Instead, ``p_b`` remains part of the PPE. This has no effect on the diagonal elements ``a_{ii}``, which are identical to those of the other density calculators. The ``d_{ii}`` values are also computed in the same way as for @@ -383,11 +384,11 @@ However, the off-diagonal term ``\sum_{j \neq i} a_{ij} p_j`` in the relaxed Ja changes slightly. For pressure boundaries it takes the form ```math - \sum_{j \neq i} a_{ij} p_j = \sum_f m_f \left( \sum_k d_{ik} p_k - d_{ff} p_f - \sum_{k \neq f} d_{fk} p_k \right) \nabla W_{if} \\ - + \sum_b m_b \left( \sum_k d_{ik} p_k \right) \nabla W_{ib}, +\sum_{j \neq i} a_{ij} p_j = \sum_f m_f \left( \sum_k d_{ik} p_k - d_{ff} p_f - \sum_{k \neq i} d_{fk} p_k \right) \cdot \nabla W_{if} ++ \sum_b m_b \left( \sum_k d_{ik} p_k \right) \cdot \nabla W_{ib}, ``` -where ``k``represents all neighboring particles of ``i`` (both fluid and boundary). +where ``k`` represents all neighboring particles of ``i`` (both fluid and boundary). ```@docs PressureBoundaries -``` \ No newline at end of file +``` diff --git a/docs/src/systems/total_lagrangian_sph.md b/docs/src/systems/total_lagrangian_sph.md index b6dc4e6f04..2965aa9713 100644 --- a/docs/src/systems/total_lagrangian_sph.md +++ b/docs/src/systems/total_lagrangian_sph.md @@ -9,7 +9,7 @@ The governing equations with respect to the initial configuration are given by: \frac{\mathrm{D}\bm{v}}{\mathrm{D}t} = \frac{1}{\rho_0} \nabla_0 \cdot \bm{P} + \bm{g}, ``` where the zero subscript denotes a derivative with respect to the initial configuration -and $\bm{P}$ is the first Piola-Kirchhoff (PK1) stress tensor. +and ``\bm{P}`` is the first Piola-Kirchhoff (PK1) stress tensor. The discretized version of this equation is given by [Oโ€™Connor & Rogers (2021)](@cite OConnor2021): ```math @@ -21,17 +21,17 @@ with the correction matrix (see also [`GradientCorrection`](@ref)) ```math \bm{L}_{0a} := \left( -\sum_{b} \frac{m_{0b}}{\rho_{0b}} \nabla_{0a} W(\bm{X}_{ab}) \bm{X}_{ab}^T \right)^{-1} \in \R^{d \times d}. ``` -The subscripts $a$ and $b$ denote quantities of particle $a$ and $b$, respectively. +The subscripts ``a`` and ``b`` denote quantities of particles ``a`` and ``b``, respectively. The zero subscript on quantities denotes that the quantity is to be measured in the initial configuration. -The difference in the initial coordinates is denoted by $\bm{X}_{ab} = \bm{X}_a - \bm{X}_b$, -the difference in the current coordinates is denoted by $\bm{x}_{ab} = \bm{x}_a - \bm{x}_b$. +The difference in the initial coordinates is denoted by ``\bm{X}_{ab} = \bm{X}_a - \bm{X}_b``, +the difference in the current coordinates is denoted by ``\bm{x}_{ab} = \bm{x}_a - \bm{x}_b``. -For the computation of the PK1 stress tensor, the deformation gradient $\bm{F}$ is computed per particle as +For the computation of the PK1 stress tensor, the deformation gradient ``\bm{F}`` is computed per particle as ```math \bm{F}_a = \sum_b \frac{m_{0b}}{\rho_{0b}} \bm{x}_{ba} (\bm{L}_{0a}\nabla_{0a} W(\bm{X}_{ab}))^T \\ \qquad = -\left(\sum_b \frac{m_{0b}}{\rho_{0b}} \bm{x}_{ab} (\nabla_{0a} W(\bm{X}_{ab}))^T \right) \bm{L}_{0a}^T ``` -with $1 \leq i,j \leq d$. +with ``1 \leq i,j \leq d``. From the deformation gradient, the Green-Lagrange strain ```math \bm{E} = \frac{1}{2}(\bm{F}^T\bm{F} - \bm{I}) @@ -53,9 +53,9 @@ and ```math \lambda = \frac{E\nu}{(1 + \nu)(1 - 2\nu)} ``` -are the Lamรฉ coefficients, where $E$ is the Young's modulus and $\nu$ is the Poisson ratio. +are the Lamรฉ coefficients, where ``E`` is the Young's modulus and ``\nu`` is the Poisson ratio. -The term $\bm{f}_a^{PF}$ is an optional penalty force. See e.g. [`PenaltyForceGanzenmueller`](@ref). +The term ``\bm{f}_a^{PF}`` is an optional penalty force. See e.g. [`PenaltyForceGanzenmueller`](@ref). ```@autodocs Modules = [TrixiParticles] @@ -69,26 +69,26 @@ This is caused by the stiffness matrix having zero eigenvalues (so-called hourgl The name "hourglass modes" comes from the fact that elements can deform into an hourglass shape. Similar effects can occur in SPH as well. -Particles can change positions without changing the SPH approximation of the deformation gradient $\bm{F}$, +Particles can change positions without changing the SPH approximation of the deformation gradient ``\bm{F}``, thus, without causing an increase of energy. To ensure regular particle positions, we can apply similar correction forces as are used in FEM. -[Ganzenmรผller (2015)](@cite Ganzenmueller2015) introduced a so-called hourglass correction force or penalty force $f^{PF}$, +[Ganzenmรผller (2015)](@cite Ganzenmueller2015) introduced a so-called hourglass correction force or penalty force ``f^{PF}``, which is given by ```math \bm{f}_a^{PF} = \frac{1}{2} \alpha \sum_b \frac{m_{0a} m_{0b} W_{0ab}}{\rho_{0a}\rho_{0b} |\bm{X}_{ab}|^2} \left( E \delta_{ab}^a + E \delta_{ba}^b \right) \frac{\bm{x}_{ab}}{|\bm{x}_{ab}|} ``` -The subscripts $a$ and $b$ denote quantities of particle $a$ and $b$, respectively. +The subscripts ``a`` and ``b`` denote quantities of particles ``a`` and ``b``, respectively. The zero subscript on quantities denotes that the quantity is to be measured in the initial configuration. -The difference in the initial coordinates is denoted by $\bm{X}_{ab} = \bm{X}_a - \bm{X}_b$, -the difference in the current coordinates is denoted by $\bm{x}_{ab} = \bm{x}_a - \bm{x}_b$. -Note that [Ganzenmรผller (2015)](@cite Ganzenmueller2015) has a flipped sign here because they define $\bm{x}_{ab}$ the other way around. +The difference in the initial coordinates is denoted by ``\bm{X}_{ab} = \bm{X}_a - \bm{X}_b``, +the difference in the current coordinates is denoted by ``\bm{x}_{ab} = \bm{x}_a - \bm{x}_b``. +Note that [Ganzenmรผller (2015)](@cite Ganzenmueller2015) has a flipped sign here because they define ``\bm{x}_{ab}`` the other way around. This correction force is based on the potential energy density of a Hookean material. -Thus, $E$ is the Young's modulus and $\alpha$ is a dimensionless coefficient that controls +Thus, ``E`` is the Young's modulus and ``\alpha`` is a dimensionless coefficient that controls the amplitude of hourglass correction. -The separation vector $\delta_{ab}^a$ indicates the change of distance which the particle separation should attain +The separation vector ``\delta_{ab}^a`` indicates the change of distance which the particle separation should attain in order to minimize the error and is given by ```math \delta_{ab}^a = \frac{\bm{\epsilon}_{ab}^a \cdot \bm{x_{ab}}}{|\bm{x}_{ab}|}, @@ -106,16 +106,16 @@ Pages = [joinpath("schemes", "structure", "total_lagrangian_sph", "penalty_force ## Viscosity Another technique that is used to correct the hourglass instability is artificial viscosity. -Hereby, a viscosity term designed for fluids (see [Viscosity](@ref viscosity_sph)) is applied. -First, the force ``f_{ab}^{\text{fluid}}`` exerted by particle ``b`` on particle ``a`` +Here, a viscosity term designed for fluids (see [Viscosity](@ref viscosity_sph)) is applied. +First, the force ``\bm{F}_{ab}^{\text{fluid}}`` exerted by particle ``b`` on particle ``a`` due to artificial viscosity is computed as if both particles were fluid particles (see [Viscosity](@ref viscosity_sph) for the relevant equations). Then, according to [Lin et al. (2015)](@cite Lin2015), this force can be applied to TLSPH with the following conversion: ```math -f_{ab}^{\text{AV}} = \det(F_a) F_a^{-1} f_{ab}^{\text{fluid}}, +\bm{F}_{ab}^{\text{AV}} = \det(\bm{F}_a) \bm{F}_a^{-T} \bm{F}_{ab}^{\text{fluid}}, ``` -where ``F_a`` is the deformation gradient at particle ``a``. +where ``\bm{F}_a`` is the deformation gradient at particle ``a``. We found that artificial viscosity is not effective at correcting the incorrect particle positions due to hourglass modes. diff --git a/docs/src/systems/weakly_compressible_sph.md b/docs/src/systems/weakly_compressible_sph.md index 2c69f4b3c9..9e5aef7120 100644 --- a/docs/src/systems/weakly_compressible_sph.md +++ b/docs/src/systems/weakly_compressible_sph.md @@ -52,22 +52,24 @@ pressure field. It is highly recommended to use density diffusion when using WCS ### Formulation All density diffusion terms extend the continuity equation (see [`ContinuityDensity`](@ref)) -by an additional term +by an additional term: ```math -\frac{\mathrm{d}\rho_a}{\mathrm{d}t} = \sum_{b} m_b v_{ab} \cdot \nabla W_{ab} - + \delta h c \sum_{b} V_b \psi_{ab} \cdot \nabla W_{ab}, +\frac{\mathrm{d}\rho_a}{\mathrm{d}t} = + \sum_{b} m_b \frac{\rho_a}{\rho_b} v_{ab} \cdot \nabla W_{ab} + + \delta c \sum_{b} \bar{h}_{ab} V_b \psi_{ab} \cdot \nabla W_{ab}, ``` -where ``V_b = m_b / \rho_b`` is the volume of particle ``b`` and ``\psi_{ab}`` depends on +where ``\bar{h}_{ab} = \frac{1}{2}(h_a + h_b)`` is the averaged smoothing length, +``V_b = m_b / \rho_b`` is the volume of particle ``b`` and ``\psi_{ab}`` depends on the density diffusion method (see [`AbstractDensityDiffusion`](@ref TrixiParticles.AbstractDensityDiffusion) for available terms). Also, ``\rho_a`` denotes the density of particle ``a`` and ``r_{ab} = r_a - r_b`` is the difference of the coordinates, ``v_{ab} = v_a - v_b`` of the velocities of particles -``a`` and ``b``. +``a`` and ``b``. For fixed smoothing length, ``\bar{h}_{ab} = h``. ### Numerical Results All density diffusion terms remove numerical noise in the pressure field and produce more -accurate results than weakly commpressible SPH without density diffusion. +accurate results than weakly compressible SPH without density diffusion. This can be demonstrated with dam break examples in 2D and 3D. Here, ``ฮด = 0.1`` has been used for all terms. Note that, due to added stability, the adaptive time integration method that was used here @@ -129,11 +131,14 @@ in such simulations. ### Mathematical formulation We use the following formulation by [Sun et al. (2018)](@cite Sun2018). -After each time step, a correction term ``\delta r_a`` is added to the position ``r_a`` +The relation ``\text{CFL} \cdot \text{Ma} = \Delta t \, v_\text{max} / h`` +is stated there on page 29, immediately above Equation 9, and gives the +dimensional form below. +After each time step, a correction term ``\delta \bm{r}_a`` is added to the position ``\bm{r}_a`` of particle ``a``, which is given by ```math -\delta r_a = -4 \Delta t \, v_\text{max} h - \sum_b \left( 1 + R \left( \frac{W_{ab}}{W(\Delta x_a)} \right)^n \right) \nabla W_{ab} +\delta \bm{r}_a = -4 \Delta t \, v_\text{max} h + \sum_b \left( 1 + R \left( \frac{W_{ab}}{W(\Delta x_a)} \right)^n \right) \nabla_a W_{ab} \frac{m_b}{\rho_a + \rho_b}, ``` where: @@ -141,14 +146,26 @@ where: - ``v_\text{max}`` is the maximum velocity over all particles, - ``h`` is the smoothing length, - ``R`` and ``n`` are constants, which are set to ``0.2`` and ``4`` respectively, -- ``W(\Delta x_a)`` is the smoothing kernel of the particle size of particle ``a``, - which can be interpreted as the target particle spacing that we want to achieve. -- ``\nabla W_{ab}`` is the gradient of the smoothing kernel, +- ``\Delta x_a`` is the target particle spacing associated with particle ``a``, +- ``W(\Delta x_a)`` is the smoothing kernel evaluated at that target particle spacing, +- ``\nabla_a W_{ab}`` is the gradient of the smoothing kernel with respect to particle ``a``, - ``m_b`` is the mass of particle ``b``, - ``\rho_a, \rho_b`` is the density of particles ``a`` and ``b``, respectively. -Note that we replaced ``\text{CFL} \cdot \text{Ma}`` by ``\Delta t \cdot v_\text{max} / h``, -as explained in [Sun2018](@cite Sun2018) on page 29, right above Equation 9. +TrixiParticles.jl applies this correction through a shifting velocity +```math +\delta \bm{r}_a = \Delta t \, \delta \bm{v}_a, +``` +with +```math +\delta \bm{v}_a = - v_* \frac{(2h)^2}{2\Delta x} + \sum_b \left( 1 + \frac{2}{10} \left( \frac{W_{ab}}{W(\Delta x)} \right)^4 \right) + \frac{m_b}{\rho_a + \rho_b} \nabla_a W_{ab}. +``` +Here, ``v_*`` is the velocity scale configured by the shifting technique. It is either +``v_\text{factor}\max_a \Vert \bm{v}_a \Vert`` when `v_max_factor` is used, or +``v_\text{factor} c`` when `sound_speed_factor` is used. +The constants are fixed to ``R = 0.2`` and ``n = 4``. The ``\delta``-SPH method (WCSPH with density diffusion) together with this formulation of PST is commonly referred to as ``\delta^+``-SPH. @@ -156,6 +173,21 @@ of PST is commonly referred to as ``\delta^+``-SPH. To apply particle shifting, use the keyword argument `shifting_technique` in the constructor of a system that supports it. +The default particle-shifting configuration remains restricted to closed systems. Morris CSF +and CSS simulations can explicitly opt into tangential free-surface shifting by reusing their +smooth color-field interface activity and normal: + +```julia +surface_normal_method = ColorfieldSurfaceNormal() +shifting_technique = ConsistentShiftingSun2019(; + free_surface_treatment=FreeSurfaceTangentialShifting()) +``` + +Full consistent shifting is retained in the interior. Across the smooth interface transition, +the normal component is progressively removed until shifting is tangential at the represented +surface. This avoids moving particles into or out of the interface. The treatment currently +requires `SurfaceTensionMorris` or `SurfaceTensionMomentumMorris`; it is not available for TVF. + ## [Transport Velocity Formulation (TVF)](@id transport_velocity_formulation) @@ -178,18 +210,29 @@ is a constant background pressure field. The tilde in the second term of the right-hand side indicates that the material derivative has an advection part. -The discretized form of the last term is +In the literature, the discretized form of the last term is ```math -\frac{1}{\rho_a} \nabla p_{\text{background}} \approx -\frac{p_{\text{background}}}{m_a} \sum_b \left(V_a^2 + V_b^2 \right) \nabla_a W_{ab}, ``` -where ``V_a``, ``V_b`` denote the volume of particles ``a`` and ``b`` respectively. +where ``V_a`` and ``V_b`` denote the particle volumes of particles ``a`` and ``b`` respectively. Note that although in the continuous case ``\nabla p_{\text{background}} = 0``, -the discretization is not 0th-order consistent for **non**-uniform particle distribution, -which means that there is a non-vanishing contribution only when particles are disordered. -That also means that ``p_{\text{background}}`` occurs as pre-factor to correct -the trajectory of a particle resulting in uniform pressure distributions. -Suggested is a background pressure which is in the order of the reference pressure, -but it can be chosen arbitrarily large when the time-step criterion is adjusted. +the discretization is not 0th-order consistent for non-uniform particle distributions. +This means that a non-vanishing contribution appears only when the particles are disordered, +so ``p_{\text{background}}`` acts as a prefactor that regularizes the trajectories and promotes +more uniform particle distributions. + +TrixiParticles.jl evaluates this term with the selected pressure-acceleration operator. +For the default [`ContinuityDensity`](@ref) pressure acceleration and the CFL estimate +used by [Adami et al. (2013)](@cite Adami2013), +```math +\Delta t \leq \frac{1}{4} \frac{h}{c_s}, +``` +used as an equality, this gives +```math +\delta \bm{v}_a = - \frac{p_{\text{background}}}{8} \frac{h}{c_s} +\sum_b \frac{2m_b}{\rho_a \rho_b} \nabla_a W_{ab}, +``` +where ``h`` is the smoothing length and ``c_s`` is the speed of sound. The inviscid momentum equation with an additional convection term for a particle moving with ``\tilde{v}`` is @@ -200,18 +243,31 @@ where the tensor ``\bm{A} = \rho v\left(\tilde{v}-v\right)^T`` is a consequence of the modified advection velocity and can be interpreted as the convection of momentum with the relative velocity ``\tilde{v}-v``. -The discretized form of the momentum equation for a particle ``a`` reads as +The discretized form of the momentum equation for a particle ``a`` reads ```math -\frac{\tilde{\mathrm{d}} v_a}{\mathrm{d}t} = \frac{1}{m_a} \sum_b \left(V_a^2 + V_b^2 \right) \left[ -\tilde{p}_{ab} \nabla_a W_{ab} + \frac{1}{2} \left(\bm{A}_a + \bm{A}_b \right) \cdot \nabla_a W_{ab} \right]. +\frac{\tilde{\mathrm{d}} v_a}{\mathrm{d}t} += \frac{1}{m_a} \sum_b \left(V_a^2 + V_b^2 \right) +\left[ -\tilde{p}_{ab} \nabla_a W_{ab} ++ \frac{1}{2} \left(\bm{A}_a + \bm{A}_b \right) \cdot \nabla_a W_{ab} \right]. ``` Here, ``\tilde{p}_{ab}`` is the density-weighted pressure ```math \tilde{p}_{ab} = \frac{\rho_b p_a + \rho_a p_b}{\rho_a + \rho_b}, ``` -with the density ``\rho_a``, ``\rho_b`` and the pressure ``p_a``, ``p_b`` of particles ``a`` -and ``b``, respectively. ``\bm{A}_a`` and ``\bm{A}_b`` are the convection tensors -for particle ``a`` and ``b``, respectively, and are given, e.g., for particle ``a``, -as ``\bm{A}_a = \rho v_a\left(\tilde{v}_a-v_a\right)^T``. +with ``\rho_a``, ``\rho_b`` and ``p_a``, ``p_b`` denoting the densities and pressures +of particles ``a`` and ``b``, respectively. + +TrixiParticles.jl evaluates this additional term with the selected pressure-acceleration +operator. For the default [`ContinuityDensity`](@ref) pressure acceleration, this gives +```math +\left.\frac{\tilde{\mathrm{d}} v_a}{\mathrm{d}t}\right|_{\bm{A}} += - \sum_b \frac{m_b}{\rho_a \rho_b} +\left(\bm{A}_a + \bm{A}_b \right) \cdot \nabla_a W_{ab}. +``` +Here, for example, +```math +\bm{A}_a = \rho_a \bm{v}_a \left(\tilde{\bm{v}}_a - \bm{v}_a\right)^T. +``` To apply the TVF, use the keyword argument `shifting_technique` in the constructor of a system that supports it. @@ -247,17 +303,18 @@ Only the combination of PST and TIC is able to produce physical results. The force that particle ``a`` experiences from particle ``b`` due to pressure is given by ```math -f_{ab} = -m_a m_b \frac{p_a + p_b}{\rho_a \rho_b} \nabla W_{ab} +\bm{f}_{ab} = -m_a m_b \frac{p_a + p_b}{\rho_a \rho_b} \nabla_a W_{ab} ``` for the WCSPH method with [`ContinuityDensity`](@ref). -The TIC formulation changes this force to +The TIC formulation changes this term to ```math -f_{ab} = -m_a m_b \frac{|p_a| + p_b}{\rho_a \rho_b} \nabla W_{ab}. +\bm{f}_{ab}^{\mathrm{TIC}} = -m_a m_b \frac{|p_a| + p_b}{\rho_a \rho_b} \nabla_a W_{ab}. ``` Note that this formulation is asymmetric and sacrifices conservation of linear and angular momentum. ```@docs tensile_instability_control +InterfaceAwareTensileInstabilityControl ``` diff --git a/docs/src/time_integration.md b/docs/src/time_integration.md index f8a2842c13..58d9a47908 100644 --- a/docs/src/time_integration.md +++ b/docs/src/time_integration.md @@ -107,7 +107,7 @@ half step for ``v``, yielding u^{1/2} &= u^0 + \frac{1}{2} \Delta t\, \operatorname{drift}(v^0, u^0, t^0), \\ v^{1/2} &= v^0 + \frac{1}{2} \Delta t\, \operatorname{kick}(v^0, u^0, t^0), \\ v^1 &= v^0 + \Delta t\, \operatorname{kick} \left( v^{1/2}, u^{1/2}, t^0 + \frac{1}{2} \Delta t \right), \\ -u^1 &= u^{1/2} + \frac{1}{2} \Delta t\, \operatorname{drift}(v^{1}, u^{1}, t^0 + \Delta t). +u^1 &= u^{1/2} + \frac{1}{2} \Delta t\, \operatorname{drift}(v^{1}, u^{1/2}, t^0 + \Delta t). \end{align*} ``` This scheme is implemented in `OrdinaryDiffEqSymplecticRK` as `LeapfrogDriftKickDrift` and yields @@ -133,7 +133,7 @@ v^{1/2} &= v^0 + \frac{1}{2} \Delta t\, \operatorname{kick}(v^0, u^0, t^0), \\ \rho^{1/2} &= \rho^0 + \frac{1}{2} \Delta t\, R(v^0, u^0, t^0), \\ v^1 &= v^0 + \Delta t\, \operatorname{kick} \left( v^{1/2}, u^{1/2}, t^0 + \frac{1}{2} \Delta t \right), \\ \rho^1 &= \rho^0 \frac{2 - \varepsilon^{1/2}}{2 + \varepsilon^{1/2}}, \\ -u^1 &= u^{1/2} + \frac{1}{2} \Delta t\, \operatorname{drift}(v^{1}, u^{1}, t^0 + \Delta t), +u^1 &= u^{1/2} + \frac{1}{2} \Delta t\, \operatorname{drift}(v^{1}, u^{1/2}, t^0 + \Delta t), \end{align*} ``` where diff --git a/docs/src/tutorial.md b/docs/src/tutorial.md index 4225faf486..8a27d363b2 100644 --- a/docs/src/tutorial.md +++ b/docs/src/tutorial.md @@ -1,12 +1,16 @@ # Tutorials +Choose a tutorial based on the task in front of you. + > New to TrixiParticles.jl? Start with [Setting up your simulation from scratch](tutorials/tut_setup.md). ## Recommended Path 1. [Setting up your simulation from scratch](tutorials/tut_setup.md): learn the structure of a simulation file and run a complete WCSPH example. 2. [Modifying or extending components of TrixiParticles.jl within a simulation file](tutorials/tut_custom_kernel.md): replace selected parts of an existing setup without cloning the package. -3. [Particle packing tutorial](tutorials/tut_packing.md): build a body-fitted particle configuration for complex geometries. +3. [Setting up a 2D simulation from geometry files](tutorials/tut_2d_geometry.md): load 2D geometry files, turn them into filled wall regions, and combine them with standard 2D fluid blocks. +4. [Particle packing tutorial](tutorials/tut_packing.md): build a body-fitted particle configuration for complex geometries. +5. [Fluid-structure interaction with rigid bodies](tutorials/tut_rigid_body_fsi.md): simulate objects moving in a fluid. ## Tutorials @@ -38,6 +42,19 @@ directly in the file you run. - Focus: `trixi_include`, custom kernels, rapid iteration - Choose this if: you want to prototype changes without cloning and modifying the package +### [Setting up a 2D simulation from geometry files](tutorials/tut_2d_geometry.md) + +```@raw html +2D pipe and coastline geometries converted to wall and fluid particles +``` + +Load 2D geometry files, fill them with particles using `ComplexShape`, and build 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: + ![image](https://github.com/user-attachments/assets/383d323a-3020-4232-9dc3-682b0afe8653) It is useful to make the dot size dependent on the actual particle size. @@ -28,9 +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`". + ![image](https://github.com/user-attachments/assets/194d9a09-5937-4ee4-b229-07078afe3ff0) -#### Visualization with Macro +#### Visualization with a Macro To simplify visualization of particle data in ParaView, you can use a macro. It reduces the manual steps from the previous section to a single click. Install the macro as follows. @@ -44,7 +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). + ![image](https://github.com/user-attachments/assets/10dcf7eb-5808-4d4d-9db8-4beb25b5e51a) ## 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