0

I'm trying to use FiPy to solve for the transient behaviour of a simple 2D particle diffusion scenario with a small circular sink (particle concentration = n). I would like the sink to be saturable, i.e., there should be a maximum possible loss rate inside the sink. At the same time, the concentration should not be allowed to go negative. The precise behaviour of the sink should be:

  • When the total rate of particles entering the sink <= maximum rate, the sink absorbs all the particles inside it. This is like a Dirichlet boundary condition at the sink with n = 0.
  • When the total rate of particles entering the sink > maximum rate, the sink only absorbs particles at the maximum rate. I believe this could be expressed as a Neumann (flux) boundary condition at the perimeter of the sink.

The maximum rate is expressed in units of s^-1, i.e., it is the loss rate of particles inside the sink (not a rate of concentration loss). What is the most computationally-efficient way to express this in FiPy?

Background

In this scenario there is an initial Gaussian distribution in n, whose subsequent behaviour is governed by:

eq = TransientTerm() == DiffusionTerm(diff_coeff) - ImplicitSourceTerm(1/constant)

I'm dealing with orthotropic diffusion, so the diff_coeff variable is given in the form [[[D1, 0], [0, D2]]].

The aim is to see the impact of one small circular sink on this solution. Since the sink is much smaller than the initial distribution and diffusion length, I'm using a Gmsh mesh to reduce calculation time. In this mesh, the cell size becomes much smaller near the sink.

I've already done this for an "absolute" sink (i.e., one which fixes n to 0 locally by using a Dirichlet boundary condition on the perimeter of the sink):

n.constrain(value=0, where=mesh.physicalFaces["sink"])

Solving for n(t) in this case works very well, and I can take large implicit time steps without sacrificing too much accuracy.

My issue arises when I try to make the sink "zero-order", i.e., include it in the equation as:

sink = CellVariable(mesh=mesh)
sink.setValue(value=maximum_rate, where=sink_coords <= sink_radius)
eq = TransientTerm() == DiffusionTerm(diff_coeff) - ImplicitSourceTerm(1/constant) - sink

Solving for n(t) in this case also works well with large implicit steps, but when the maximum_rate is large, or the sink is far from the initial distribution, n can be negative around the sink. For my scenario, n is not allowed to be negative, and this is the main problem I'm facing.

If I try to control things so n does not go negative, I am unable to take large implicit time steps without losing a lot of accuracy (see Full code example at the end). Is there a way to implement a saturable sink while still being able to take large timesteps with reasonable accuracy? (Similar sized timesteps to the "absolute" sink case).

Attempted solutions

So far I have only tried naïve ways to solve the problem, all of which have resulted in the necessity of much smaller timesteps in order to get a reasonably accurate solution. One is to simply force n to be non-negative after each timestep:

eq = TransientTerm() == DiffusionTerm(diff_coeff) - ImplicitSourceTerm(1/constant) - sink
for step in range(steps):
    eq.solve(var=n, dt=time_step)
    n.setValue(value=0, where=n < 0)

The other is to completely exclude the sink from the FiPy equation, and instead manually account for it in each timestep:

eq = TransientTerm() == DiffusionTerm(diff_coeff) - ImplicitSourceTerm(1/constant)
for step in range(steps):
    eq.solve(var=n, dt=time_step)
    n.setValue(value = n - maximum_rate * time_step, where=sink_coords <= sink_radius)
    n.setValue(value=0, where=n < 0)

It isn't surprising that these methods need much smaller time_step. The first method overestimates the impact of the sink if time_step is large since (as far as I understand) in the eq.solve step diffusion can still be caused by negative n. The second method underestimates the sink impact with large time_step.

I imagine there should be some solution in which the dependence of sink on n could be explicitly expressed. Something like:

expression = n / time_step if n / time_step < maximum_rate else maximum_rate
sink.setValue(value=expression, where=sink_coords <= sink_radius)
eq = TransientTerm() == DiffusionTerm(diff_coeff) - ImplicitSourceTerm(1/constant) - sink

But I can't think of a way to express it properly. I suppose I would need some numerix function which is like a Heaviside function with a constant slope in the transition region?

Another solution could also be in using flux boundary conditions instead, but I am unsure about how to proceed with this. Any help provided is much appreciated, and thanks in advance! What follows is one full example of what I am currently doing.

Full code example

Using Python 3.10.4, FiPy 3.4.3, Gmsh 4.6.0.

My current simple test case is comparing an "absolute" sink (one which absorbs all particles, using Dirichlet condition n = 0) with a saturable sink with a very high maximum_rate. Ideally, these scenarios should give identical solutions, since the sink should behave as an absolute one until the total rate of particles entering the sink > maximum_rate - if maximum_rate is very high, this condition will never be met, so the sink should always be an absolute one.

from fipy import CellVariable, Gmsh2D, TransientTerm, DiffusionTerm, ImplicitSourceTerm
from fipy.tools import numerix

