Analytic disk

We create a Magrittetorch model from an analytic description of a Keplerian disk (see e.g. Homan et al. 2018; Booth et al. 2019).

Setup

Import the required functionalty.

[1]:
import magrittetorch.model.model as magritte
import magrittetorch.tools.mesher as mesher
import magrittetorch.tools.setup as setup
import numpy             as np                      # Data structures
from astropy import units, constants    # Unit conversions
from astropy.units import Quantity # For function arguments
import os
import torch
import warnings                                     # Hide warnings
warnings.filterwarnings('ignore')                   # especially for yt
import yt                                           # 3D plotting
import os

from tqdm                import tqdm                # Progress bars
from astropy             import units, constants    # Unit conversions
from scipy.spatial       import Delaunay, cKDTree   # Finding neighbors
from yt.funcs            import mylog               # To avoid yt output
mylog.setLevel(40)                                  # as error messages

Define a working directory (you will have to change this; it must be an absolute path).

[2]:
wdir = "/lhome/thomasc/Magrittetorch-examples/Analytic_disk/"

Create the working directory.

[3]:
!mkdir -p $wdir

Define file names.

[4]:
model_file = os.path.join(wdir, 'model_analytic_disk.hdf5')   # Resulting Magritte model
lamda_file = os.path.join(wdir, 'co.txt'                  )   # Line data file

We use a data file that can be downloaded with the following links.

[5]:
lamda_link = "https://home.strw.leidenuniv.nl/~moldata/datafiles/co.dat"

Dowload the snapshot and the linedata (%%capture is just used to suppress the output).

[6]:
%%capture
!wget $lamda_link --output-document $lamda_file

Model parameters

The functions below describe the disk structure, based on the Magritte application presented in De Ceuster et al. (2019).

[7]:
G      =           constants.G
kb     =           constants.k_B
m_H2   = 2.01588 * constants.u

XCO    =  6.0e-4   # [.]
vturb  =  1.5e+3 * units.m/units.s  # [m/s]

M_star =     2.0 * constants.M_sun
r_star =     2.0 * constants.au
T_star =    2500 * units.K  # [K]

rho_in = 5.0e-12 * units.kg/units.m**3  # [kg/m^3]
r_out  =   600.0 * constants.au
r_in   =    10.0 * constants.au
T_in   =    1500 * units.K  # [K]

p      =  -2.125   # [.]
h      =   1.125   # [.]
q      =  -0.5     # [.]


def cylindrical(x: Quantity[units.m], y: Quantity[units.m], z: Quantity[units.m])-> tuple[Quantity[units.m], Quantity[units.dimensionless_unscaled], Quantity[units.m]]:
    """
    Convert cartesian to cylindrical coordinates.
    """
    rxy = np.sqrt   (x**2 + y**2)
    phi = np.arctan2(y, x)
    return (rxy, phi, z)


def H(rxy: Quantity[units.m]) -> Quantity[units.m]:
    """
    Vertical Gaussian scale height.
    """
    # Compute prefactor
    prefactor = r_in * np.sqrt(kb * T_in / m_H2 * r_in / (G * M_star))
    # Return power law result
    return prefactor * np.power(rxy/r_in, h)


def density(rr: [Quantity[units.m], Quantity[units.m], Quantity[units.m]]) -> Quantity[units.kg/units.m**3]:
    """
    Keplerian disk density in cylindrical coordinates.
    """
    # Convert carthesian to cylindrical coords
    (rxy,phi,z) = cylindrical(rr[:,0],rr[:,1],rr[:,2])
    # Compute density
    rho = rho_in * np.power(rxy/r_in, p) * np.exp(-0.5 * (z/H(rxy))**2)
    # Set radii below r_in to zero (in a way that also work for np arrays)
    if hasattr(rxy, "__len__"):
        rho[rxy<r_in] = 0.0
    else:
        if (rxy<r_in):
            rho = 0.0
    # Return result
    return rho


def abn_nH2(rr: [Quantity[units.m], Quantity[units.m], Quantity[units.m]]) -> Quantity[units.m**-3]:
    """
    H2 number density function.
    """
    return density(rr) / (2.01588 * constants.u)


def abn_nCO(rr: [Quantity[units.m], Quantity[units.m], Quantity[units.m]]) -> Quantity[units.m**-3]:
    """
    CO number density function.
    """
    return XCO * abn_nH2(rr)


