Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Limiter to clip fields to zero #479

Merged
merged 3 commits into from
Feb 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions examples/compressible/unsaturated_bubble.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
VCG1 = FunctionSpace(mesh, "CG", 1)
Vu_DG1 = VectorFunctionSpace(mesh, VDG1.ufl_element())
Vu_CG1 = VectorFunctionSpace(mesh, "CG", 1)
Vt = domain.spaces("theta")

u_opts = RecoveryOptions(embedding_space=Vu_DG1,
recovered_space=Vu_CG1,
Expand All @@ -92,10 +93,12 @@
# Physics schemes
# NB: can't yet use wrapper or limiter options with physics
rainfall_method = DGUpwind(eqns, 'rain', outflow=True)
zero_limiter = MixedFSLimiter(eqns, {'water_vapour': ZeroLimiter(Vt),
'cloud_water': ZeroLimiter(Vt)})
physics_schemes = [(Fallout(eqns, 'rain', domain, rainfall_method), SSPRK3(domain)),
(Coalescence(eqns), ForwardEuler(domain)),
(EvaporationOfRain(eqns), ForwardEuler(domain)),
(SaturationAdjustment(eqns), ForwardEuler(domain))]
(SaturationAdjustment(eqns), ForwardEuler(domain, limiter=zero_limiter))]

# Time stepper
stepper = SemiImplicitQuasiNewton(eqns, io, transported_fields,
Expand All @@ -116,7 +119,6 @@

# spaces
Vu = domain.spaces("HDiv")
Vt = domain.spaces("theta")
Vr = domain.spaces("DG")
x, z = SpatialCoordinate(mesh)
quadrature_degree = (4, 4)
Expand Down
37 changes: 37 additions & 0 deletions gusto/kernels.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,40 @@ def apply(self, field_hat, field_DG1, field_old):
"field_DG1": (field_DG1, READ),
"field_old": (field_old, READ)},
is_loopy_kernel=True)


class ClipZero():
"""Clips any negative field values to be zero."""

def __init__(self, V):
"""
Args:
V (:class:`FunctionSpace`): The space of the field to be clipped.
"""
shapes = {'nDOFs': V.finat_element.space_dimension()}
domain = "{{[i]: 0 <= i < {nDOFs}}}".format(**shapes)

instrs = ("""
for i
if field_in[i] < 0.0
field[i] = 0.0
else
field[i] = field_in[i]
end
end
""")

self._kernel = (domain, instrs)

def apply(self, field, field_in):
"""
Performs the par loop.

Args:
field (:class:`Function`): The field to be written to.
field_in (:class:`Function`): The field to be clipped.
"""
par_loop(self._kernel, dx,
{"field": (field, WRITE),
"field_in": (field_in, READ)},
is_loopy_kernel=True)
51 changes: 49 additions & 2 deletions gusto/limiters.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
from firedrake import (BrokenElement, Function, FunctionSpace, interval,
FiniteElement, TensorProductElement)
from firedrake.slope_limiter.vertex_based_limiter import VertexBasedLimiter
from gusto.kernels import LimitMidpoints
from gusto.kernels import LimitMidpoints, ClipZero

import numpy as np

__all__ = ["DG1Limiter", "ThetaLimiter", "NoLimiter", "MixedFSLimiter"]
__all__ = ["DG1Limiter", "ThetaLimiter", "NoLimiter", "ZeroLimiter",
"MixedFSLimiter"]


class DG1Limiter(object):
Expand Down Expand Up @@ -163,6 +164,52 @@ def apply(self, field):
field.interpolate(self.field_hat)


class ZeroLimiter(object):
"""
A simple limiter to enforce non-negativity of a field pointwise.

Negative values are simply clipped to be zero. There is also the option to
project the field to another function space to enforce non-negativity there.
"""