# Define some parameters.
Ds = [50000, 12500] # diffusion constants
sink_r = 1 # sink radius
initial_fwhm = 70 # initial state FWHM
max_time = 2 # up to which time we care about
x_sink = 300 # sink x coordinate
y_sink = 0 # sink y coordinate
constant = 4 # Background decay
use_dirichlet = False # if set to True, use n = 0 Dirichlet perimeter BC, no saturation.

# For testing purpose, set the maximum loss rate very high, so solution should match
# the use_dirichlet scenario.
maximum_rate = 1e5

# Define Gaussian function for initial condition.
def gauss(x, y, fwhm):
    sigma = fwhm / 2 * numerix.sqrt(2 * numerix.log(2))
    return numerix.exp(-(x**2 + y**2) / (2 * sigma**2)) / (sigma**2 * 2 * numerix.pi)

# Generate Gmsh.
major_radius = 2.5 * 2 * numerix.sqrt(Ds[0] * max_time) + 1.5 * initial_fwhm + 1.1 * sink_r
minor_radius = 2.5 * 2 * numerix.sqrt(Ds[1] * max_time) + 1.5 * initial_fwhm + 1.1 * sink_r
cell_size = minor_radius / 10
sink_cell_size = sink_r / 2
sink_extra_radius = sink_r * 3
sink_limit_radius = sink_extra_radius + 3 * (cell_size - sink_cell_size)
initial_cell_size = initial_fwhm / 4
initial_limit_radius = minor_radius * 0.66

# Set the sink as a "hole" in the surface if we are using n=0 BCs.
plane_surface = "19, 20" if use_dirichlet else "19"
embed = "" if use_dirichlet else "15, 16, 17, 18"

gmsh_script = f"""
    Point(1) = {{0, 0, 0, {initial_cell_size}}};
    Point(2) = {{0, {-minor_radius}, 0, {cell_size}}};
    Point(3) = {{{major_radius}, 0, 0, {cell_size}}};
    Point(4) = {{0, {minor_radius}, 0, {cell_size}}};
    Point(5) = {{{-major_radius}, 0, 0, {cell_size}}};
    Ellipse(6) = {{2, 1, 3}};
    Ellipse(7) = {{3, 1, 4}};
    Ellipse(8) = {{4, 1, 5}};
    Ellipse(9) = {{5, 1, 2}};
    Point(10) = {{{x_sink}, {y_sink}, 0, {sink_cell_size}}};
    Point(11) = {{{-sink_r + x_sink}, {y_sink}, 0, {sink_cell_size}}};
    Point(12) = {{{x_sink}, {sink_r + y_sink}, 0, {sink_cell_size}}};
    Point(13) = {{{sink_r + x_sink}, {y_sink}, 0, {sink_cell_size}}};
    Point(14) = {{{x_sink}, {-sink_r + y_sink}, 0, {sink_cell_size}}};
    Circle(15) = {{11, 10, 12}};
    Circle(16) = {{12, 10, 13}};
    Circle(17) = {{13, 10, 14}};
    Circle(18) = {{14, 10, 11}};
    Line Loop(19) = {{6, 7, 8, 9}};
    Line Loop(20) = {{15, 16, 17, 18}};
    Plane Surface(21) = {{{plane_surface}}};
    Curve{{{embed}}} In Surface{{21}};

    Physical Surface("main") = {{21}};
    Physical Line("sink") = {{15, 16, 17, 18}};

    Field[1] = Distance;
    Field[1].NodesList = {{1}};
    Field[2] = Distance;
    Field[2].NodesList = {{10}};

    Field[3] = Threshold;
    Field[3].IField = 1;
    Field[3].LcMin = {initial_cell_size};
    Field[3].LcMax = {cell_size};
    Field[3].DistMin = 0;
    Field[3].DistMax = {initial_limit_radius};
    Field[4] = Threshold;
    Field[4].IField = 2;
    Field[4].LcMin = {sink_cell_size};
    Field[4].LcMax = {cell_size};
    Field[4].DistMin = {sink_extra_radius};
    Field[4].DistMax = {sink_limit_radius};

    Field[5] = Min;
    Field[5].FieldsList = {{3, 4}};
    Background Field = 5;

    Mesh.CharacteristicLengthExtendFromBoundary = 0;
    Mesh.CharacteristicLengthFromPoints = 0;
    Mesh.CharacteristicLengthFromCurvature = 0;
    """

mesh = Gmsh2D(gmsh_script)

# Set up n.
n = CellVariable(name="solution variable", mesh=mesh, value=0.0)
x, y = mesh.cellCenters
n.setValue(gauss(x, y, 70))

# Set up sink.
sink_coords = numerix.sqrt((x - x_sink) ** 2 + y**2)
adj_rate = maximum_rate / (numerix.pi * sink_r**2) # Adjust for the size of the sink
sink = CellVariable(mesh=mesh)
sink.setValue(value=adj_rate, where=sink_coords <= sink_r)

# Set up equation.
diff_coeff = [[[Ds[0], 0], [0, Ds[1]]]]
eq = TransientTerm() == DiffusionTerm(diff_coeff) - ImplicitSourceTerm(1/constant) - sink
if use_dirichlet: n.constrain(value=0, where=mesh.physicalFaces["sink"])

