If you use this code in your research, please cite:
- Gronkiewicz D., Różańska A., 2020, Warm and thick corona for a magnetically supported disk in galactic black hole binaries, A&A 633, A35, doi:10.1051/0004-6361/201935033
- Gronkiewicz D., Różańska A., Petrucci P.-O., Belmont R., 2023, Thermal instability as a constraint for warm X-ray coronas in active galactic nuclei, A&A 675, A198, doi:10.1051/0004-6361/202244410
Machine-readable metadata is in
CITATION.cff.
diskvert computes the 1D vertical structure of an accretion disk around a black hole at a single radius, from the equatorial plane (
The code solves a set of coupled ODEs (hydrostatic equilibrium, radiative diffusion, magnetic field evolution, energy transport and, optionally, the local heating–cooling balance) as a boundary-value problem, using a relaxation (Newton) method on a grid. All physics is described in the papers above; this README tells you how to run the code and how it is put together.
Contents:
- Getting started
- Running a model
- Output files
- Reading results in Python
- How the code works
- Changing the equations (
coefficients.py) - Python bindings to the Fortran library
- Repository map
There are four ways to get diskvert:
| for whom | |
|---|---|
| prebuilt AppImage | you only want to run models |
| native build | development on your own machine: Fortran compiler + OpenBLAS + a current Python |
| conda | as native, but with everything (incl. the old Python tools) in one environment |
| Docker / Apptainer | a ready Ubuntu 20.04 environment with the Python tools; Apptainer for clusters |
The Python tools for plotting and reading output (python/) have not been maintained since 2021 and need old package versions (Python 3.8, see python/requirements.txt); conda or the containers provide them.
sympy/coefficients.py, which generates the equations, runs on current Python (tested with Python 3.14 + sympy 1.14 and Python 3.8 + sympy 1.3).
To build from source, clone the repository with --recursive; the input-file parser libconfort is a git submodule and the build fails without it:
git clone --recursive https://github.com/gronki/diskvert.git
cd diskvertEach tagged version on the Releases page provides two files for Linux x86_64:
diskvert-x86_64.AppImage: only thediskvertprogram, no installation needed (glibc 2.31 or newer):chmod +x diskvert-x86_64.AppImage && ./diskvert-x86_64.AppImage -corona -o mydisk < input.txtdiskvert-x86_64.sif: the full Apptainer image, including the Python tools:apptainer exec diskvert-x86_64.sif diskvert -corona -o mydisk < input.txt
They are built by .github/workflows/release.yml; the AppImage can be built locally with scripts/build_appimage.sh (see the header of that script).
Requires make, gfortran (GCC 6 or newer) and OpenBLAS (the Makefile links -lopenblas). To regenerate the equations you also need Python 3 with sympy and numpy.
make -C build
make -C build install # into /usr/local if writable, otherwise ~/.local
make -C build install prefix=/opt/somewhereThis installs the programs into bin/, libdiskvert.so into lib/, Fortran modules into lib/diskvert/modules/ and a pkg-config file. Make sure bin/ is on your PATH (for ~/.local: export PATH="$PATH:$HOME/.local/bin").
conda env create -f environment.yml # once
conda activate diskvert
make -C build install prefix=$CONDA_PREFIX
pip install ./python # Python tools (optional)The programs are installed into the environment, so diskvert is on the PATH while it is active. mamba/micromamba work the same.
Both images are based on Ubuntu 20.04 and contain the compiler and the old Python tools. The repository on the host is mounted in the container, so you edit the code on the host as usual.
Docker:
./run_docker.shThis builds the image diskvert and starts an interactive shell. Directories are shared with the host:
| host | container | purpose |
|---|---|---|
. (repository) |
/source |
source code |
work/ |
/work |
your working directory (shell starts here) |
build/dist/ |
/opt/diskvert |
installed binaries and libdiskvert.so |
On first start, work/ is created and filled with the files from examples/.
Apptainer (e.g. on a cluster):
./build_apptainer.sh # builds diskvert.sif (uses sudo)
./run_apptainer.sh # starts a shell, same mounts as Docker except work/Instead of building, you can download diskvert-x86_64.sif from the releases and save it as diskvert.sif in the repository root.
In both, build/dist/ from the host is mounted over /opt/diskvert, which hides the binaries compiled while building the image. On first start run rebuild.sh (make in /source/build + install into /opt/diskvert). The installed files stay on the host in build/dist/, so later you only need to rebuild after changing the code.
cat examples/input.txt | diskvert -corona -o mydisk # compute
diskvert-plot mydisk.dat # writes mydisk.png (needs the Python tools)After editing sympy/coefficients.py (or any Fortran source), regenerate the equations and rebuild:
| environment | regenerate + rebuild |
|---|---|
| native | (cd sympy && python3 coefficients.py) && make -C build install |
| conda | (cd sympy && python coefficients.py) && make -C build install prefix=$CONDA_PREFIX |
| Docker / Apptainer shell | coefficients.sh && rebuild.sh |
Skip the regeneration step if you changed only Fortran. make recompiles only what changed, so a rebuild takes seconds. With containers, you can also run it from the host without entering the shell: ./run_docker.sh bash -c 'coefficients.sh && rebuild.sh' (the same works with ./run_apptainer.sh).
diskvert reads the parameters from standard input and options from the command line:
cat input.txt | diskvert [options] -o NAMEKey–value pairs, one per line, keys are case-sensitive, # starts a comment:
mbh 10 # black hole mass [M_sun]
mdot 0.05 # accretion rate [Eddington units]
radius 10 # distance from the black hole [Schwarzschild radii]
alpha 0.1 # magnetic alpha (field generation by dynamo)
eta 0.05 # magnetic eta (buoyant rise speed of the field)
nu 1 # magnetic nu (reconnection rate)
| key | required | meaning |
|---|---|---|
mbh |
yes | black hole mass in |
mdot |
yes | accretion rate in units of globals.f90) |
radius |
yes | radius in units of |
alpha |
yes |
|
eta |
no |
|
nu |
no |
|
output |
no | output base name (same as -o) |
On start the program prints derived quantities:
Chemical composition (X, Z), the mean molecular weight (src/globals.f90.
diskvert-random writes a random but sensible input to standard output (options -mbh, -mdot, -radius, -qcor; -mbh agn or -mbh xrb draws a typical mass):
diskvert-random -mbh agn -qcor 3 | diskvert -corona -o diskThis is the most important choice. Exactly one of:
| option | equations solved | |
|---|---|---|
-dyfu, -equilibrium |
gas temperature = radiation temperature (no corona) | default |
-compton |
separate gas temperature from heating–cooling balance, Compton cooling only | |
-corona, -balance |
separate gas temperature from the full heating–cooling balance (Compton + free–free) |
The full balance (-corona) does not converge when the atmosphere is thermally unstable (see the 2023 paper). In that case one can use:
-post-corona: compute the structure first (e.g. with-dyfuor-compton), then solve the full heating–cooling balance once at fixed density. This breaks the hydrostatic equilibrium slightly.-iter-post-corona: as above, but iterated a few times.
Every on/off switch has a negated form with -no-, e.g. -no-fluxcorr.
| option | default | meaning |
|---|---|---|
-o NAME |
disk |
base name of the output files |
-alpha |
off | non-magnetic |
-quench |
on | switch off MRI (and thus magnetic field generation) where the field exceeds the maximum allowed by MRI; may cause divergence |
-alpha-prad |
on | include radiation pressure in the pressure used by the |
-fluxcorr |
on | add the energy flux escaping above the top of the grid (relevant for strongly magnetized disks) |
-rel |
on | relativistic correction to Compton cooling |
-klnish |
off | Klein–Nishina cross-section (also turns on -rel); usually does not converge |
-ff, -bf
|
on | free–free and bound–free absorption opacity |
-simple-hydro |
off | in hydrostatic equilibrium, use the radiation temperature gradient instead of the gas temperature gradient (used by default in dv.run) |
-n N |
2048 | number of grid points (300–4000, rounded to a multiple of 16; increased by ⅓ for the corona step) |
-htop H |
auto | top of the grid in units of zscale below); by default estimated from the parameters |
-log, -linear, -linlog, -asinh, -pow2
|
-log |
grid type |
-trim |
off | cut the top of the grid where density becomes negligible |
-estim-full, -estim-compt, -estim-instab, -no-estim
|
automatic | how to guess the gas temperature before the corona step |
-all |
off | write every iteration to NAME.NNN.dat (useful to watch the relaxation) |
-conduction |
off | thermal conduction; experimental, marked as not working in coefficients.py
|
The terminal shows one line per iteration with the relative error and "ramps" (see How the code works). A successful run ends with SUCCESS; otherwise you see diverged or not converged. The output files are written anyway, so always check converged in NAME.txt.
To run many models, use e.g. GNU parallel (installed in the Docker/Apptainer images); see tests/fluxcorr/run.sh for an example.
Each run produces three text files:
| file | content |
|---|---|
NAME.dat |
the vertical structure: one row per grid point |
NAME.col |
names and types of the columns of .dat (read by the Python tools) |
NAME.txt |
scalar quantities: input, derived parameters, convergence flags, values at characteristic heights |
To save space you can pack them; the Python reader opens archives directly:
tar czf NAME.tgz NAME.{col,dat,txt} && rm NAME.{col,dat,txt}All values are in cgs units.
The first three columns are the point index i, the height z [cm] and h = zscale =
| column | meaning |
|---|---|
rho |
density [g cm⁻³] |
temp, trad
|
gas and radiation temperature [K] |
pgas, prad, pmag
|
gas, radiation, magnetic pressure [dyn cm⁻²] |
frad, fmag, fcnd
|
radiative, magnetic (Poynting, |
heat |
heating rate [erg cm⁻³ s⁻¹]; heatm and heatr are its two magnetic contributions |
cool, coolb, coolc
|
total, free–free and Compton cooling rate [erg cm⁻³ s⁻¹]; compfr = Compton fraction |
coolerr |
(heat − cool)/heat: how well the local energy balance is satisfied |
tau, taues, tauth
|
optical depth from the top: absorption+scattering, scattering only, effective (thermalization) |
tavg |
absorption-weighted average gas temperature above this point [K] |
coldens |
column density from the midplane [g cm⁻²]; nhtot: hydrogen column from the top [cm⁻²] |
beta |
plasma beta |
qcor |
local slope |
qmri |
MRI quenching factor (1 = MRI active) |
instabp |
thermal instability criterion at constant pressure; negative = unstable |
ksct, kabs, kabp
|
scattering, absorption (incl. scattering), Planck opacity [cm² g⁻¹] |
compy |
Compton |
ionxi |
|
ts_cool |
cooling time cool [s] |
The remaining columns are diagnostics; their definitions are in the subroutine fillcols in src/prog/diskvert.f90.
Lines of key value # comment. Important keys:
-
converged,has_corona,has_instability,model,niter,ngrid; - input and derived parameters:
mbh,mdot,radius,alpha,eta,nu,qcor,beta_0,facc(flux produced by accretion),teff,omega,zscale,rschw; -
zdisk/hdisk: density-weighted disk thickness [cm] / [$H$];coldens,tavgr; - values at characteristic heights, with a suffix:
_midpl(midplane),_phot($\tau=1$ ),_therm($\tau^\star=1$ ),_tmin(temperature minimum),_cor(corona base = max of the two previous),_instabp_lo/_hi/_md(extent of the unstable zone). For each there are e.g.zphot,hphot,temp_phot,rho_phot,tavg_phot,taues_phot, …
import diskvert as dv
import matplotlib.pyplot as plt
d, p = dv.col2python('mydisk.dat') # also accepts mydisk.tgz / .tar.gz
# d: numpy structured array, columns by name (names are lower-case, e.g. 'cool_dt')
# p: object with all keys of mydisk.txt as attributes
plt.plot(d['z'] / p.zdisk, d['trad'], label='T_rad')
plt.plot(d['z'] / p.zdisk, d['temp'], label='T_gas')
plt.yscale('log')
plt.legend()
plt.show()Helpers:
dv.run(pars, options=['-corona', '-simple-hydro'])runsdiskvertin a temporary directory for adictof input parameters and returns(d, p). Raises an exception with the log if the program fails.dv.standard_pars_q(q)returns(alpha, eta, nu)for a desiredqcor(one particular parametrization, seepython/diskvert/random.py).diskvert-plot NAME.datdraws an overview figure. The x axis is-col(column density, default),-xl(log height),-x(linear height),-tauor-grid;-showopens a window,-o file.pngsets the output.
At every grid point the unknowns are (depending on the model) sympy/coefficients.py, are:
-
hydrostatic equilibrium:
$\dfrac{dP_{\rm gas}}{dz} + \dfrac{dP_{\rm mag}}{dz} = \dfrac{\kappa\rho}{c}F_{\rm rad} - \Omega^2\rho z$ -
radiative diffusion:
$16\sigma T_{\rm rad}^3\dfrac{dT_{\rm rad}}{dz} = -3\kappa\rho F_{\rm rad}$ -
magnetic field:
$\eta z\dfrac{dP_{\rm mag}}{dz} = \alpha P_{\rm gen} - (2\eta + \alpha\nu)P_{\rm mag}$ , where$P_{\rm gen} = P_{\rm gas} + P_{\rm rad} + P_{\rm mag}$ (with default options) -
energy:
$\dfrac{dF_{\rm rad}}{dz} = \mathcal{H}$ , with heating$\mathcal{H} = 2(\eta + \alpha\nu)\Omega P_{\rm mag} - \alpha\Omega P_{\rm gen}$ -
heating = cooling (only with
-compton/-corona):$\mathcal{H} = 4\sigma\rho\left[\kappa_P(T^4 - T_{\rm rad}^4) + \kappa_{\rm es}T_{\rm rad}^4\dfrac{4k}{m_ec^2}(T - T_{\rm rad})\right]$ , where the free–free term is dropped for-compton
Boundary conditions: at -fluxcorr term) and
In the non-magnetic mode (-alpha) -quench, -rel, -alpha-prad and -fluxcorr switches modify these expressions; see coefficients.py for the exact form.
Each combination of physics is a separate "model" with its own set of generated routines. Names and numbers (printed as model in NAME.txt):
| nr | name | temperature | magnetic | conduction |
|---|---|---|---|---|
| 1 | ad |
no | no | |
| 5 | md |
yes | no | |
| 6 | mw |
Compton balance | yes | no |
| 8 | mc |
full balance | yes | no |
| 13 | mdt |
yes | yes | |
| 16 | mct |
full balance | yes | yes |
The number is mrx_number in src/relaxation.f90).
- Global quantities at the given radius (
$\Omega$ ,$F_{\rm acc}$ ,$T_{\rm eff}$ ,$H$ ) are computed incylinder(globals.f90), and a Shakura–Sunyaev solution (ss73solution.f90) gives the initial guess. - The ODEs are discretized on the grid (central differences between neighbouring points). For an approximate solution
$Y$ ,mrx_matrix(relaxation.f90) builds the residuals$A$ and the Jacobian$M$ . The linear system$M,\delta Y = A$ is banded and is solved with LAPACKdgbsv. - The correction is applied with a damping factor ("ramp" in the terminal output):
$Y \leftarrow Y + \text{ramp}\cdot\delta Y$ , where ramp < 1 when the error is large. - For stability, the absorption opacity is switched on gradually (
R_OPAcolumn,opacities_killin the code) and so is MRI quenching (R_MRI,qmri_kill). - First the model with
$T=T_{\rm rad}$ is converged. If a corona was requested, the solution is copied to the model with a separate gas temperature (mrx_transfer), the grid is refined, the gas temperature is estimated (-estim-*) and relaxation continues. - Finally, the output columns are computed (
fillcols) and files are written.
The main program src/prog/diskvert.f90 drives all of this; most physics lives in the generated file src/mrxcoeff.fi.
The residuals and their partial derivatives (the Jacobian) are not written by hand. sympy/coefficients.py defines the equations symbolically, differentiates them with SymPy and prints Fortran code into src/:
| generated file | content |
|---|---|
mrxcoeff.fi |
for each model: mrx_coeff1_* (1st-order ODEs: residual A, Jacobians MY w.r.t. values and MD w.r.t. derivatives), mrx_coeff0_* (algebraic equations such as heating = cooling), mrx_coeffbl_* / mrx_coeffbr_* (boundary conditions at the midplane / top), mrx_output_* (output quantities) |
mrxdims.fi |
numbers of variables, equations and boundary conditions per model |
mrxptrs.fi |
selects the routines above for a model number |
mrxhash.fi |
position of each variable ( |
mrxfout.fi |
selects the output routine |
These files are included into src/relaxation.f90. Things to know before editing:
- Opacities are abstract functions
fkabs,fkabp,fksct,fkcndof$(\rho, T)$ . In the generated code they are replaced by the matrixF(3,4): row 1 is the value, rows 2 and 3 the derivatives with respect to$\rho$ and$T$ ; columns are absorption, Planck, scattering, conduction. The Fortran side fillsFfromkappabs,kappabp,kappesp,kappcndinglobals.f90. To change opacities, edit those Fortran functions (value and derivatives); no regeneration is needed. - Run-time switches (
use_prad_in_alpha,use_relcompt,use_quench_mri,use_flux_correction) becomemerge(...)expressions in Fortran, so they can be changed without regenerating. - The list of models is
choicesat the top of the loop. - The order of the list
youtmust match the constantsc_rho,c_temp, … inrelaxation.f90, andyvar_allmust matchcc_rho, …. If you add an output, updateN_RELAX_OUTPUTStoo. - The script checks that the number of equations equals the number of unknowns and that there is one boundary condition per first-order equation.
To regenerate and rebuild, see Development loop. The script must be run from the sympy/ directory because it writes to ../src/. Commit the regenerated .fi files together with the change in coefficients.py.
Different sympy versions format the generated code differently (order of terms, line breaks), so regenerating with another version changes many lines of mrxcoeff.fi even if the equations did not change. The code is equivalent: models computed with files from sympy 1.3 and 1.14 were bit-identical. To keep git diffs readable, regenerate with the same sympy version as the committed files (currently 1.3, as pinned in python/requirements.txt).
make also builds libdiskvert.so. python/diskvert/models.py loads it with ctypes (the directory with the library must be on LD_LIBRARY_PATH; the containers set this) and exposes the relaxation routines, so the Jacobian can be inspected from Python:
| Python | Fortran (relaxation_c in relaxation.f90) |
purpose |
|---|---|---|
mrx_init(mbh, mdot, r, alpha, eta, nu) |
mrx_init_c |
set the disk parameters |
mrx_model(balance, magnetic, conduction) |
mrx_number_c |
model number; balance is b'D', b'W' (Compton) or b'C' (full) |
mrx_get_ny(nr) |
mrx_get_ny_c |
number of variables per grid point |
mrx_matrix(nr, z, nz, Y, ny, A, na, M) |
mrx_matrix_c |
fill residuals A and Jacobian M (Fortran order) |
mrx_advance(nr, z, nz, Y, dY, ny) |
mrx_advance_c |
solve dY receives the correction |
The state vector Y has length nz*ny and is ordered point by point: [ρ₁, T₁, …, ρ₂, T₂, …]; the order of variables within a point is given by mrxhash.fi. Run-time switches and the opacity ramp keep their module defaults (all on), i.e. the binding does not reproduce the gradual start used by diskvert.
examples/draw_matrix.py builds an initial guess and plots the matrix structure. These bindings are incomplete and serve mostly for inspection; to compute models, run the diskvert program.
src/prog/diskvert.f90 main program: options, relaxation loop, output
src/relaxation.f90 building the Jacobian, model selection, C bindings
src/mrx*.fi generated by sympy/coefficients.py — do not edit by hand
src/globals.f90 constants, composition, opacities, Ω, F_acc, T_eff
src/balance.f90 local heating–cooling balance solver (corona estimate, -post-corona)
src/ss73solution.f90 Shakura–Sunyaev solution (initial guess)
src/util/settings.f90 reading input keys and common command-line options
sympy/coefficients.py symbolic equations → Fortran code
python/diskvert/ reading output, running models, ctypes bindings
python/scripts/ diskvert-plot and other scripts
examples/ example input files (copied to work/)
build/Makefile build and install
libconfort/ submodule: key–value input parser
The programs dv-alpha, dv-mag, dv-rad1, dv-ss73 and dv-opactbl are legacy tools built alongside diskvert; they are not maintained.