def __init__(self, space, clipping_space=None):
"""
Args:
space (:class:`FunctionSpace`): the space of the incoming field to
clip.
clipping_space (:class:`FunctionSpace`, optional): the space in
which to clip the field. If not specified, the space of the
input field is used.
"""

self.space = space
if clipping_space is not None:
self.clipping_space = clipping_space
self.map_to_clip = True
self.field_to_clip = Function(self.clipping_space)
else:
self.clipping_space = space
self.map_to_clip = False

self._kernel = ClipZero(self.clipping_space)

def apply(self, field):
"""
The application of the limiter to the field.

Args:
field (:class:`Function`): the field to apply the limiter to.
"""

# Obtain field in clipping space
if self.map_to_clip:
self.field_to_clip.interpolate(field)
self._kernel.apply(self.field_to_clip, self.field_to_clip)
field.interpolate(self.field_to_clip)
else:
self._kernel.apply(field, field)


class NoLimiter(object):
"""A blank limiter that does nothing."""

Expand Down
174 changes: 174 additions & 0 deletions integration-tests/transport/test_zero_limiter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"""
This tests the ZeroLimiter, which enforces non-negativity.
A sharp bubble of warm air is generated in a vertical slice and then transported
by a prescribed transport scheme. If the limiter is working, the transport
should have produced no negative values.
"""

from gusto import *
from firedrake import (as_vector, PeriodicIntervalMesh, pi, SpatialCoordinate,
ExtrudedMesh, FunctionSpace, Function, norm,
conditional, sqrt)
import numpy as np
import pytest


def setup_zero_limiter(dirname, clipping_space):

# ------------------------------------------------------------------------ #
# Parameters for test case
# ------------------------------------------------------------------------ #

Ld = 1.
tmax = 0.2
dt = tmax / 40
rotations = 0.25

# ------------------------------------------------------------------------ #
# Build model objects
# ------------------------------------------------------------------------ #

# Domain
m = PeriodicIntervalMesh(20, Ld)
mesh = ExtrudedMesh(m, layers=20, layer_height=(Ld/20))
degree = 1

domain = Domain(mesh, dt, family="CG", degree=degree)

DG1 = FunctionSpace(mesh, 'DG', 1)
DG1_equispaced = domain.spaces('DG1_equispaced')

Vpsi = domain.spaces('H1')

eqn = AdvectionEquation(domain, DG1, 'tracer')
output = OutputParameters(dirname=dirname+'/limiters',
dumpfreq=1, dumplist=['u', 'tracer', 'true_tracer'])

io = IO(domain, output)

# ------------------------------------------------------------------------ #
# Set up transport scheme
# ------------------------------------------------------------------------ #

if clipping_space is None:
limiter = ZeroLimiter(DG1)
elif clipping_space == 'equispaced':
limiter = ZeroLimiter(DG1, clipping_space=DG1_equispaced)

transport_schemes = SSPRK3(domain, limiter=limiter)
transport_method = DGUpwind(eqn, "tracer")

# Build time stepper
stepper = PrescribedTransport(eqn, transport_schemes, io, transport_method)

# ------------------------------------------------------------------------ #
# Initial condition
# ------------------------------------------------------------------------ #

tracer0 = stepper.fields('tracer', DG1)
true_field = stepper.fields('true_tracer', space=DG1)

x, z = SpatialCoordinate(mesh)

tracer_min = 12.6
dtracer = 3.2

# First time do initial conditions, second time do final conditions
for i in range(2):

if i == 0:
x1_lower = 2 * Ld / 5
x1_upper = 3 * Ld / 5
z1_lower = 6 * Ld / 10
z1_upper = 8 * Ld / 10
x2_lower = 6 * Ld / 10
x2_upper = 8 * Ld / 10
z2_lower = 2 * Ld / 5
z2_upper = 3 * Ld / 5
elif i == 1:
# Rotated anti-clockwise by 90 degrees (x -> z, z -> -x)
x1_lower = 2 * Ld / 10
x1_upper = 4 * Ld / 10
z1_lower = 2 * Ld / 5
z1_upper = 3 * Ld / 5
x2_lower = 2 * Ld / 5
x2_upper = 3 * Ld / 5
z2_lower = 6 * Ld / 10
z2_upper = 8 * Ld / 10
else:
raise ValueError