# Define time step based on mesh.
time_step = 0.25 * numerix.mean(mesh.cellVolumes) / (4 * min(Ds))
steps = int(max_time / time_step)

# Solve up to max_time.
for step in range(steps):
    eq.solve(var=n, dt=time_step)
    n.setValue(value=0, where=n<0)

If the n returned by this code with use_dirichlet = False (attempted saturable sink case) is compared to the n returned when use_dirichlet = True (absolute sink with n=0 boundary condition), the discrepancy is clear. Again, ideally these two cases should return the same solution for n at time max_time since the maximum_rate is set to be very high.

  • To be clear, I have read [a previous relevant answer](https://stackoverflow.com/questions/36385367/solving-pde-with-sources-in-python) on stack overflow, but it didn't solve this issue as I do not want to make the sink entirely first-order (not saturable). There was meant to be more discussion on [a FiPy mailing list](http://thread.gmane.org/gmane.comp.python.fipy/4034) but the link no longer seems to work... – thomasfjord Aug 22 '22 at 16:56
  • [Gmane is effectively dead](https://lars.ingebrigtsen.no/2020/01/15/news-gmane-org-is-now-news-gmane-io/). That earlier mailing list discussion is now available at https://groups.google.com/a/list.nist.gov/g/fipy/c/juv9VBMKVXM/m/J0Feh91yAgAJ. – jeguyer Aug 22 '22 at 20:50
  • @jeguyer Thanks for the link. The discussion is useful, but in the end gives similar information to the related stack overflow answer - saturated sinks were touched upon but in the end a first-order sink (with loss rate proportional to concentration) was recommended. Such a first-order sink is really not physically reasonable for the system I'm trying to characterise. So I would still be really grateful for any help you could provide on getting a saturable sink to work! – thomasfjord Aug 23 '22 at 09:18
  • I'm starting to think the best way to handle this is with changing boundary conditions. Such a saturable sink should be handled by: - A Dirichlet boundary condition at the sink perimeter with *n* = 0 when the total rate of particles entering the sink < `maximum_rate`. - A Neumann boundary condition otherwise, fixing the flux at the perimeter so the rate of particles entering the sink = `maximum_rate`. I'm just having a lot of trouble implementing this at the moment, especially with Gmsh mesh. – thomasfjord Aug 23 '22 at 13:11
  • You're making a bunch of phenomenological statements but, ultimately, FiPy is about solving math problems. I recommend that you: (1) express `sink` mathematically, (2) get rid of the anisotropic diffusivity, (3) get rid of the anisotropic mesh... – jeguyer Aug 23 '22 at 17:51
  • ...(4) get rid of the Gaussian initial condition, (5) stop worrying about "computationally-efficient" and "large timesteps", (6) figure things out on a simple 3x3 or 5x5 grid with a simple (probably constant) profile in `n` where you can do everything by hand and make sure your desired conservation laws are being obeyed. Right now, all that stuff is cluttering up the problem and not helping figure out the sink. – jeguyer Aug 23 '22 at 17:53

1 Answers1

0

I can't tell what you actually want the magnitude of sink to be:

  • n / time_step?
  • n / (numerix.pi * sink_r**2)?
  • n / (numerix.pi * sink_r**2 * time_step)?
  • something else?

Setting that aside, assuming the sink should be rate * n

# expression = n / time_step if n / time_step < maximum_rate else maximum_rate

sink = (ImplicitSourceTerm(rate * (rate * n < maximum_rate) * (sink_coords <= sink_r))
        + maximum_rate * (rate * n >= maximum_rate) * (sink_coords <= sink_r))

This can still gives some very small magnitude negative values, but so does removing the sink completely. The LinearLUSolver() seems less prone to that. Regardless, the values seem pretty small and don't lead to any obvious instability.

What criteria are you using to conclude that you are "unable to take large implicit time steps without losing a lot of accuracy"?

jeguyer
  • 2,379
  • 1
  • 11
  • 15
  • Thanks for your help! I'm sorry for being unclear about the magnitude of the sink. It is a similar sink to the mailing list scenario. The cases are: – thomasfjord Aug 23 '22 at 12:24
  • No space in the comments unfortunately! I will adjust the beginning of my question to be more clear. Essentially, if the total rate of particles entering the sink is less than `maximum_rate`, then the sink should act as an absolute one, i.e., it fixes the concentration to 0 locally. Hence the criterion I'm using is the comparison between an absolute sink (Dirichlet boundary condition *n*=0) and a saturable sink with a very high `maximum_rate`. This can be done in my example code using `use_dirichlet`. – thomasfjord Aug 23 '22 at 12:35
  • My `maximum_rate` is expressed in units of s^-1, i.e., maximum total particle loss rate inside the sink. Since the sink is divided into cells with units m^-2 s^-1, this is why I set the cell values of the `sink` to `maximum_rate/ (pi * sink_r**2)` - I'm just matching the units. Sometimes `time_step` is included because I'm trying to get the sink to act as an absolute one when the total rate of particles entering the sink is less than `maximum_rate`, but I think this is a bad idea which prevents large `time_step`. – thomasfjord Aug 23 '22 at 13:05