def temperature(rr: [Quantity[units.m], Quantity[units.m], Quantity[units.m]]) -> Quantity[units.K]:
    """
    Temperature structure.
    """
    # Convert carthesian to cylindrical coords
    (rxy,phi,z) = cylindrical(rr[:,0],rr[:,1],rr[:,2])
    # Get spherical radial coordinate
    r = np.sqrt(rxy**2 + z**2)
    # Compute temperature
    return T_star * np.power(r/r_star, q)


def velocity_f(rr: [Quantity[units.m], Quantity[units.m], Quantity[units.m]]) -> Quantity[units.m/units.s]:
    """
    Velocity structure.
    """
    # Convert carthesian to cylindrical coords
    (rxy,phi,z) = cylindrical(rr[:,0],rr[:,1],rr[:,2])
    # Get spherical radial coordinate
    r = np.sqrt(rxy**2 + z**2)
    # Compute angle
    d = phi.si.value + 0.5 * np.pi
    # Compute velocity (in carthesian)
    return np.sqrt(G * M_star/r)[:, np.newaxis] * np.stack([np.cos(d), np.sin(d), np.zeros_like(d)], axis=1)

Create background mesh

Define the desired background mesh, a (\(75 \times 75 \times 75\)) cube.

[8]:
resolution = 75

xs = np.linspace(-r_out, +r_out, resolution, endpoint=True)
ys = np.linspace(-r_out, +r_out, resolution, endpoint=True)
zs = np.linspace(-r_out, +r_out, resolution, endpoint=True)

(Xs, Ys, Zs) = np.meshgrid(xs, ys, zs)

# Extract positions of points in background mesh
position = np.stack((Xs.ravel(), Ys.ravel(), Zs.ravel()), axis=1)

# Evaluate the density on the cube
rhos      = density(np.stack([Xs, Ys, Zs], axis=1))
rhos_min  = np.min(rhos[rhos!=0.0])
rhos     += rhos_min

Now we remesh this model using the new remesher

[9]:
positions_reduced, nb_boundary = mesher.remesh_point_cloud(position, rhos.si.value.ravel(), max_depth=10, threshold= 2e-1, hullorder=4)
new interior points:  198688
number boundary points:  1538

We add a spherical inner boundary at 0.01*r_out

[10]:
healpy_order = 5 #using healpy to determine where to place the 12*healpy_order**2 boundary points on the sphere
origin = np.array([0.0,0.0,0.0]).T * units.m #the origin of the inner boundary sphere
positions_reduced, nb_boundary = mesher.point_cloud_add_spherical_inner_boundary(positions_reduced, nb_boundary, 0.01*r_out, healpy_order=healpy_order, origin=origin)
print("number of points in reduced grid: ", len(positions_reduced))
print("number of boundary points: ", nb_boundary)
#Magritte-torch does not yet support having an inner boundary, so we just treat them as normal points
nb_boundary = 0
number of points in reduced grid:  200525
number of boundary points:  1838

We add a spherical outer boundary at r_out

[11]:
healpy_order = 15 #using healpy to determine where to place the 12*healpy_order**2 boundary points on the sphere
origin = np.array([0.0,0.0,0.0]).T * units.m #the origin of the inner boundary sphere
positions_reduced, nb_boundary = mesher.point_cloud_add_spherical_outer_boundary(positions_reduced, nb_boundary, r_out, healpy_order=healpy_order, origin=origin)
print("number of points in reduced grid: ", len(positions_reduced))
number of points in reduced grid:  114051
[12]:
npoints = len(positions_reduced)

# Extract Delaunay vertices (= Voronoi neighbors)
delaunay = Delaunay(positions_reduced)
(indptr, indices) = delaunay.vertex_neighbor_vertices
neighbors = [indices[indptr[k]:indptr[k+1]] for k in range(npoints)]
nbs       = [n for sublist in neighbors for n in sublist]
n_nbs     = [len(sublist) for sublist in neighbors]

# Convenience arrays
zeros = np.zeros(npoints)
ones  = np.ones (npoints)

Convert model functions to arrays based the model mesh.

[13]:
position = positions_reduced
velocity = velocity_f (positions_reduced)
nH2      = abn_nH2    (positions_reduced)
nCO      = abn_nCO    (positions_reduced)
tmp      = temperature(positions_reduced)
trb      = np.ones(len(tmp))*vturb

Create model