expr_1 = conditional(x > x1_lower,
conditional(x < x1_upper,
conditional(z > z1_lower,
conditional(z < z1_upper, dtracer, 0.0),
0.0),
0.0),
0.0)

expr_2 = conditional(x > x2_lower,
conditional(x < x2_upper,
conditional(z > z2_lower,
conditional(z < z2_upper, dtracer, 0.0),
0.0),
0.0),
0.0)

if i == 0:
tracer0.interpolate(Constant(tracer_min) + expr_1 + expr_2)
elif i == 1:
true_field.interpolate(Constant(tracer_min) + expr_1 + expr_2)
else:
raise ValueError

# ------------------------------------------------------------------------ #
# Velocity profile
# ------------------------------------------------------------------------ #

psi = Function(Vpsi)
u = stepper.fields('u')

# set up solid body rotation for transport
# we do this slightly complicated stream function to make the velocity 0 at edges
# thus we avoid odd effects at boundaries
xc = Ld / 2
zc = Ld / 2
r = sqrt((x - xc) ** 2 + (z - zc) ** 2)
omega = rotations * 2 * pi / tmax
r_out = 9 * Ld / 20
r_in = 2 * Ld / 5
A = omega * r_in / (2 * (r_in - r_out))
B = - omega * r_in * r_out / (r_in - r_out)
C = omega * r_in ** 2 * r_out / (r_in - r_out) / 2
psi_expr = conditional(r < r_in,
omega * r ** 2 / 2,
conditional(r < r_out,
A * r ** 2 + B * r + C,
A * r_out ** 2 + B * r_out + C))
psi.interpolate(psi_expr)

gradperp = lambda v: as_vector([-v.dx(1), v.dx(0)])
u.project(gradperp(psi))

return stepper, tmax, true_field


@pytest.mark.parametrize('space', [None, 'equispaced'])
def test_zero_limiter(tmpdir, space):

# Setup and run
dirname = str(tmpdir)

stepper, tmax, true_field = setup_zero_limiter(dirname, space)

stepper.run(t=0, tmax=tmax)

final_field = stepper.fields('tracer')

# Check tracer is roughly in the correct place
assert norm(true_field - final_field) / norm(true_field) < 0.05, \
'Something appears to have gone wrong with transport of tracer using a limiter'

# Check for no new overshoots
assert np.min(final_field.dat.data) >= 0.0, \
'Application of limiter has not prevented negative values'
40 changes: 40 additions & 0 deletions unit-tests/kernel_tests/test_clip_zero_kernel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""
A test of the ClipZero kernel, which is used to enforce non-negativity.
"""

from firedrake import UnitSquareMesh, Function, FunctionSpace
from gusto import kernels
import numpy as np


def test_clip_zero():

# ------------------------------------------------------------------------ #
# Set up meshes and spaces
# ------------------------------------------------------------------------ #

mesh = UnitSquareMesh(3, 3)

DG1 = FunctionSpace(mesh, "DG", 1)

field = Function(DG1)

# ------------------------------------------------------------------------ #
# Initial conditions
# ------------------------------------------------------------------------ #

field.dat.data[:] = -3.0

# ------------------------------------------------------------------------ #
# Apply kernel
# ------------------------------------------------------------------------ #

kernel = kernels.ClipZero(DG1)
kernel.apply(field, field)

# ------------------------------------------------------------------------ #
# Check values
# ------------------------------------------------------------------------ #

assert np.all(field.dat.data >= 0.0), \
'ClipZero kernel is not enforcing non-negativity'
Loading