verificationCases/laplaceEmbedTubeAdapt.c
laplaceEmbedTubeAdapt.c
Verification of surface tension
inside an embedded tube on an adaptive tree,
and a direct test of the three embed/axi/VOF couplings
that src-local/embed-vof-tube.h exists to
guard.
Claim
For a static spherical droplet of radius \(R_d\) held on the axis of
an embedded tube of radius \(R_{tube}\), with matched
densities and viscosities and no gravity, the exact
solution of the implemented equations is \(\mathbf{u} \equiv 0\)
everywhere and a uniform pressure jump \(\Delta p = 2\sigma/R_d\)
across the interface. On an adaptive tree, and provided
the grid is re-synchronised after every
adapt_wavelet() call, the discrete solution
stays within the same tolerances as the uniform \(N = 128\) case.
The comparator is an exact solution of the equations the solver claims to discretise, so this is verification, not validation: it says nothing about whether those equations describe a real drop in a tube.
Relation to
laplaceEmbedTube.c
The physical setup is identical — \(L_0 = 2\), \(R_d = 0.4\), \(R_{tube} = 0.7\), \(X_d = 1\), \(\sigma = 1\), \(\rho_1 = \rho_2 = 1\),
\(\mu_1 = \mu_2 = 1\),
DT \(=
10^{-2}\), TOLERANCE \(= 10^{-5}\), \(t_{end} = 1\) — and
MAXLEVEL \(=
7\) reproduces that case’s finest spacing \(\Delta = L_0/128\). The
only difference is the grid: here the run starts from a
coarse init_grid(1 << MINLEVEL) and
adapt_wavelet() runs every step.
The comparison is therefore direct at the
interface, which the f criterion pins
at MAXLEVEL; it is not direct at the wall,
which ends up at MINLEVEL — see the
refinement criteria below, and the grid statistics the
case prints.
Density and viscosity filtering is deliberately
absent: with \(\rho_1 =
\rho_2\) and \(\mu_1 =
\mu_2\) the property functions of
two-phase-generic.h are identically unity
whether or not the smoothed field replaces
f, so #define FILTERED would
be inert — and the production solver no longer sets it
either.
Refinement criteria
adapt_wavelet() on \(\{f, c_s, u_x, u_y\}\) with
thresholds \(\{10^{-3},
10^{-3}, 10^{-2}, 10^{-2}\}\) between
MINLEVEL \(=
4\) and MAXLEVEL \(= 7\). The choices:
fErr\(= 10^{-3}\) holds the whole droplet interface atMAXLEVEL;csErr\(= 10^{-3}\) was intended to hold the tube wall atMAXLEVEL. It does not, and this case measures that. The wall is planar and grid-aligned, andembed.hsetscs.prolongation = fraction_refine, which reproduces a planar interface exactly; the wavelet error ofcsis therefore identically zero along a straight tube wall and no refinement is requested. Measured here: theinitevent refines the wall band toMAXLEVEL, the firstadapt_wavelet()coarsens it straight back, and at \(t = t_{end}\) all 16 cut cells sit atMINLEVEL. LoweringcsErrcannot change this — the error is zero, not small. This contradicts the remedy suggested insrc-local/embed-vof-tube.hfor coupling 3 (“cases must refine the wall region (e.g. by adapting oncs)”), and it applies equally tosimulationCases/bretherton.c, which uses the same criterion: there, wall refinement is delivered by the interface criteria once the film is close to the wall, not bycs. A case that needs the wall refined independently of the interface must raiseMINLEVELor add an explicit geometric criterion.uErr\(= 10^{-2}\) is set an order of magnitude above the spurious velocity the case is allowed to reach (TOL_U\(= 10^{-3}\)). The exact solution has \(\mathbf{u} \equiv 0\), so a tighter velocity criterion would refine on numerical noise and the mesh would stop being a controlled variable. Velocity is kept in the criterion list because the production solver adapts on it, but here it is deliberately inactive.
The production solver also adapts on the curvature
KAPPA; that is omitted here because the
interface is a sphere of fixed radius and
fErr alone already pins it at
MAXLEVEL.
Note that MINLEVEL \(= 4\) means the droplet
interior and the far field coarsen to \(\Delta = L_0/16\). Both are
regions of uniform pressure, so the cell-averaged
pressures used below are not sensitive to this.
The three probes
src-local/embed-vof-tube.h names three
couplings, all three triggered by adaptation.
No other case in this repository runs on an adaptive
grid, so until now none of them was exercised in the
situation the header was written for. Each probe below
targets one of them and is evaluated after
every adaptation step, with the running
maximum carried to the end of the run.
P1 — metric consistency. With
AXI and EMBED the metric must
satisfy \(c_m =
y\,c_s\) and \(f_m =
y\,f_s\) (with the cut-cell plane-centre
correction of axi.h). axi.h
fills cm/fm once, in its
metric event at i = 0, when
cs = 1 everywhere; every later
solid() call and every
adapt_wavelet() — which re-prolongates
cs/fs — leaves them stale. The
probe recomputes reference fields with the same
arithmetic as axi.h’s
cm_update() and fm_update()
(including the
facet_normal/plane_center
correction in cut cells, the \(-\sigma(1 - f_{s,x})\)
shift on \(x\)-faces,
and the fact that in foreach_face(y) the
coordinate y is the face position, not the
cell centre) and reports \(\max|c_m - c_m^{ref}|\) and
\(\max|f_m -
f_m^{ref}|\). The probe iterates over
foreach() and foreach_face(),
i.e. over exactly the domain
cm_update()/fm_update() write,
which includes the faces on the domain boundary. Both
maxima must be at round-off, TOL_METRIC
\(= 10^{-12}\).
Because the probe recomputes exactly what
embed_axi_metric_sync() writes, in the
guarded build P1 is close to tautological: it confirms
that the sync was actually invoked and that nothing
between the sync and the probe perturbed the metric. Its
discriminating power is demonstrated by the negative
control, where the same probe fires.
P2 — no dispersed phase inside the
solid. vof.h only updates cells
with cs > 0, but adaptation prolongates
f with no knowledge of cs, so
refined solid cells can inherit non-zero f
from fluid neighbours, corrupting the (non-embed-aware)
height-function columns near the wall. The probe reports
\(\max|f|\) over cells
with \(c_s \le 0\) and
requires it to be exactly zero.
P3 — Young–Laplace. Relative error
of the measured pressure jump below TOL_DP
\(= 2\times 10^{-2}\)
and \(\max|\mathbf{u}|\) below
TOL_U \(=
10^{-3}\) at \(t =
t_{end}\), using the same thresholds and the same
measurement regions as the uniform case: cell averages
of p over \(r
< R_d/2\) inside and over \(r > 3R_d/2\), \(y < R_{tube} - 0.1\),
full fluid cells only, outside. This is the
physics-level consequence of couplings 1–3 being handled
correctly.
Negative control
Compile with -DSKIP_EMBED_GUARDS to use
active-cell metric initialisation without the complete
stored-tree tube reconstruction, and to omit the
post-adaptation embed_axi_metric_sync() and
vof_solid_cleanup() calls. The control
tests the complete tube compatibility pathway; it does
not isolate the contribution of each call.
qcc -I../src-local -O2 -Wall -disable-dimensions \
laplaceEmbedTubeAdapt.c -o laplaceEmbedTubeAdapt -lm
qcc -I../src-local -O2 -Wall -disable-dimensions -DSKIP_EMBED_GUARDS \
laplaceEmbedTubeAdapt.c -o laplaceEmbedTubeAdaptNoGuard -lmThe guard-less build is expected to fail, and to fail on P1 or P2, i.e. for the reason the header states, rather than merely by producing NaN. The probes therefore run before any solver step consumes the corrupted state, and the first firing iteration and magnitude are recorded and printed. If the guard-less build were to pass, that would be a finding about the header — not a reason to weaken this case.
The control retains valid active-cell geometry at initialisation, so its failure must come from a measured consistency violation rather than an invalid starting PDE state. It does not measure the dynamical error caused by omitting a particular synchronisation call.
Outputs
- the running maxima of P1 and P2 and their first-firing iterations;
- \(\Delta p\), its exact value, the relative error and \(\max|\mathbf{u}|\) at \(t = t_{end}\);
- final grid statistics: leaf count, level range,
finest \(\Delta\), and
the number and level range of the cut cells — the last
of these is what exposes the
cs-refinement behaviour discussed above; - a time series of \(\Delta p\), \(\max|\mathbf{u}|\) and the P1/P2 maxima on stderr;
- a bare
PASSorFAILline on stdout, withexit(1)on failure.
Limits
- One geometry, one droplet radius, one tube radius,
one interface–wall separation \(R_{tube} - R_d = 0.3\):
about 19 cells at
MAXLEVELon the interface side, about 2.4 cells atMINLEVELon the wall side. Coupling 3 (interface–wall separation) is therefore satisfied by construction here, not tested: this case establishes nothing about what happens when the interface approaches the wall to within a few cells, which is exactly the Bretherton film regime. - The wall ends up at
MINLEVEL(see the refinement criteria). That is a measured property ofadapt_waveletoncsfor a planar boundary, reported rather than worked around, but it does mean the cut cells here are coarse and that the metric coupling is exercised only through the single 7 → 4 coarsening of the wall band during the first adaptation steps, not through sustained churn of the cut cells. - For the same reason the dispersed phase never
reaches the wall, so
fis zero in the solid region throughout and P2 has no opportunity to fire. It is a necessary condition that the guarded build satisfies, and a genuine regression guard, but the negative control here can only exercise P1. A case in which the drop touches or nearly touches the wall would be needed to exercise P2. - Matched properties only (\(\rho_1 = \rho_2\), \(\mu_1 = \mu_2\)): nothing is established about the density and viscosity contrasts the production solver runs at.
- One resolution:
MAXLEVEL\(= 7\) only, so this is a tolerance check at fixed resolution, not a convergence study. The uniform refinement sequence lives inlaplaceEmbedTube.c. - The measured jump is a difference of cell averages over finite regions, not a pointwise jump, so it carries its own discretisation error.
- P1 is a consistency check of the live
metric against
axi.h’s own formulae. It does not verify that those formulae are themselves the correct axisymmetric metric; that isaxi.h’s claim, guarded upstream bysrc/test/missing_metric.c.
#include "embed.h"
#include "axi.h"
#include "navier-stokes/centered.h"
#include "two-phase.h"
#include "tension.h"
#include "embed-vof-tube.h"
#define Rtube 0.7214
#define Rd 0.4215
#define Xd 1.0216
#define tend 1.0217
#define MAXLEVEL 7219
#define MINLEVEL 4220
#define fErr 1e-3222
#define csErr 1e-3223
#define uErr 1e-2224
#define TOL_DP 2e-2226
#define TOL_U 1e-3227
#define TOL_METRIC 1e-12228
u.n[embed] = dirichlet (0.);
u.t[embed] = dirichlet (0.);Probe state
Running maxima and the iteration at which each probe
first exceeds its threshold (-1 while it
has never fired).
static double dcmMax = 0., dfmMax = 0., fSolidMax = 0.;
static int iFirstMetric = -1, iFirstSolid = -1;
static double vFirstMetric = 0., vFirstSolid = 0.;
int main()
{
L0 = 2.0;
init_grid (1 << MINLEVEL);
rho1 = 1.; mu1 = 1.;
rho2 = 1.; mu2 = 1.;
f.sigma = 1.;
TOLERANCE = 1e-5;
DT = 1e-2;
run();
}Initialisation
Refine the interface and the wall to
MAXLEVEL before embedding the
solid and initialising f, so that both are
laid down on the mesh they will be carried on. The
guarded build reconstructs geometry throughout the
stored tree. The negative control initialises only the
active-cell metrics required to start integration and
omits the complete tube pathway.
event init (t = 0)
{
refine (fabs (sqrt (sq(x - Xd) + sq(y)) - Rd) < 0.1 && level < MAXLEVEL);
refine (fabs (y - Rtube) < 0.1 && level < MAXLEVEL);
#if defined(SKIP_EMBED_GUARDS)
solid (cs, fs, Rtube - y);
cm_update (cm, cs, fs);
fm_update (fm, cs, fs);
restriction ({cs, fs, cm, fm});
#else
tube_solid (Rtube);
#endif
fraction (f, Rd - sqrt (sq(x - Xd) + sq(y)));
vof_solid_cleanup (f);
}P1 and P2: the coupling probes
metric_deviation() recomputes
cm and fm from the live
cs/fs with the same arithmetic
as cm_update()/fm_update() in
$BASILISK/axi.h and returns the maximum
absolute deviation of the live fields from that
reference. cm and fm are read
through plain-field aliases, following
axi.h’s own idiom for these
(const)-declared metric fields.
solid_fraction_max() returns \(\max|f|\) over full-solid
cells: the absolute value, so that a negative
fraction prolongated into the solid is caught as well as
a positive one.
static void metric_deviation (double * dcm, double * dfm)
{
double ecm = 0., efm = 0.;
#if defined(AXI) && defined(EMBED)
scalar cmref[];
face vector fmref[];
foreach() {
if (cs[] > 0. && cs[] < 1.) {
coord p, n = facet_normal (point, cs, fs);
double alpha = plane_alpha (cs[], n);
plane_center (n, alpha, cs[], &p);
cmref[] = (y + Delta*p.y)*cs[];
}
else
cmref[] = y*cs[];
}In foreach_face(x) the coordinate
y is the cell-centre radius (an \(x\)-face has the radius of
its cell); in foreach_face(y) it is the
face radius, which vanishes on the axis — hence the
max(y, 1e-20) floor, copied from
fm_update().
foreach_face(x) {
double sig = 0.;
if (cs[] > 0. && cs[] < 1.) {
coord n = facet_normal (point, cs, fs);
sig = sign(n.y)*Delta/2.;
}
fmref.x[] = (y - sig*(1. - fs.x[]))*fs.x[];
}
foreach_face(y)
fmref.y[] = max(y, 1e-20)*fs.y[];
scalar cmv = cm;
face vector fmv = fm;
foreach (reduction(max:ecm))
if (fabs (cmv[] - cmref[]) > ecm)
ecm = fabs (cmv[] - cmref[]);
foreach_face (x, reduction(max:efm))
if (fabs (fmv.x[] - fmref.x[]) > efm)
efm = fabs (fmv.x[] - fmref.x[]);
foreach_face (y, reduction(max:efm))
if (fabs (fmv.y[] - fmref.y[]) > efm)
efm = fabs (fmv.y[] - fmref.y[]);
#endif
*dcm = ecm; *dfm = efm;
}
static double solid_fraction_max (void)
{
double fm2 = 0.;
foreach (reduction(max:fm2))
if (cs[] <= 0. && fabs (f[]) > fm2)
fm2 = fabs (f[]);
return fm2;
}Updates the running maxima and records the first violation of each predicate. Called after every adaptation.
static void probe_couplings (int iter)
{
double dcm, dfm;
metric_deviation (&dcm, &dfm);
double fsol = solid_fraction_max();
if (dcm > dcmMax) dcmMax = dcm;
if (dfm > dfmMax) dfmMax = dfm;
if (fsol > fSolidMax) fSolidMax = fsol;
if (iFirstMetric < 0 && (dcm >= TOL_METRIC || dfm >= TOL_METRIC)) {
iFirstMetric = iter;
vFirstMetric = max (dcm, dfm);
fprintf (ferr, "# P1 fired at i = %d: max|cm - y*cs| = %.6e, "
"max|fm - y*fs| = %.6e\n", iter, dcm, dfm);
}
if (iFirstSolid < 0 && fsol > 0.) {
iFirstSolid = iter;
vFirstSolid = fsol;
fprintf (ferr, "# P2 fired at i = %d: max|f| over cs <= 0 = %.6e\n",
iter, fsol);
}
}P3: pressure jump and spurious currents
Identical to laplaceEmbedTube.c: cell
averages of p well inside (\(r < R_d/2\)) and well
outside (\(r >
3R_d/2\), away from the wall) the droplet, and
the maximum velocity magnitude over every cell with
\(c_s > 0\), cut
cells included.
static void dp_umax (double * dp, double * umax)
{
double pin = 0., pout = 0., win = 0., wout = 0., un = 0.;
foreach (reduction(+:pin) reduction(+:pout)
reduction(+:win) reduction(+:wout) reduction(max:un)) {The spurious-current maximum must include cut cells.
Those are the cells adjacent to the embedded wall, and
they are where the height-function curvature error this
case measures is largest; restricting un to
full cells would let a near-wall parasitic current
exceed TOL_U unseen. The pressure averages
stay on pure fluid cells, where a cell-average of
p is meaningful.
if (cs[] > 0.) {
double u2 = sqrt (sq(u.x[]) + sq(u.y[]));
if (u2 > un)
un = u2;
}
if (cs[] >= 1.) {
double r = sqrt (sq(x - Xd) + sq(y));
if (r < 0.5*Rd) {
pin += p[]*dv();
win += dv();
}
else if (r > 1.5*Rd && y < Rtube - 0.1) {
pout += p[]*dv();
wout += dv();
}
}
}
*dp = (win > 0. && wout > 0.) ? pin/win - pout/wout : nodata;
*umax = un;
}Adaptation
The whole point of the case.
adapt_wavelet() re-prolongates
cs, fs and f with
no knowledge of the axisymmetric metric or of which
cells are solid; embed_axi_metric_sync()
and vof_solid_cleanup() repair both,
exactly as simulationCases/bretherton.c
does. The SKIP_EMBED_GUARDS build omits
these calls and uses the limited initialisation
described above.
event adapt (i++)
{
adapt_wavelet ((scalar *){f, cs, u.x, u.y},
(double[]){fErr, csErr, uErr, uErr},
MAXLEVEL, MINLEVEL);
#if !defined(SKIP_EMBED_GUARDS)
embed_axi_metric_sync();
vof_solid_cleanup (f);
#endif
probe_couplings (i);
}Reporting
summarise() prints every measured
quantity, the verdict for each probe and a bare
PASS/FAIL line, then exits
with status 1 on failure. It is called at \(t = t_{end}\), and early if
the solution has become non-finite — in that case the
recorded first-firing data are still printed, which is
what distinguishes “failed on P1/P2” from “merely blew
up”.
static void grid_stats (long * ncells, int * lmin, int * lmax)
{
long n = 0;
int lo = 30, hi = 0;
foreach (reduction(+:n) reduction(min:lo) reduction(max:hi)) {
n++;
if (level < lo) lo = level;
if (level > hi) hi = level;
}
*ncells = n; *lmin = lo; *lmax = hi;
}The cut cells carry the embedded wall, so their count
and level range say directly whether the wall is
resolved — which is what coupling 3 of
src-local/embed-vof-tube.h asks a case to
monitor.
static void cut_cell_stats (long * ncut, int * lmin, int * lmax)
{
long n = 0;
int lo = 30, hi = 0;
foreach (reduction(+:n) reduction(min:lo) reduction(max:hi))
if (cs[] > 0. && cs[] < 1.) {
n++;
if (level < lo) lo = level;
if (level > hi) hi = level;
}
*ncut = n; *lmin = lo; *lmax = hi;
}
static void summarise (const char * note, int iter, bool fatal)
{
double dp, umax;
dp_umax (&dp, &umax);
double dpex = 2./Rd;
double edp = fabs (dp - dpex)/dpex;
long ncells, ncut; int lmin, lmax, lcmin, lcmax;
grid_stats (&ncells, &lmin, &lmax);
cut_cell_stats (&ncut, &lcmin, &lcmax);
bool p1 = (dcmMax < TOL_METRIC && dfmMax < TOL_METRIC);
bool p2 = (fSolidMax == 0.);
bool p3 = (edp < TOL_DP && umax < TOL_U);
#if defined(SKIP_EMBED_GUARDS)
printf ("build: SKIP_EMBED_GUARDS (negative control, "
"active-cell initialisation; no post-adaptation sync/cleanup)\n");
#else
printf ("build: guarded (embed_axi_metric_sync + vof_solid_cleanup "
"after every adapt_wavelet)\n");
#endif
printf ("stopped at: %s (t = %g, i = %d)\n", note, t, iter);
printf ("grid: %ld leaves, levels %d-%d, finest Delta = %.6e\n",
ncells, lmin, lmax, L0/(1 << lmax));
if (ncut > 0)
printf ("cut cells (embedded wall): %ld, levels %d-%d, "
"Delta at wall = %.6e\n", ncut, lcmin, lcmax,
L0/(1 << lcmax));
else
printf ("cut cells (embedded wall): none\n");
printf ("P1 metric consistency: max|cm - cm_ref| = %.6e, "
"max|fm - fm_ref| = %.6e (tol %.1e) -> %s\n",
dcmMax, dfmMax, (double) TOL_METRIC, p1 ? "pass" : "FAIL");
if (iFirstMetric >= 0)
printf (" first violated at i = %d, magnitude %.6e\n",
iFirstMetric, vFirstMetric);
printf ("P2 dispersed phase in solid: max|f| over cs <= 0 = %.6e "
"(required exactly 0) -> %s\n", fSolidMax, p2 ? "pass" : "FAIL");
if (iFirstSolid >= 0)
printf (" first violated at i = %d, magnitude %.6e\n",
iFirstSolid, vFirstSolid);
printf ("P3 Young-Laplace: dp = %.6e (exact %.6e, rel. error %.3e, "
"tol %.1e)\n", dp, dpex, edp, (double) TOL_DP);
printf (" max spurious velocity = %.6e at TOLERANCE %g (tol %.1e) -> %s\n",
umax, TOLERANCE, (double) TOL_U, p3 ? "pass" : "FAIL");A non-finite state is a failure in its own right, even with every probe green: the probes sample fluid cells, so NaNs confined to solid or cut cells would otherwise be reported as a pass.
bool ok = p1 && p2 && p3 && !fatal;
if (fatal)
printf ("divergence: the solution is not finite, so the probes above "
"are not meaningful\n");
printf (ok ? "PASS\n" : "FAIL\n");
fflush (stdout);
if (!ok)
exit (1);
}Divergence guard: if the solution is no longer finite there is nothing left to measure, so report what was recorded and stop. This never triggers in the guarded build.
static bool nonfinite_state (void)
{
double bad = 0.;
foreach (reduction(max:bad))
if (!isfinite (u.x[]) || !isfinite (u.y[]) || !isfinite (p[]))
bad = 1.;
return bad > 0.;
}
event logfile (t += 0.1; t <= tend)
{
double dp, umax;
dp_umax (&dp, &umax);
fprintf (stderr, "%g %g %.6e %.6e %.6e %.6e %.6e\n",
TOLERANCE, t, dp, umax, dcmMax, dfmMax, fSolidMax);
}
event sanity (i++)
{
if (nonfinite_state())
summarise ("non-finite solution", i, true);
}
event end (t = tend)
{
summarise ("t = tend", i, false);
}