Protoplanetary disk models#
DiskSource provides parametric protoplanetary-disk sky
models for lightweight interferometric workflows. Each component specifies a radial
brightness profile, outer (and optionally inner) radius, inclination, and position angle.
Visibilities are evaluated directly in the UV plane using closed-form Fourier transforms
(or a small radial quadrature for power-law disks) — no hydrodynamics and no radiative
transfer.
What this is for: fast sky simulation, imaging pipeline tests, AI/ML training data, and algorithm benchmarking. Compared to hydrodynamic or Monte Carlo radiative-transfer models, these analytic disks are lighter, easier to configure, and much faster to evaluate (especially at scale with Dask). You trade physical realism for speed and reproducibility when the goal is to test interferometry software, not to model gas dynamics.
What this is not: a substitute for realistic disk physics (gaps from planet formation,
chemistry, time evolution, etc.). Use parametric rings and envelopes to mimic observed
morphology, then validate imaging or ML methods against known ground truth in the
visibility domain. Compose multiple components with
CompositeSource (or the + operator) — see
examples/notebooks/protoplanetary_disk_alma_simulation.ipynb.
See also
Simulation Framework — general simulation workflow and sky-model overview
Repository Examples — notebook index including the ALMA disk tutorial
DiskSource— API reference (auto-generated)
Geometry: inclination and position angle#
Disk radii are defined in the disk midplane. Inclination \(i\) and position
angle \(\mathrm{PA}\) project the disk onto the sky and rotate the UV coordinates
used by the Fourier transform. The formulas below follow
projected_uv_radius().
Position angle convention: \(\mathrm{PA}\) is the major-axis orientation
east of north — the same definition as FITS BPA, CASA restoring beams, and
radio_beam.Beam (counterclockwise from north on the sky). This matches
ALMA protoplanetary-disk papers. Internally the rotation uses
\(\theta = \pi/2 - \mathrm{PA}\) because \(l\) is defined positive toward
east and \(m\) toward north.
Sky direction-cosine offsets \((l, m)\) are in radians (small-angle limit:
\(\Delta\mathrm{RA} \approx l / \cos\delta\)). \(l\) is positive toward
east; \(m\) is positive toward north. Pyralysis builds image-plane
coordinates like create_image_meshgrid():
with \(\mathrm{cellsize}_l = \texttt{cellsize[0]} < 0\) and \(\mathrm{cellsize}_m = \texttt{cellsize[1]} > 0\). The negative horizontal cellsize is what makes \(\Delta\mathrm{RA}\) run left to right from positive to negative on displayed images. With \(\theta = \pi/2 - \mathrm{PA}\), after rotation into the disk frame,
where \(l_r\) lies along the projected major axis and \(m_r\) along the minor
axis. The same \(\rho\) projection enters the UV-plane visibility factor via
projected_uv_radius().
In the UV plane, coordinates \((u, v)\) in wavelengths use the same
\(\theta\) and \(\cos i\) projection as in the class docstring of
DiskSource.
GaussianSource uses the same \(\mathrm{PA}\)
convention for theta_angle.
Note
Bright ring lobes in inclined disks trace the minor axis (limb brightening), not the geometric major axis. When comparing dirty images to model \(\mathrm{PA}\), measure the envelope orientation or use visibility-domain geometry checks rather than peak-to-peak separation alone.
Model images (sky brightness)#
Before convolution with the instrumental response, the disk appears on the sky as a
2D brightness distribution \(I(l, m)\). The figures below are
normalized model images (not dirty maps or simulated visibilities) rasterized
with the Pyralysis cellsize sign convention above.
Face-on normalized model images for the five built-in profiles (\(i = 0^\circ\)). \(\Delta\mathrm{RA}\) increases to the left.#
Same profiles at \(i = 60^\circ\), \(\mathrm{PA} = 30^\circ\). The projected ellipse flattens by \(\cos i\) along the minor axis.#
Sky brightness shapes (normalized to peak 1):
Profile |
\(I(\rho)\) in the disk midplane (\(\rho\) in rad) |
|---|---|
|
Constant inside \(\rho \le r_\mathrm{out}\); zero outside |
|
Constant for \(r_\mathrm{in} \le \rho \le r_\mathrm{out}\) |
|
Infinitely thin shell at \(\rho = r_\mathrm{out}\); the documentation figure uses a narrow annulus for display only |
|
\(\exp(-\rho / r_\mathrm{out})\) |
|
\(\rho^{-p}\) between \(r_\mathrm{in}\) and \(r_\mathrm{out}\) (peak-normalized for display) |
Regenerate the documentation figures after changing profile math (one-off script, not part of the installed package):
python docs/scripts/generate_disk_profile_figures.py
UV-plane visibilities (Fourier theory)#
DiskSource returns visibilities
where \(I_0\) is the Stokes flux from reference_intensity,
\(F\) is the normalized spatial Fourier factor from
disk_profile_visibility(), and the
phase term shifts the disk when sky_position is offset from the phase center.
With \(x = 2\pi r_\mathrm{out}\,\rho\) and \(\rho\) the projected UV radius
above, the analytic factors implemented in pyralysis.models.sky.disk_profiles
are:
Uniform disk
Annular ring (\(r_\mathrm{in} < r_\mathrm{out}\))
Thin ring at \(r_\mathrm{out}\)
Exponential disk
Note
The disk exponential profile is not GaussianSource.
A Gaussian has image brightness \(\propto \exp(-r^2/2\sigma^2)\) and UV visibility
\(\exp(-2\pi^2\sigma^2\rho^2)\); the disk exponential has
\(\exp(-\rho/r_\mathrm{out})\) in the midplane and
\(F_\mathrm{exp}=(1+x^2)^{-3/2}\) above. Use GaussianSource
for elliptical Gaussians; use profile_type="exponential" for a radial disk envelope.
Power law \(\rho^{-p}\) between \(r_\mathrm{in}\) and \(r_\mathrm{out}\)
The implementation uses a fixed 100-point radial quadrature (see
_POWER_LAW_QUADRATURE_STEPS in pyralysis.models.sky.disk_profiles).
All factors are evaluated in float32 with Dask block-wise SciPy Bessel calls for HPC-friendly simulation.
Building individual disk components#
Angle attributes accept Quantity, Angle, strings, or numeric literals
(radii in radians, orientation angles in degrees when passed as bare numbers),
matching GaussianSource.
Uniform disk
from astropy import units as u
from pyralysis.models.sky import DiskSource
inner_disk = DiskSource(
reference_intensity=0.5 * u.Jy,
reference_frequency=230e9 * u.Hz,
sky_position="16h00m00s -30d00m00s",
profile_type="uniform",
major_radius=0.12 * u.arcsec,
inclination=45 * u.deg,
position_angle=30 * u.deg,
)
Bright ring
bright_ring = DiskSource(
reference_intensity=2.5 * u.Jy,
reference_frequency=230e9 * u.Hz,
sky_position="16h00m00s -30d00m00s",
profile_type="ring",
inner_radius=0.35 * u.arcsec,
major_radius=0.55 * u.arcsec,
inclination=45 * u.deg,
position_angle=30 * u.deg,
)
Thin ring (delta shell in the UV plane)
thin_ring = DiskSource(
reference_intensity=1.0 * u.Jy,
profile_type="thin_ring",
major_radius=0.5 * u.arcsec,
inclination=60 * u.deg,
position_angle=30 * u.deg,
)
Exponential envelope
envelope = DiskSource(
reference_intensity=1.0 * u.Jy,
profile_type="exponential",
major_radius=0.7 * u.arcsec,
inclination=45 * u.deg,
position_angle=30 * u.deg,
)
Power-law disk
power_disk = DiskSource(
reference_intensity=1.0 * u.Jy,
profile_type="power_law",
inner_radius=0.2 * u.arcsec,
major_radius=1.0 * u.arcsec,
power_index=1.5,
inclination=45 * u.deg,
position_angle=30 * u.deg,
)
Composing multi-component disks#
Add components with CompositeSource or +.
Negative reference_intensity on a ring can carve a parametric gap without a
dedicated gap profile.
Example composite: exponential envelope + bright ring − darker ring + compact uniform core (same geometry as the ALMA notebook).#
from pyralysis.models.sky import CompositeSource, DiskSource
common = dict(
sky_position="16h00m00s -30d00m00s",
reference_frequency=230e9,
inclination=45,
position_angle=30,
)
disk_model = CompositeSource(
sources=[
DiskSource(
reference_intensity=1.0,
profile_type="exponential",
major_radius="0.7 arcsec",
**common,
),
DiskSource(
reference_intensity=2.5,
profile_type="ring",
inner_radius="0.35 arcsec",
major_radius="0.55 arcsec",
**common,
),
DiskSource(
reference_intensity=-0.8,
profile_type="ring",
inner_radius="0.22 arcsec",
major_radius="0.32 arcsec",
**common,
),
DiskSource(
reference_intensity=0.5,
profile_type="uniform",
major_radius="0.12 arcsec",
**common,
),
],
operation="add",
)
Simulating visibilities#
Once the sky model is defined, pass it to Simulator as
for any other source. Pyralysis evaluates visibilities analytically in the UV plane.
The model-image figures earlier show true sky brightness; a dirty map is that
brightness convolved with the synthesised beam and affected by UV-coverage sidelobes.
The ALMA notebook (protoplanetary_disk_alma_simulation.ipynb) uses the same composite
disk_model geometry as above. Its observation setup is:
Array:
alma.cycle9.7.cfg(43 antennas, long-baseline Cycle 9)Band 6: 10 channels of 100 MHz from 229.55 to 230.45 GHz (centred on 230 GHz)
Time: 10 minutes total, 2-minute integrations (five time steps)
Imaging:
cellsize = \mathrm{theo\_resolution}/7, fixedimsize = 2048(use 1024 for a faster run), Natural weighting, thenDirtyMapperDisplay: crop ±1 arcsec around the phase centre so rings are easy to see
Omit explicit chunks on Simulator so Dask uses
"auto". Expect similar morphology between model images and the dirty map, not a
pixel-identical match.
Tip
Resolved structure needs radii comparable to or larger than the synthesized beam. With ALMA Band 6 and long-baseline configurations the beam can be \(\sim 0.07''\) or finer; use arcsecond-scale radii in tutorials unless you deliberately want an unresolved component.
from astropy import units as u
from astropy.coordinates import Angle
from astropy.time import Time
from pyralysis.io.antenna_config_io import AntennaConfigurationIo
from pyralysis.simulation import Simulator
interferometer = AntennaConfigurationIo(
input_name="src/pyralysis/simulation/antenna_configs/alma.cycle9.7.cfg"
).read()
interferometer.configure_observation(
min_frequency=229.55e9,
max_frequency=230.55e9, # half-open grid → 10 × 100 MHz channels to 230.45 GHz
frequency_step=100e6,
right_ascension=Angle("16h00m00s", unit=u.hourangle),
declination=Angle("-30d00m00s"),
integration_time=2 * u.minute,
observation_time="10m",
reference_time=Time("2024-01-01T00:00:00", format="isot"),
)
dataset = Simulator(interferometer=interferometer, sources=disk_model).simulate(
create_dataset=True
)
The notebook continues with ThermalNoiseInjector
(channel_bandwidth=100 MHz, integration_time=2 min), Natural weighting, and
dirty imaging. See Simulation Framework for noise injection, chunking, and dataset export.
See also#
Faraday Rotation and Depolarization — Faraday rotation for polarized components