Binder

3. Advanced Customization

The core principle of DustPy is that you can change anything easily. Not only the initial conditions as shown in the previous chapter, but also the physics behind the simulation.

[1]:
from dustpy import Simulation
[2]:
sim = Simulation()

Customizing the Grids

By default the radial and the mass grid will be created when calling Simulation.initialize(). But there can be situations where you need to know the grid sizes before completely initializing the Simulation object. For example if you want to create custom fields and you need to initialize them with the correct shape.

In that case you can call Simulation.makegrids() to only create the grids without initializing the simulation objects. In fact, Simulation.makegrids() is by default called within Simulation.initialize().

[3]:
sim.grid
[3]:
Group (Grid quantities)
-----------------------
    A            : NoneType
    m            : NoneType
    Nm           : NoneType
    Nr           : NoneType
    OmegaK       : NoneType
    r            : NoneType
    ri           : NoneType
  -----
[4]:
sim.makegrids()
[5]:
sim.grid
[5]:
Group (Grid quantities)
-----------------------
    A            : Field (Radial grid annulus area [cm²]), constant
    m            : Field (Mass grid [g]), constant
    Nm           : Field (# of mass bins), constant
    Nr           : Field (# of radial grid cells), constant
    r            : Field (Radial grid cell centers [cm]), constant
    ri           : Field (Radial grid cell interfaces [cm]), constant
  -----
    OmegaK       : NoneType
  -----

Note that the Keplerian frequency has not been initialized at this point.

The Radial Grid

By default the radial grid is a regular logarithmic grid. Meaning, the ratio of adjacent grid cells is constant.

[6]:
sim.grid.r[1:]/sim.grid.r[:-1]
[6]:
[1.07151931 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931
 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931
 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931
 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931
 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931
 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931
 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931
 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931
 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931
 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931
 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931
 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931
 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931
 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931
 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931
 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931 1.07151931
 1.07151931 1.07151931 1.07151931]
As explained in the previous chapter, the location of the grid boundaries and the number of grid cells can be controlled via Simulation.ini.grid.rmin, Simulation.ini.grid.rmax, and Simulation.ini.grid.Nr.
Simulation.makegrids() will use these parameters to create the radial grid.

But it is also possible to completely customize the radial grid. To do so you have to set the locations of the radial grid cell interfaces Simulation.grid.ri before calling either Simulation.makegrids() or Simulation.initialize().

In this example we simply want to refine the grid at a given location. We use this helper function, which takes an existing grid ri and doubles the number of grid cells in a region num grid cells on both sides around location r0. We also recursively call this function with reduced num to even further refine the grid and to have a smooth transition between the high and low resolution regions.

[7]:
import numpy as np

def refinegrid(ri, r0, num=3):
    """Function to refine the radial grid

    Parameters
    ----------
    ri : array
        Radial grid
    r0 : float
        Radial location around which grid should be refined
    num : int, option, default : 3
        Number of refinement iterations

    Returns
    -------
    ri : array
        New refined radial grid"""
    if num == 0:
        return ri
    ind = np.argmin(r0 > ri) - 1
    indl = ind-num
    indr = ind+num+1
    ril = ri[:indl]
    rir = ri[indr:]
    N = (2*num+1)*2
    rim = np.empty(N)
    for i in range(0, N, 2):
        j = ind-num+int(i/2)
        rim[i] = ri[j]
        rim[i+1] = 0.5*(ri[j]+ri[j+1])
    ri = np.concatenate((ril, rim, rir))
    return refinegrid(ri, r0, num=num-1)

We now create a regular logarithmic grid and feed it to our function. We want to refine the grid in a location around \(4.5\,\mathrm{AU}\).

[8]:
import dustpy.constants as c
[9]:
ri = np.logspace(0., 3., num=100, base=10.) * c.au
[10]:
ri = refinegrid(ri, 4.5*c.au, num=3)

We can now create a new empty Simulation object, assign the grid cell interfaces and initialize the grids.

[11]:
sim = Simulation()
[12]:
sim.grid.ri = ri
[13]:
sim.grid
[13]:
Group (Grid quantities)
-----------------------
    A            : NoneType
    m            : NoneType
    Nm           : NoneType
    Nr           : NoneType
    OmegaK       : NoneType
    r            : NoneType
    ri           : ndarray
  -----

Note: it is sufficient to assign a numpy.ndarray to Simulation.grid.ri and not a simframe.Field.

We can now make the grids.

[14]:
sim.makegrids()
[15]:
sim.grid
[15]:
Group (Grid quantities)
-----------------------
    A            : Field (Radial grid annulus area [cm²]), constant
    m            : Field (Mass grid [g]), constant
    Nm           : Field (# of mass bins), constant
    Nr           : Field (# of radial grid cells), constant
    r            : Field (Radial grid cell centers [cm]), constant
    ri           : Field (Radial grid cell interfaces [cm]), constant
  -----
    OmegaK       : NoneType
  -----

As you can see, Simulation.grid.ri was automatically converted to a simframe.Field and the other fields were created. The number of radial grid cells is greater than \(100\) as we added more grid cells.

[16]:
sim.grid.Nr
[16]:
114

To see that we actually refined the grid at the correct location, we can plot the location of the radial grid cells.

[17]:
import matplotlib.pyplot as plt
[18]:
fig = plt.figure(dpi=150)
ax = fig.add_subplot(111)
ax.semilogy(sim.grid.r/c.au)
ax.axhline(4.5, c="gray", lw=1)
ax.set_xlabel("# of radial grid cell")
ax.set_ylabel("Location of radial grid cell [AU]")
fig.tight_layout()
plt.show()
_images/3_advanced_customization_28_0.png

The position of the radial grid cells have to be exactly in the center between their grid cell interfaces and are automatically calculated by Simulation.makegrids().

[19]:
sim.grid.r == 0.5 * (sim.grid.ri[1:] + sim.grid.ri[:-1])
[19]:
[ True  True  True  True  True  True  True  True  True  True  True  True
  True  True  True  True  True  True  True  True  True  True  True  True
  True  True  True  True  True  True  True  True  True  True  True  True
  True  True  True  True  True  True  True  True  True  True  True  True
  True  True  True  True  True  True  True  True  True  True  True  True
  True  True  True  True  True  True  True  True  True  True  True  True
  True  True  True  True  True  True  True  True  True  True  True  True
  True  True  True  True  True  True  True  True  True  True  True  True
  True  True  True  True  True  True  True  True  True  True  True  True
  True  True  True  True  True  True]

The Mass Grid

You should NEVER set the mass grid manually! The mass grid has to be strictly logarithmic. Only customize the mass grid by setting Simulation.ini.grid.mmin, Simulation.ini.grid.mmax, and Simulation.ini.grid.Nmbpd.

If you have to create your own non-logarithmic mass grid for some reason, be aware that you have to re-write the entire coagulation algorithm as well, since it only conserves mass on a logarithmic grid.

Customizing the Physics of a Field

In this example we want to have a fragmentation velocity that depends on the temperature in the disk. Is the temperature below 150 K, we want to have a fragmentation velocity of 10 m/s, otherwise it shall be 1 m/s. The idea behind this approach is than particles coated in water ice are stickier that pure silicate particles and can widthstand higher collision velocities. See for example Pinilla et al. (2017). However, keep in mind that newer experiments suggest that particles covered in water ice do not have a beneficial collisional behavior, see e.g. Musiolik & Wurm (2019).

First, we initialize our simulation object.

[20]:
sim.initialize()

The fragmentation velocity has the shape (Nr,), meaning there is one value at every location in the grid.

[21]:
sim.dust.v.frag.shape
[21]:
(114,)

But right now it’s constant.

[22]:
fig = plt.figure(dpi=150)
ax = fig.add_subplot(111)
ax.semilogx(sim.grid.r/c.au, sim.dust.v.frag)
ax.set_xlabel("Distance from star [AU]")
ax.set_ylabel("Fragmentation velocity [cm/s]")
fig.tight_layout()
plt.show()
_images/3_advanced_customization_37_0.png

We have to write a function that takes the simulation object as input parameter and returns our desired fragmentation velocities. We can use the fact that the gas temperature has the same shape. Keep in mind that everything has to be in cgs units.

[23]:
sim.gas.T.shape
[23]:
(114,)
[24]:
def v_frag(sim):
    return np.where(sim.gas.T<150., 1000., 100)

We can now assign this function to the updater of the dust fragmentation velocities. For details of this process, please have a look at the Simframe documentation.

[25]:
sim.dust.v.frag.updater = v_frag

The updater of a group/field stores a simframe.Heatbeat object. When calling the update() function the heartbeat will be executed which consists of a systole, the actual updater, and a diastole. The systole is executed before the actual update functions, the diastole afterwards.

When assigning a function (or None) to the updater of a group/field a new Heartbeat object will be created with empty systoles and diastoles only executing the update function. If the existing updater already has systoles/diastoles, those would be overwritten with an empty function.

To prevent this you can directly assign the function only to the updater leaving the systoles/diastoles as they are.

[26]:
sim.dust.v.updater.updater = v_frag

The systoles/diastoles can be set with the following command. Only for demonstration here, since we assign None. Read more about this in the section about Systoles and Diastoles.

[27]:
sim.dust.v.updater.systole = None
sim.dust.v.updater.diastole = None

As of now, the simulation object still holds the old data for the fragmentation velocity. We have to tell it to update itself. We can either update the whole simulation frame with Simulation.update(), or we just update the fragmentation velocities.

[28]:
sim.dust.v.frag.update()

The fragmentation velocities should now show our desired behavior.

[29]:
fig = plt.figure(dpi=150)
ax = fig.add_subplot(111)
ax.semilogx(sim.grid.r/c.au, sim.dust.v.frag)
ax.set_xlabel("Distance from star [AU]")
ax.set_ylabel("Fragmentation velocity [cm/s]")
fig.tight_layout()
plt.show()
_images/3_advanced_customization_50_0.png

Note: If you customized a quantity on which other quantities depend on, you also have to update these quantities. In our case this would be the sticking/fragmentation probabilites. So it is always better to update the whole simulation frame.

[30]:
sim.update()

Adding Custom Fields

We can not only modify existing fields, we can also create our own fields.

In this example we want to add another field rsnow to Simulation.grid, that gives us the location of the so called snowline, i.e., the location in the disk where water ice starts to sublime.

First, we add the field and initialize it with zero.

[31]:
sim.grid.addfield("rsnow", 0., description="Snowline location [cm]")

The grid group has now a new member.

[32]:
sim.grid
[32]:
Group (Grid quantities)
-----------------------
    A            : Field (Radial grid annulus area [cm²]), constant
    m            : Field (Mass grid [g]), constant
    Nm           : Field (# of mass bins), constant
    Nr           : Field (# of radial grid cells), constant
    OmegaK       : Field (Keplerian frequency [1/s])
    r            : Field (Radial grid cell centers [cm]), constant
    ri           : Field (Radial grid cell interfaces [cm]), constant
    rsnow        : Field (Snowline location [cm])
  -----

As a next step we have to write a function that returns us the location of the snowline. Here we simply use the first grid cell where the temperature is smaller than \(150\,\mathrm{K}\) and return the value of the inner interface of that grid cell.

[33]:
def rsnow(sim):
    isnow = np.argmax(sim.gas.T<150.)
    return sim.grid.ri[isnow]

We assign this function to the updater of our snowline field.

[34]:
sim.grid.rsnow.updater.updater = rsnow

And update the field.

[35]:
sim.grid.rsnow.update()
[36]:
print("The snowline is located at {:4.2f} AU.".format(sim.grid.rsnow/c.au))
The snowline is located at 2.15 AU.

Right now the temperature is constant throughout the simulation, because the stellar parameters do not change. To see an effect in our snowline location, we need to have a changing temperature profile.

To achieve this, we let the stellar radius decrease from a value of \(3\,M_\odot\) to \(2\,M_\odot\) within the first \(10,000\,\mathrm{yrs}\). This results in decreasing disk temperature. This is only for demonstration purposes and is not necessarily physical.

[37]:
def Rstar(sim):
    dR = -1.*c.R_sun
    dt = 1.e4 * c.year
    m = dR/dt
    R = m*sim.t + 3.*c.R_sun
    R = np.maximum(R, c.R_sun)
    return R

And assign this to the updater of the stellar radius.

[38]:
sim.star.R.updater.updater = Rstar

Modifying the Update Order

But we are still not done, yet. We have given DustPy instructions how to update the snowline location, but we have not yet told it to actually update it regularily.

DustPy calls Simulation.update(), the updater of the simulation object, once per timestep after the integration step and just before writing the data files. The updater of a group/field is basically a list of groups/fields, whose updater is called in that order.

For the main simulation object this is

[39]:
sim.updateorder
[39]:
['star', 'grid', 'gas', 'dust']

This means that if you call Simulation.update() you basically call Simulation.star.update(), Simulation.grid.update(), Simulation.gas.update(), and Simulation.dust.update() in that order.

The updaters of the sub-groups and fields look as follows

[40]:
sim.star.updateorder
[40]:
['M', 'R', 'T', 'L']
[41]:
sim.grid.updateorder
[41]:
['OmegaK']
[42]:
sim.gas.updateorder
[42]:
['gamma',
 'mu',
 'T',
 'alpha',
 'cs',
 'Hp',
 'nu',
 'rho',
 'n',
 'mfp',
 'P',
 'eta',
 'S']
[43]:
sim.gas.S.updateorder
[43]:
['ext', 'tot']
[44]:
sim.gas.v.updateorder
[44]:
['visc', 'rad']
[45]:
sim.dust.updateorder
[45]:
['delta',
 'rhos',
 'fill',
 'a',
 'St',
 'H',
 'rho',
 'backreaction',
 'v',
 'D',
 'eps',
 'kernel',
 'p',
 'S']
[46]:
sim.dust.backreaction.updateorder
[46]:
['A', 'B']
[47]:
sim.dust.delta.updateorder
[47]:
['rad', 'turb', 'vert']
[48]:
sim.dust.Fi.updateorder
[48]:
['adv', 'diff', 'tot']
[49]:
sim.dust.p.updateorder
[49]:
['frag', 'stick']
[50]:
sim.dust.S.updateorder
[50]:
['ext', 'tot']
[51]:
sim.dust.v.updateorder
[51]:
['frag', 'driftmax', 'rel']
[52]:
sim.dust.v.rel.updateorder
[52]:
['azi', 'brown', 'rad', 'turb', 'vert', 'tot']

Note: The gas updater does not contain the updaters of Simulation.gas.v and Simulation.gas.Fi and the updater of the gas sources does not contain the updater of Simulation.gas.S.hyd. These are quantities that are calculated in the finalization step of the integrator, since they are derived from the result of the implicit gas integration.

The same is true for Simulation.dust.v.rad, Simulation.dust.Fi, Simulation.dust.S.coag, and Simulation.dust.S.hyd in the dust updater, which are also calculated from the implicit integration in the finalization step of the integrator

As you can see, the grid updater is only updating the Keplerian frequency, but not our snowline location. So we can simply adding it to the list.

[53]:
sim.grid.updater = ["OmegaK", "rsnow"]

If you assign lists to updaters, their systoles and diastoles will always be overwritten with None.

[54]:
sim.grid.updateorder
[54]:
['OmegaK', 'rsnow']

Systoles and Diastoles

However, the previous solution has a conceptional problem. As you can see from the update order previously the grid is updated before the gas. The snowline location, however, needs the gas temperature and, therefore, has to be updated after the gas. But we also cannot update the grid as a whole after the gas, because the gas updaters need the Keplerian frequency. We need another solution.

But first, we revert the grid updater.

[55]:
sim.grid.updater = ["OmegaK"]

Every updater has a systole and a diastole. That is a function that is called before respectively after the actual updater. Since no other quantity depends on our snowline location, we can simply update it at the end and put it in the diastole of the main updater. Or we could assign it to the diastole of the gas temperature updater, since it only requires the updated gas temperature.

We therefore write a diastole function, that is updating the snowline location separately.

[56]:
def diastole(sim):
    sim.grid.rsnow.update()

And assign this function to the diastole of the gas temperature updater.

[57]:
sim.gas.T.updater.diastole = diastole

Now every time Simulation.gas.T.update() is called, Simulation.grid.rsnow.update() will be called at the end of it.

Customizing the Snapshots

As already explained in a previous chapter, the snapshots can be customized by simply setting Simulation.t.snapshots. In this example we only want to run the simulation for \(10,000\,\mathrm{yrs}\). The snapshots have to include the starting time to write out the initial conditions.

[58]:
sim.t.snapshots = np.hstack(
    [sim.t, np.geomspace(1.e3, 1.e4, num=21) * c.year]
)

We can now change the data directory to avoid an overwrite error and start the simulation with our modifications.

[59]:
sim.writer.datadir = "3_data"
[60]:
sim.run()

DustPy v1.0.6

Documentation: https://stammler.github.io/dustpy/
PyPI:          https://pypi.org/project/dustpy/
GitHub:        https://github.com/stammler/dustpy/

Please cite Stammler & Birnstiel (2022).

Checking for mass conservation...

    - Sticking:
        max. rel. error:  2.75e-14
        for particle collision
            m[114] =  1.93e+04 g    with
            m[116] =  3.73e+04 g
    - Full fragmentation:
        max. rel. error:  5.55e-16
        for particle collision
            m[55] =  7.20e-05 g    with
            m[55] =  7.20e-05 g
    - Erosion:
        max. rel. error:  1.78e-15
        for particle collision
            m[110] =  5.18e+03 g    with
            m[118] =  7.20e+04 g

Creating data directory 3_data.
Writing file 3_data/data0000.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0001.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0002.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0003.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0004.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0005.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0006.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0007.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0008.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0009.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0010.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0011.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0012.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0013.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0014.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0015.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0016.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0017.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0018.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0019.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0020.hdf5
Writing dump file 3_data/frame.dmp
Writing file 3_data/data0021.hdf5
Writing dump file 3_data/frame.dmp
Execution time: 0:05:30

We can now have a look at the result of our modifications.

[61]:
from dustpy import plot
[62]:
plot.panel(sim)
_images/3_advanced_customization_104_0.png

The change in fragmentation velocity has an obvious effect on the particles sizes.

To check the time evolution of the snowline, we have to read the data. The gray lines are the positions of the radial grid cell interfaces and snapshots, which explains the discrete behavior of the snowline location.

[63]:
t = sim.writer.read.sequence("t") / c.year
ri = sim.writer.read.sequence("grid.ri") / c.au
rsnow = sim.writer.read.sequence("grid.rsnow") / c.au
[64]:
fig = plt.figure(dpi=150)
ax = fig.add_subplot(111)
ax.semilogx(t, rsnow)
ax.hlines(ri, 1.e3, 1e4, lw=1, color="gray", alpha=0.25)
ax.vlines(t, 2.5, 5.5, lw=1, color="gray", alpha=0.25)
ax.set_xlim(1.e3, 1.e4)
ax.set_ylim(2.5, 5.5)
ax.set_xlabel("Time [yr]")
ax.set_ylabel("Snowline location [AU]")
fig.tight_layout()
plt.show()
_images/3_advanced_customization_107_0.png