Now all data is read, we can use it to construct a Magrittetorch model.

Warning

Including all radiative transitions can be computationally expensive (both in time and memory cost) for self-consistent NLTE radiative transfer. For LTE radiative transfer, this is not the case, altough if one wants to image a specific line, that line must be in the list of considered transitions. For these examples, we include the first 10 radiative transitions of CO (J=1-0 to J=10-9). To consider all transitions, use setup.set_linedata_from_LAMDA_file as in the commented line below it.

[14]:
from magrittetorch.model.geometry.geometry import GeometryType
from magrittetorch.model.geometry.boundary import BoundaryType

model = magritte.Model(model_file) # Create model object
model.geometry.geometryType.set(GeometryType.General3D) # This is a 3D model
model.geometry.boundary.boundaryType.set(BoundaryType.Sphere3D) # With a spherical boundary

# In order to make unit conversions trivial, we use astropy quantities as input
model.geometry.points.position.set_astropy(position) # Set point positions
model.geometry.points.velocity.set_astropy(velocity) # Set point velocities
model.chemistry.species.abundance.set_astropy(np.stack([nCO, nH2, zeros/units.m**3], axis=1))# Set species number densities
model.chemistry.species.symbol.set(np.array(['CO', 'H2', 'e-'], dtype='S')) #Set species symbols; should correspond to the LAMDA file format
#Note: the dtype='S' is necessary to correctly save and read the species symbols to/from the hdf5 file

model.thermodynamics.temperature.gas.set_astropy(tmp) # Set gas temperature
model.thermodynamics.turbulence.vturb.set_astropy(trb) # Set turbulence velocity

model = setup.set_Delaunay_neighbor_lists (model) # Automatically computes and sets neighbors for each point, using a Delaunay triangulation
# For unitless quantities, we can also directly set the torch tensors
boundary_indices = torch.arange(nb_boundary, dtype = torch.int64)
model.geometry.boundary.boundary2point.set(boundary_indices) # Set which points are boundary points
# Conveniently, the remeshing function puts the boundary points in front of the positions array
model = setup.set_boundary_condition_CMB  (model) # Set CMB as boundary condition
model = setup.set_uniform_rays            (model, 12) # Number of rays for NLTE raytracing; has be of the form 12*2**n

#As this example does not do NLTE, we might as well only consider the first 10 transitions of CO
model = setup.set_linedata_from_LAMDA_file(model, lamda_file, {'considered transitions': [i for i in range(10)]})
# model = setup.set_linedata_from_LAMDA_file(model, lamda_file)   # Consider all transitions
model = setup.set_quadrature              (model, 7) # Set number of quadrature points for NLTE radiative transfer

# And write the model to disk
model.write()
Not considering all radiative transitions on the data file but only the specified ones!
Writing model to:  /lhome/thomasc/Magrittetorch-examples/Analytic_disk/model_analytic_disk.hdf5

Plot model

Load the data in a yt unstructured mesh.

[15]:
ds = yt.load_unstructured_mesh(
    connectivity = delaunay.simplices.astype(np.int64),
    coordinates  = delaunay.points.astype(np.float64) * 100.0, # yt expects cm not m
    node_data    = {('connect1', 'n'): nCO[delaunay.simplices].astype(np.float64)}
)

Plot a slice through the mesh orthogonal to the z-axis and x-axis.

[16]:
sl = yt.SlicePlot (ds, 'z', ('connect1', 'n'))
sl.set_cmap       (('connect1', 'n'), 'magma')
sl.zoom           (1.2)
[16]:

[17]:
sl = yt.SlicePlot (ds, 'x', ('connect1', 'n'))
sl.set_cmap       (('connect1', 'n'), 'magma')
sl.zoom           (1.2)
[17]:

Show meshes on the plots.

[18]:
sl = yt.SlicePlot      (ds, 'z', ('connect1', 'n'))
sl.set_cmap            (('connect1', 'n'), 'magma')
sl.zoom                (1.2)
sl.annotate_mesh_lines (plot_args={'color':'lightgrey', 'linewidths':[.25]})
[18]:

[19]:
sl = yt.SlicePlot      (ds, 'x', ('connect1', 'n'))
sl.set_cmap            (('connect1', 'n'), 'magma')
sl.zoom                (1.2)
sl.annotate_mesh_lines (plot_args={'color':'lightgrey', 'linewidths':[.25]})
[19]:

[ ]: