Source code for astromugs.nautilus.coupling

import numpy as np
from scipy.interpolate import RegularGridInterpolator
from ..constants.constants import c, autocm, amu, mu, black_body

[docs] def moving_average(x, w): """Compute a moving average using a uniform convolution kernel. Parameters ---------- x : array_like Input array to smooth. w : int Width of the rolling window (number of points). Returns ------- numpy.ndarray Smoothed array with the same length as `x`. """ return np.convolve(x, np.ones(w), 'same') / w
[docs] def dust_density(dtogas, rho_m, sizes, dens_radmc3d, rchem, zchem, d, theta): """Interpolate RADMC3D dust density onto the Nautilus chemistry grid. Converts dust mass densities from the RADMC3D spherical grid to the Nautilus cylindrical grid using bilinear interpolation, preserving radial drift gradients and smooth disk boundaries. Returns both dust number densities per grain size and gas number density. Parameters ---------- dtogas : float Dust-to-gas mass ratio. rho_m : float Material (intrinsic) density of dust grains, in g/cm^3. sizes : array_like Array of grain radii for each dust species, in cm. dens_radmc3d : numpy.ndarray Dust mass density from RADMC3D with shape ``(nbspecies, nphi, ntheta, nr)``, in g/cm^3. rchem : numpy.ndarray Radial grid of the Nautilus chemistry model, in cm. zchem : numpy.ndarray Vertical grid of the Nautilus chemistry model with shape ``(nrchem, nzchem)``, in cm. Values can differ per radius. d : numpy.ndarray Radial (spherical) distance grid of the RADMC3D model, in AU. theta : numpy.ndarray Co-latitude grid of the RADMC3D model, in radians. Returns ------- dens_naut_nd : numpy.ndarray Dust number density per grain size, shape ``(nbspecies, nrchem, nzchem)``, in cm^-3. dens_naut_nH : numpy.ndarray Gas hydrogen number density, shape ``(nrchem, nzchem)``, in cm^-3. """ nbspecies, nz, ny, nx = dens_radmc3d.shape nrchem = len(rchem) nzchem = zchem.shape[1] dens_naut = np.zeros((nbspecies, nrchem, nzchem)) # Convert RADMC3D grid from au to cm to match rchem/zchem units d_cm = d * autocm # Build query points: convert cylindrical (rchem, zchem) -> spherical (d_pt, theta_pt) # zchem has shape (nrchem, nzchem) — z values can differ per radius d_pts = np.sqrt(rchem[:, None]**2 + zchem**2) # (nrchem, nzchem) in cm theta_pts = np.arccos(np.clip(zchem / d_pts, -1, 1)) # (nrchem, nzchem) # Clamp query points to the RADMC3D grid range to avoid extrapolation d_pts = np.clip(d_pts, d_cm[0], d_cm[-1]) theta_pts = np.clip(theta_pts, theta[0], theta[-1]) # Stack into (N, 2) array for the interpolator query = np.column_stack([d_pts.ravel(), theta_pts.ravel()]) for idx_size in range(nbspecies): # dens_radmc3d shape is (nspecies, nphi, ntheta, nr) — take phi=0 data_2d = dens_radmc3d[idx_size, 0, :, :] # (ntheta, nr) # RegularGridInterpolator expects data on (d, theta) axes # data_2d is indexed as [itheta, ir], so transpose to [ir, itheta] interp = RegularGridInterpolator( (d_cm, theta), data_2d.T, method='linear', bounds_error=False, fill_value=0.0 ) dens_naut[idx_size] = interp(query).reshape(nrchem, nzchem) # Gas number density from total dust dens_naut_nH = dens_naut.sum(axis=0) / dtogas / (mu * amu) # Dust number density per grain size dens_naut_nd = np.zeros_like(dens_naut) for ai in range(nbspecies): mass = (4./3.) * np.pi * rho_m * sizes[ai]**3 dens_naut_nd[ai] = dens_naut[ai] / mass return dens_naut_nd, dens_naut_nH
[docs] def dust_temperature_disk(temp_radmc3d, rchem, zchem, d, theta, hg=None): """Remap multi-species RADMC3D dust temperatures onto the Nautilus grid using scale heights. Converts temperatures from the RADMC3D spherical grid to the Nautilus cylindrical grid via bilinear interpolation, then smooths vertical profiles with a 5-point moving average. Parameters ---------- temp_radmc3d : numpy.ndarray Dust temperature from RADMC3D with shape ``(nbspecies, nphi, ntheta, nr)``, in K. rchem : numpy.ndarray Radial grid of the Nautilus chemistry model, in cm. zchem : numpy.ndarray Normalised vertical grid points (dimensionless, scaled by `hg`). d : numpy.ndarray Radial (spherical) distance grid of the RADMC3D model, in AU. theta : numpy.ndarray Co-latitude grid of the RADMC3D model, in radians. hg : array_like, optional Gas scale height at each radial point, in cm. Used to convert normalised `zchem` to physical heights. Returns ------- numpy.ndarray Smoothed dust temperature on the Nautilus grid with shape ``(nbspecies, nrchem, nzchem)``, in K. """ nbspecies, nz, ny, nx = temp_radmc3d.shape nrchem = len(rchem) nzchem = len(zchem) d_cm = d * autocm # convert RADMC3D grid from au to cm # Convert normalised zchem to physical heights using scale heights hhg, zz = np.meshgrid(hg, zchem, indexing='ij') zz = hhg * zz # (nrchem, nzchem) in cm # Build query points: convert cylindrical (rchem, zz) -> spherical (d_pt, theta_pt) d_pts = np.sqrt(rchem[:, None]**2 + zz**2) # (nrchem, nzchem) theta_pts = np.arccos(np.clip(zz / d_pts, -1, 1)) # (nrchem, nzchem) # Clamp query points to the RADMC3D grid range d_pts = np.clip(d_pts, d_cm[0], d_cm[-1]) theta_pts = np.clip(theta_pts, theta[0], theta[-1]) # Stack into (N, 2) array for the interpolator query = np.column_stack([d_pts.ravel(), theta_pts.ravel()]) temp_naut = np.ones((nbspecies, nrchem, nzchem)) temp_naut_smooth = np.ones((nbspecies, nrchem, nzchem)) for size_id in range(nbspecies): data_2d = temp_radmc3d[size_id, 0, :, :] # (ntheta, nr) interp = RegularGridInterpolator( (d_cm, theta), data_2d.T, method='linear', bounds_error=False, fill_value=None ) temp_naut[size_id] = interp(query).reshape(nrchem, nzchem) #SMOOTHING TEMPERATURE PROFILE for idx in range(nrchem): for size_id in range(nbspecies): temp_naut_smooth[size_id, idx, :] = moving_average(temp_naut[size_id, idx, :], 5) temp_naut_smooth[:, :, 0:2] = temp_naut[:, :, 0:2] #clean the boundary effects temp_naut_smooth[:, :, -2:] = temp_naut[:, :, -2:] return temp_naut_smooth
[docs] def dust_temperature_single_disk(temp_radmc3d, rchem, zchem, d, theta, hg=None): """Remap single-species RADMC3D dust temperature onto the Nautilus grid using scale heights. Same as `dust_temperature_disk` but for a single dust species (no species dimension). Uses nearest-neighbor lookup followed by 5-point moving-average smoothing of vertical profiles. Parameters ---------- temp_radmc3d : numpy.ndarray Dust temperature from RADMC3D with shape ``(nphi, ntheta, nr)``, in K. rchem : numpy.ndarray Radial grid of the Nautilus chemistry model, in cm. zchem : numpy.ndarray Normalised vertical grid points (dimensionless, scaled by `hg`). d : numpy.ndarray Radial (spherical) distance grid of the RADMC3D model, in AU. theta : numpy.ndarray Co-latitude grid of the RADMC3D model, in radians. hg : array_like, optional Gas scale height at each radial point, in cm. Used to convert normalised `zchem` to physical heights. Returns ------- numpy.ndarray Smoothed dust temperature on the Nautilus grid with shape ``(nrchem, nzchem)``, in K. """ nz, ny, nx = temp_radmc3d.shape d_cm = d * autocm # convert RADMC3D grid from au to cm hhg, zz = np.meshgrid(hg, zchem, indexing='ij') zz = hhg*zz temp_naut = np.ones((len(rchem), len(zchem))) temp_naut_smooth = np.ones((len(rchem), len(zchem))) for idx, r in enumerate(rchem): for alt in range(len(zchem)): d_pt = np.sqrt(r**2 + zz[idx, alt]**2) #convert from cartesian to spherical theta_pt = np.arccos(zz[idx, alt]/d_pt) #convert from cartesian to spherical closest_d = min(enumerate(d_cm), key=lambda x: abs(x[1]-d_pt)) #find closest grid point closest_t = min(enumerate(theta), key=lambda x: abs(x[1]-theta_pt)) #find closest grid point temp_naut[idx, alt] = temp_radmc3d[0, closest_t[0], closest_d[0]] #SMOOTHING TEMPERATURE PROFILE for idx in range(len(rchem)): temp_naut_smooth[idx, :] = moving_average(temp_naut[idx, :], 5) #average the values over a rolling window of 5 points. temp_naut_smooth[:, 0:2] = temp_naut[:, 0:2] #clean the boundary effects temp_naut_smooth[:, -2:] = temp_naut[:, -2:] return temp_naut_smooth
[docs] def dust_temperature(temp_radmc3d, rchem, zchem, d, theta): """Remap multi-species RADMC3D dust temperatures onto the Nautilus grid. Converts temperatures from the RADMC3D spherical grid to the Nautilus cylindrical grid via bilinear interpolation, where `zchem` provides physical heights per radius. Vertical profiles are smoothed with a 5-point moving average. Parameters ---------- temp_radmc3d : numpy.ndarray Dust temperature from RADMC3D with shape ``(nbspecies, nphi, ntheta, nr)``, in K. rchem : numpy.ndarray Radial grid of the Nautilus chemistry model, in cm. zchem : numpy.ndarray Vertical grid with shape ``(nrchem, nzchem)``, in cm. Heights can differ per radial point. d : numpy.ndarray Radial (spherical) distance grid of the RADMC3D model, in AU. theta : numpy.ndarray Co-latitude grid of the RADMC3D model, in radians. Returns ------- numpy.ndarray Smoothed dust temperature on the Nautilus grid with shape ``(nbspecies, nrchem, nzchem)``, in K. """ nbspecies, nz, ny, nx = temp_radmc3d.shape nrchem = len(rchem) nzchem = zchem.shape[1] d_cm = d * autocm # convert RADMC3D grid from au to cm # Build query points: convert cylindrical (rchem, zchem) -> spherical (d_pt, theta_pt) d_pts = np.sqrt(rchem[:, None]**2 + zchem**2) # (nrchem, nzchem) theta_pts = np.arccos(np.clip(zchem / d_pts, -1, 1)) # (nrchem, nzchem) # Clamp query points to the RADMC3D grid range d_pts = np.clip(d_pts, d_cm[0], d_cm[-1]) theta_pts = np.clip(theta_pts, theta[0], theta[-1]) # Stack into (N, 2) array for the interpolator query = np.column_stack([d_pts.ravel(), theta_pts.ravel()]) temp_naut = np.ones((nbspecies, nrchem, nzchem)) temp_naut_smooth = np.ones((nbspecies, nrchem, nzchem)) for size_id in range(nbspecies): data_2d = temp_radmc3d[size_id, 0, :, :] # (ntheta, nr) interp = RegularGridInterpolator( (d_cm, theta), data_2d.T, method='linear', bounds_error=False, fill_value=None ) temp_naut[size_id] = interp(query).reshape(nrchem, nzchem) #SMOOTHING TEMPERATURE PROFILE for idx in range(nrchem): for size_id in range(nbspecies): temp_naut_smooth[size_id, idx, :] = moving_average(temp_naut[size_id, idx, :], 5) temp_naut_smooth[:, :, 0:2] = temp_naut[:, :, 0:2] #clean the boundary effects temp_naut_smooth[:, :, -2:] = temp_naut[:, :, -2:] return temp_naut_smooth
[docs] def dust_temperature_single(temp_radmc3d, rchem, zchem, d, theta): """Remap single-species RADMC3D dust temperature onto the Nautilus grid. Same as `dust_temperature` but for a single dust species (no species dimension). Uses nearest-neighbor lookup followed by 5-point moving-average smoothing of vertical profiles. Parameters ---------- temp_radmc3d : numpy.ndarray Dust temperature from RADMC3D with shape ``(nphi, ntheta, nr)``, in K. rchem : numpy.ndarray Radial grid of the Nautilus chemistry model, in cm. zchem : numpy.ndarray Vertical grid with shape ``(nrchem, nzchem)``, in cm. Heights can differ per radial point. d : numpy.ndarray Radial (spherical) distance grid of the RADMC3D model, in AU. theta : numpy.ndarray Co-latitude grid of the RADMC3D model, in radians. Returns ------- numpy.ndarray Smoothed dust temperature on the Nautilus grid with shape ``(nrchem, nzchem)``, in K. """ nz, ny, nx = temp_radmc3d.shape d_cm = d * autocm # convert RADMC3D grid from au to cm temp_naut = np.ones((len(rchem), len(zchem[0,:]))) temp_naut_smooth = np.ones((len(rchem), len(zchem[0,:]))) for idx, r in enumerate(rchem): for idz, z in enumerate(zchem[idx, :]): d_pt = np.sqrt(r**2 + z**2) #convert from cartesian to spherical theta_pt = np.arccos(z/d_pt) #convert from cartesian to spherical closest_d = min(enumerate(d_cm), key=lambda x: abs(x[1]-d_pt)) #find closest grid point closest_t = min(enumerate(theta), key=lambda x: abs(x[1]-theta_pt)) #find closest grid point temp_naut[idx, idz] = temp_radmc3d[0, closest_t[0], closest_d[0]] #SMOOTHING TEMPERATURE PROFILE for idx in range(len(rchem)): temp_naut_smooth[idx, :] = moving_average(temp_naut[idx, :], 5) #average the values over a rolling window of 5 points. temp_naut_smooth[:, 0:2] = temp_naut[:, 0:2] #clean the boundary effects temp_naut_smooth[:, -2:] = temp_naut[:, -2:] # # #-------------------------------- # import matplotlib.pyplot as plt # from matplotlib.colors import LogNorm # fig = plt.figure(figsize=(10, 8.)) # ax = fig.add_subplot(111) # plt.xlabel(r'r', fontsize = 17) # plt.ylabel(r'z', fontsize = 17, labelpad=-7.4) # zz, rr = np.meshgrid(zchem, rchem) #inverse r,z because dim is (len(r), len(z)) # t = plt.pcolormesh(rr/autocm, zz/autocm, temp_naut_smooth[:,:], cmap='gnuplot2', shading='gouraud', vmin=5, vmax=80, rasterized=True) # #t = plt.contourf(rr/autocm, zz/autocm, dens_naut[0, :,:], levels=[0.1,1,8,10,20,30,40,50,60, 70, 80], cmap='jet', rasterized=True) # clr = plt.colorbar(t) # plt.show() # # #----------------------------------- return temp_naut_smooth
[docs] def local_field(): """Compute the local radiation field (placeholder, not yet implemented).""" pass
[docs] def avz_disk(field_radmc3d, lam_mono, R_star, T_star, rchem, zchem, d, theta, hg): """Compute the vertical visual extinction map using scale-height-based vertical coordinates. Integrates the RADMC3D radiation field over UV wavelengths, remaps it onto the Nautilus cylindrical grid (with heights derived from gas scale heights), and computes Av by comparing the attenuated field to an unattenuated blackbody reference. Parameters ---------- field_radmc3d : numpy.ndarray Monochromatic mean intensity from RADMC3D with shape ``(nlam, nphi, ntheta, nr)``, in cgs units. lam_mono : numpy.ndarray Wavelength grid of the radiation field, in microns. R_star : float Stellar radius, in cm. T_star : float Stellar effective temperature, in K. rchem : numpy.ndarray Radial grid of the Nautilus chemistry model, in cm. zchem : numpy.ndarray Normalised vertical grid points (dimensionless, scaled by `hg`). d : numpy.ndarray Radial (spherical) distance grid of the RADMC3D model, in AU. theta : numpy.ndarray Co-latitude grid of the RADMC3D model, in radians. hg : array_like Gas scale height at each radial point, in cm. Returns ------- numpy.ndarray Visual extinction Av on the Nautilus grid with shape ``(nrchem, nzchem)``, in magnitudes. """ nlam, nph, nt, nr = field_radmc3d.shape d_cm = d * autocm # convert RADMC3D grid from au to cm lamuv = np.where((lam_mono <= 0.2)) # extract the ~ uv if len(lamuv[0]) == len(lam_mono): extrawave = lam_mono[lamuv[0][-1]] - lam_mono[lamuv[0][-2]] extrawave += lam_mono[lamuv[0][-1]] lam_mono = np.append(lam_mono, extrawave) freq = c/(lam_mono*1e-6) fieldint = np.zeros((nt, nr)) bbint = 0 # Integrate over uv frequencies: for i in lamuv[0]: fieldint += field_radmc3d[i, 0, :, :]*abs(freq[i+1]-freq[i]) bbint += black_body(T_star, lam_mono[i])*abs(freq[i+1]-freq[i]) #integrate BB spectru over the chosen wavelength range fieldint[fieldint==0.0] = 1e-30 #provide arbitrary low values in order to avoid division by zero. # Convert from spherical to nautilus grid: hhg, zz = np.meshgrid(hg, zchem, indexing='ij') zz = hhg*zz field_naut, field_naut_smooth = np.ones((len(rchem), len(zchem))), np.ones((len(rchem), len(zchem))) avz = np.ones((len(rchem), len(zchem))) bbint_map = np.ones((len(rchem), len(zchem))) #CREATE UNATTENUATED MAP USING A BB SPECTRUM for idx, r in enumerate(rchem): for idz in range(len(zchem)): bbint_map[idx, idz] = bbint*R_star**2*(1/(r**2+zz[idx, idz]**2))/np.pi for idx, r in enumerate(rchem): for z in range(len(zchem)): d_pt = np.sqrt(r**2 + zz[idx, z]**2) #convert from cartesian to spherical theta_pt = np.arccos(zz[idx, z]/d_pt) #convert from cartesian to spherical closest_d = min(enumerate(d_cm), key=lambda x: abs(x[1]-d_pt)) #find closest grid point closest_t = min(enumerate(theta), key=lambda x: abs(x[1]-theta_pt)) #find closest grid point field_naut[idx, z] = fieldint[closest_t[0], closest_d[0]] # Smoothing vertical profiles for idx in range(len(rchem)): field_naut_smooth[idx, :] = moving_average(field_naut[idx, :], 5) #average the values over a rolling window of 5 points. field_naut_smooth[:, 0:2] = field_naut[:, 0:2] #clean the boundary effects field_naut_smooth[:, -2:] = field_naut[:, -2:] #CREATE Av MAP for idx in range(len(rchem)): for idz in range(len(zchem)): avz[idx, idz] = abs(-1.086*np.log(field_naut[idx,idz]/bbint_map[idx, idz])) #field0[idx])) avz[idx,1:][avz[idx,1:]==0.0] = np.trim_zeros(avz[idx])[0] # avz_df = pd.DataFrame(data=avz.transpose()) # avz_df = avz_df.rolling(window=5, center=True, min_periods=2).mean() return avz
[docs] def av_z(field_radmc3d, lam_mono, R_star, T_star, rchem, zchem, d, theta): """Compute the vertical visual extinction map using physical vertical coordinates. Integrates the RADMC3D radiation field over UV wavelengths, remaps it onto the Nautilus cylindrical grid (where `zchem` contains physical heights per radius), and derives Av by comparing the attenuated field to an unattenuated blackbody reference. Both vertical and radial profiles are smoothed with a 5-point moving average. Parameters ---------- field_radmc3d : numpy.ndarray Monochromatic mean intensity from RADMC3D with shape ``(nlam, nphi, ntheta, nr)``, in cgs units. lam_mono : numpy.ndarray Wavelength grid of the radiation field, in microns. R_star : float Stellar radius, in cm. T_star : float Stellar effective temperature, in K. rchem : numpy.ndarray Radial grid of the Nautilus chemistry model, in cm. zchem : numpy.ndarray Vertical grid with shape ``(nrchem, nzchem)``, in cm. Heights can differ per radial point. d : numpy.ndarray Radial (spherical) distance grid of the RADMC3D model, in AU. theta : numpy.ndarray Co-latitude grid of the RADMC3D model, in radians. Returns ------- numpy.ndarray Smoothed visual extinction Av on the Nautilus grid with shape ``(nrchem, nzchem)``, in magnitudes. """ nlam, nph, nt, nr = field_radmc3d.shape d_cm = d * autocm # convert RADMC3D grid from au to cm lamuv = np.where((lam_mono <= 0.2)) # extract the ~ uv if len(lamuv[0]) == len(lam_mono): extrawave = lam_mono[lamuv[0][-1]] - lam_mono[lamuv[0][-2]] extrawave += lam_mono[lamuv[0][-1]] lam_mono = np.append(lam_mono, extrawave) freq = c/(lam_mono*1e-6) fieldint = np.zeros((nt, nr)) bbint = 0 # Integrate over uv frequencies: for i in lamuv[0]: fieldint += field_radmc3d[i, 0, :, :]*abs(freq[i+1]-freq[i]) bbint += black_body(T_star, lam_mono[i])*abs(freq[i+1]-freq[i]) #integrate BB spectru over the chosen wavelength range fieldint[fieldint==0.0] = 1e-30 #provide arbitrary low values in order to avoid division by zero. ## Convert from spherical to nautilus grid: field_naut, field_naut_smooth = np.ones((len(rchem), len(zchem[0,:]))), np.ones((len(rchem), len(zchem[0,:]))) avz, avz_smooth = np.ones((len(rchem), len(zchem[0,:]))), np.ones((len(rchem), len(zchem[0,:]))) bbint_map = np.ones((len(rchem), len(zchem[0,:]))) #CREATE UNATTENUATED MAP USING A BB SPECTRUM for idx, r in enumerate(rchem): for idz, z in enumerate(zchem[idx, :]): bbint_map[idx, idz] = bbint*R_star**2*(1/(r**2+z**2))/np.pi for idx, r in enumerate(rchem): for idz, z in enumerate(zchem[idx, :]): d_pt = np.sqrt(r**2 + z**2) #convert from cartesian to spherical theta_pt = np.arccos(z/d_pt) #convert from cartesian to spherical closest_d = min(enumerate(d_cm), key=lambda x: abs(x[1]-d_pt)) #find closest grid point closest_t = min(enumerate(theta), key=lambda x: abs(x[1]-theta_pt)) #find closest grid point field_naut[idx, idz] = fieldint[closest_t[0], closest_d[0]] # Smoothing vertical profiles for idx in range(len(rchem)): field_naut_smooth[idx, :] = moving_average(field_naut[idx, :], 5) #average the values over a rolling window of 5 points. field_naut_smooth[:, 0:2] = field_naut[:, 0:2] #clean the boundary effects field_naut_smooth[:, -2:] = field_naut[:, -2:] # #-------------------------------- #import matplotlib.pyplot as plt #from matplotlib.colors import LogNorm #fig = plt.figure(figsize=(10, 8.)) #ax = fig.add_subplot(111) #plt.xlabel(r'r', fontsize = 17) #plt.ylabel(r'z', fontsize = 17, labelpad=-7.4) #rr, zz = np.meshgrid(rchem, zchem) #t = plt.pcolormesh(rr/autocm, zz/autocm, bbint_map, cmap='gnuplot2', shading='gouraud', norm=LogNorm(vmin=np.min(bbint_map), vmax=np.max(bbint_map)), rasterized=True) #clr = plt.colorbar(t) #plt.show() # #----------------------------------- #CREATE Av MAP for idx in range(len(rchem)): for idz in range(len(zchem[idx,:])): avz[idx, idz] = abs(-1.086*np.log(field_naut[idx,idz]/bbint_map[idx, idz])) #field0[idx])) avz[idx, 1:][avz[idx, 1:]==0.0] = np.trim_zeros(avz[idx])[0] ##SMOOTHING for idx in range(len(rchem)): avz_smooth[idx, :] = moving_average(avz[idx, :], 5) #average the values over a rolling window of 5 points. for idz in range(len(zchem[0,:])): avz_smooth[:, idz] = moving_average(avz_smooth[:, idz], 5) #average the values over a rolling window of 5 points. avz_smooth[:, 0:2] = avz[:, 0:2] #clean the boundary effects avz_smooth[:, -2:] = avz[:, -2:] avz_smooth[0:2, :] = avz[0:2, :] #clean the boundary effects avz_smooth[-2:, :] = avz[-2:, :] #avz_smooth = np.where(avz_smooth<1, avz_smooth*100, avz_smooth) return avz_smooth
[docs] def to_spherical(chemmodel, nr, nt, dist, theta, struct='numberdens_species'): """Convert a Nautilus chemistry structure from cylindrical to spherical coordinates. Maps a quantity stored on the Nautilus cylindrical grid (keyed by radial position) onto a regular spherical ``(r, theta)`` grid via bilinear interpolation. Each chemistry column is first interpolated onto a common z grid (built from the union of all column z arrays), producing a regular 2-D field in ``(R_cyl, z)`` that is then passed to :class:`~scipy.interpolate.RegularGridInterpolator`. Points outside the chemistry domain — above the disk surface, beyond the outermost radius, or inside the inner radius — receive floor values. Parameters ---------- chemmodel : dict Dictionary keyed by cylindrical radius **in AU** (int or float). Each value is a dict containing: - ``'z'``: 1-D array of vertical heights **in AU**, stored surface → midplane (descending order). - the field named by `struct`: 1-D array of the quantity to remap, same length and order as ``'z'``. nr : int Number of radial cell centres in the output spherical grid. nt : int Number of co-latitude cell centres in the output spherical grid. dist : numpy.ndarray Spherical radial distance grid (cell centres) **in AU**, shape ``(nr,)``. theta : numpy.ndarray Co-latitude grid (cell centres) **in radians**, shape ``(nt,)``. struct : str, optional Key in each ``chemmodel`` entry for the quantity to remap. Default is ``'numberdens_species'``. Returns ------- numpy.ndarray Remapped quantity on the spherical grid, shape ``(nr, nt)``. Cells inside the inner radius are set to ``1e-20``; cells outside the modelled domain are set to ``0.0``. """ # ── 1. Sorted Nautilus radii (AU) ──────────────────────────────────────── r_naut = np.array(sorted(chemmodel.keys()), dtype=float) r_inner = r_naut[0] # ── 2. Build a common z grid from the union of all column z arrays ─────── # Each column stores z surface→midplane (descending); flip to ascending. z_common = np.unique(np.concatenate([chemmodel[r]['z'] for r in r_naut])) # z_common is already ascending after np.unique (returns sorted values). # ── 3. Interpolate every column onto z_common ───────────────────────────── # np.interp requires xp to be ascending, so we flip z (descending→ascending) # and the corresponding values. # left = midplane value (z < lowest z in column → below midplane) # right = 0.0 (z > highest z in column → above truncation) data_2d = np.zeros((len(r_naut), len(z_common))) for ir, r in enumerate(r_naut): z_asc = chemmodel[r]['z'][::-1] # ascending val_asc = chemmodel[r][struct][::-1] # corresponding values data_2d[ir] = np.interp(z_common, z_asc, val_asc, left=val_asc[0], right=0.0) # ── 4. Build the 2-D interpolator on (R_cyl, z) ────────────────────────── interp = RegularGridInterpolator( (r_naut, z_common), data_2d, method='linear', bounds_error=False, fill_value=0.0 ) # ── 5. Evaluate at all spherical cell centres (vectorised) ─────────────── dist_g, theta_g = np.meshgrid(dist, theta, indexing='ij') # (nr, nt) R_cyl = dist_g * np.sin(theta_g) # (nr, nt) AU z_cyl = np.abs(dist_g * np.cos(theta_g)) # (nr, nt) AU query = np.stack([R_cyl.ravel(), z_cyl.ravel()], axis=1) result = interp(query).reshape(nr, nt) # Inner-radius cavity: set a small floor instead of 0 to avoid log-scale issues result[dist_g < r_inner] = 1e-20 return result