├── tests
├── __init__.py
├── test_isoradials.py
├── test_viz.py
├── test_isoredshifts.py
├── test_black_hole.py
└── test_black_hole_math.py
├── docs
├── _templates
│ ├── python
│ │ ├── attribute.rst
│ │ ├── exception.rst
│ │ ├── package.rst
│ │ ├── property.rst
│ │ ├── method.rst
│ │ ├── function.rst
│ │ ├── data.rst
│ │ ├── class.rst
│ │ └── module.rst
│ └── macros.rst
├── _static
│ └── _images
│ │ ├── bh.png
│ │ ├── isoradials.png
│ │ ├── isofluxlines.png
│ │ ├── isoredshifts.png
│ │ ├── isofluxlines_black.png
│ │ └── isofluxlines_white.png
├── source
│ ├── index.rst
│ ├── bibliography.rst
│ ├── api_reference.rst
│ ├── bibliography.bib
│ └── conf.py
├── requirements.txt
├── _pygments
│ └── style.py
├── Makefile
└── make.bat
├── parameters.ini
├── assets
├── bh_plot.png
├── bh_plot_dark.png
├── bh_plot_light.png
└── 1979A+A____75__228L.pdf
├── .gitattributes
├── .gitignore
├── .github
└── workflows
│ ├── requirements.txt
│ ├── test.yml
│ └── publish.yml
├── .readthedocs.yaml
├── luminet
├── __init__.py
├── solver.py
├── spatial.py
├── viz.py
├── isoredshift.py
├── isoradial.py
├── black_hole.py
└── black_hole_math.py
├── LICENSE.md
├── pyproject.toml
└── README.md
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/_templates/python/attribute.rst:
--------------------------------------------------------------------------------
1 | {% extends "python/data.rst" %}
2 |
--------------------------------------------------------------------------------
/docs/_templates/python/exception.rst:
--------------------------------------------------------------------------------
1 | {% extends "python/class.rst" %}
2 |
--------------------------------------------------------------------------------
/docs/_templates/python/package.rst:
--------------------------------------------------------------------------------
1 | {% extends "python/module.rst" %}
2 |
--------------------------------------------------------------------------------
/parameters.ini:
--------------------------------------------------------------------------------
1 | [isoradial_angular_parameters]
2 | angular_precision = 200
--------------------------------------------------------------------------------
/assets/bh_plot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bgmeulem/luminet/HEAD/assets/bh_plot.png
--------------------------------------------------------------------------------
/assets/bh_plot_dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bgmeulem/luminet/HEAD/assets/bh_plot_dark.png
--------------------------------------------------------------------------------
/assets/bh_plot_light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bgmeulem/luminet/HEAD/assets/bh_plot_light.png
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # SCM syntax highlighting
2 | pixi.lock linguist-language=YAML linguist-generated=true
3 |
--------------------------------------------------------------------------------
/docs/_static/_images/bh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bgmeulem/luminet/HEAD/docs/_static/_images/bh.png
--------------------------------------------------------------------------------
/assets/1979A+A____75__228L.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bgmeulem/luminet/HEAD/assets/1979A+A____75__228L.pdf
--------------------------------------------------------------------------------
/docs/_static/_images/isoradials.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bgmeulem/luminet/HEAD/docs/_static/_images/isoradials.png
--------------------------------------------------------------------------------
/docs/source/index.rst:
--------------------------------------------------------------------------------
1 | Contents
2 | --------
3 |
4 | .. include:: api_reference.rst
5 |
6 | .. include:: bibliography.rst
--------------------------------------------------------------------------------
/docs/_static/_images/isofluxlines.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bgmeulem/luminet/HEAD/docs/_static/_images/isofluxlines.png
--------------------------------------------------------------------------------
/docs/_static/_images/isoredshifts.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bgmeulem/luminet/HEAD/docs/_static/_images/isoredshifts.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # pixi environments
3 | .pixi
4 | *.egg-info
5 | **.pyc
6 | **__pycache__**
7 | docs/build**
8 | docs/source/autoapi**
--------------------------------------------------------------------------------
/docs/_static/_images/isofluxlines_black.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bgmeulem/luminet/HEAD/docs/_static/_images/isofluxlines_black.png
--------------------------------------------------------------------------------
/docs/_static/_images/isofluxlines_white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bgmeulem/luminet/HEAD/docs/_static/_images/isofluxlines_white.png
--------------------------------------------------------------------------------
/docs/source/bibliography.rst:
--------------------------------------------------------------------------------
1 | Bibliography
2 | ============
3 |
4 | .. bibliography:: bibliography.bib
5 | :style: unsrt
6 | :cited:
--------------------------------------------------------------------------------
/.github/workflows/requirements.txt:
--------------------------------------------------------------------------------
1 | matplotlib==3.5.2
2 | numpy==1.22.4
3 | pandas==1.4.2
4 | pytest==7.1.2
5 | scipy==1.8.1
6 | tqdm==4.64.0
7 |
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | # Read the Docs configuration file
2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
3 |
4 | # Required
5 | version: 2
6 |
7 | sphinx:
8 | configuration: docs/source/conf.py
9 |
10 | build:
11 | os: ubuntu-24.04
12 | tools:
13 | python: "3.13"
14 |
15 | python:
16 | install:
17 | - requirements: docs/requirements.txt
--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | # docs dependencies
2 | sphinx>=8.1.3,<9
3 | pygments>=2.19.1,<3
4 | furo>=2024.8.6,<2025
5 | toml>=0.10.2,<0.11
6 | sphinxcontrib-bibtex>=2.6.3,<3
7 | sphinx-copybutton>=0.5.2,<0.6
8 | sphinx-inline-tabs>=2023.4.21,<2024
9 | sphinx-autoapi>=3.4.0,<4
10 | catppuccin[pygments]>=2.3.4,<3
11 | sphinx-paramlinks>=0.6.0,<0.7
12 |
13 | # project dependencies
14 | matplotlib>=3.10.0,<4
15 | numpy>=2.2.2,<3
16 | scipy>=1.15.1,<2
17 | pandas>=2.2.3,<3
18 | pytest>=8.3.4,<9
--------------------------------------------------------------------------------
/docs/_pygments/style.py:
--------------------------------------------------------------------------------
1 | from pygments.style import Style
2 | from pygments.token import Keyword, Name, Comment, String, Error, Text, \
3 | Number, Operator, Generic, Whitespace, Punctuation, Other, Literal
4 | from pygments.styles import get_style_by_name
5 |
6 | catppuccin_light = get_style_by_name('catppuccin-latte')
7 |
8 | class LightStyle(catppuccin_light):
9 | """
10 | This style mimics the catppuccin color scheme.
11 | """
12 |
13 | background_color = "#f8f9fb"
--------------------------------------------------------------------------------
/luminet/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Calculate and plot Swarzschild black holes with a thin accretion disk
3 | """
4 |
5 | import toml, os
6 | pyproject_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "pyproject.toml")
7 | project_info = toml.load(pyproject_path)
8 | version = project_info["project"]["version"]
9 | __version__ = version
10 | __author__ = [author["name"] for author in project_info["project"]["authors"]]
11 | __license__ = project_info["project"]["license"]["text"]
12 | __email__ = project_info["project"]["authors"][0]["email"]
--------------------------------------------------------------------------------
/docs/_templates/python/property.rst:
--------------------------------------------------------------------------------
1 | {% if obj.display %}
2 | {% if is_own_page %}
3 | {{ obj.id }}
4 | {{ "=" * obj.id | length }}
5 |
6 | {% endif %}
7 | .. py:property:: {% if is_own_page %}{{ obj.id}}{% else %}{{ obj.short_name }}{% endif %}
8 | {% if obj.annotation %}
9 |
10 | :type: {{ obj.annotation }}
11 | {% endif %}
12 | {% for property in obj.properties %}
13 |
14 | :{{ property }}:
15 | {% endfor %}
16 |
17 | {% if obj.docstring %}
18 |
19 | {{ obj.docstring|indent(3) }}
20 | {% endif %}
21 | {% endif %}
22 |
--------------------------------------------------------------------------------
/docs/source/api_reference.rst:
--------------------------------------------------------------------------------
1 | API reference
2 | =============
3 |
4 | .. toctree::
5 | :hidden:
6 |
7 | autoapi/luminet/black_hole/index
8 | autoapi/luminet/black_hole_math/index
9 | autoapi/luminet/isoradial/index
10 | autoapi/luminet/isoredshift/index
11 | autoapi/luminet/spatial/index
12 | autoapi/luminet/viz/index
13 | autoapi/luminet/solver/index
14 |
15 | .. autoapisummary::
16 |
17 | luminet.black_hole
18 | luminet.black_hole_math
19 | luminet.isoradial
20 | luminet.isoredshift
21 | luminet.spatial
22 | luminet.viz
23 | luminet.solver
--------------------------------------------------------------------------------
/tests/test_isoradials.py:
--------------------------------------------------------------------------------
1 | from luminet.isoradial import Isoradial
2 | import numpy as np
3 | import pytest
4 |
5 | @pytest.mark.parametrize("mass", [1., 2.])
6 | @pytest.mark.parametrize("incl", [0, 45, 90, 135])
7 | @pytest.mark.parametrize("radius", [6., 20, 60.])
8 | def test_isoradials(mass, incl, radius) -> None:
9 | N_ISORADIALS=20
10 | radii = np.linspace(6, 60, N_ISORADIALS)
11 | ir = Isoradial(radius=radius*mass, incl=incl, bh_mass=mass, order=0).calculate()
12 | ir_ghost = Isoradial(radius=radius*mass, incl=incl, bh_mass=mass, order=0).calculate()
13 |
14 | return None
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line, and also
5 | # from the environment for the first two.
6 | SPHINXOPTS ?=
7 | SPHINXBUILD ?= sphinx-build
8 | SOURCEDIR = source
9 | BUILDDIR = build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
21 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | pull_request:
5 | branches: [ "master" ]
6 | push:
7 | branches: [ "master" ]
8 |
9 | permissions:
10 | contents: read
11 |
12 | jobs:
13 | test:
14 | runs-on: ${{ matrix.os }}
15 | strategy:
16 | matrix:
17 | os: [ubuntu-latest, windows-latest, macos-latest]
18 |
19 | steps:
20 | - uses: actions/checkout@v3
21 | - uses: prefix-dev/setup-pixi@v0.8.2
22 | with:
23 | pixi-version: v0.41.3
24 | cache: true
25 | auth-host: prefix.dev
26 | auth-token: ${{ secrets.PREFIX_DEV_TOKEN }}
27 | - name: Test
28 | run: |
29 | pixi r test
30 | - name: Upload Coverage to Codecov
31 | uses: codecov/codecov-action@v2
32 | if: matrix.os == 'ubuntu-latest'
33 |
--------------------------------------------------------------------------------
/tests/test_viz.py:
--------------------------------------------------------------------------------
1 | import os
2 | import time
3 |
4 | import matplotlib.pyplot as plt
5 | import numpy as np
6 |
7 | from luminet import black_hole
8 |
9 |
10 | def test_bh_isoradial_coverage():
11 | """Plot a black hole by plotting a full range of isoradials"""
12 | M = 1.0
13 | incl = 85 * np.pi / 180
14 | outer_accretion_disk_edge = 40 * M
15 | bh = black_hole.BlackHole(
16 | incl=incl, mass=M, outer_edge=outer_accretion_disk_edge
17 | )
18 | t_start = time.time()
19 | ax = bh.plot()
20 | t_end = time.time()
21 | print(f"Time to calc and plot: {t_end - t_start:.2f} s")
22 | # plt.show()
23 |
24 | if __name__ == "__main__":
25 | import sys
26 | sys.path.insert(0, os.path.abspath(os.path.join(__file__, "../../")))
27 | test_bh_isoradial_coverage()
28 |
--------------------------------------------------------------------------------
/docs/_templates/python/method.rst:
--------------------------------------------------------------------------------
1 | {% if obj.display %}
2 | {% if is_own_page %}
3 | {{ obj.name }}
4 | {{ "=" * obj.name | length }}
5 |
6 | {% endif %}
7 | .. py:method:: {% if is_own_page %}{{ obj.id }}{% else %}{{ obj.short_name }}{% endif %}({{ obj.args }}){% if obj.return_annotation is not none %} -> {{ obj.return_annotation }}{% endif %}
8 | {% for (args, return_annotation) in obj.overloads %}
9 |
10 | {%+ if is_own_page %}{{ obj.id }}{% else %}{{ obj.short_name }}{% endif %}({{ args }}){% if return_annotation is not none %} -> {{ return_annotation }}{% endif %}
11 | {% endfor %}
12 | {% for property in obj.properties %}
13 |
14 | :{{ property }}:
15 | {% endfor %}
16 |
17 | {% if obj.docstring %}
18 |
19 | {{ obj.docstring|indent(3) }}
20 | {% endif %}
21 | {% endif %}
22 |
--------------------------------------------------------------------------------
/docs/_templates/python/function.rst:
--------------------------------------------------------------------------------
1 | {% if obj.display %}
2 | {% if is_own_page %}
3 | {{ obj.name }}
4 | {{ "=" * obj.name | length }}
5 |
6 | {% endif %}
7 | .. py:function:: {% if is_own_page %}{{ obj.id }}{% else %}{{ obj.short_name }}{% endif %}({{ obj.args }}){% if obj.return_annotation is not none %} -> {{ obj.return_annotation }}{% endif %}
8 | {% for (args, return_annotation) in obj.overloads %}
9 |
10 | {%+ if is_own_page %}{{ obj.id }}{% else %}{{ obj.short_name }}{% endif %}({{ args }}){% if return_annotation is not none %} -> {{ return_annotation }}{% endif %}
11 | {% endfor %}
12 | {% for property in obj.properties %}
13 |
14 | :{{ property }}:
15 | {% endfor %}
16 |
17 | {% if obj.docstring %}
18 |
19 | {{ obj.docstring|indent(3) }}
20 | {% endif %}
21 | {% endif %}
22 |
--------------------------------------------------------------------------------
/tests/test_isoredshifts.py:
--------------------------------------------------------------------------------
1 |
2 | from luminet.black_hole import BlackHole
3 | import numpy as np
4 | import matplotlib.pyplot as plt
5 | import pytest
6 |
7 | @pytest.mark.parametrize("incl", np.linspace(np.pi/3, np.pi/2, 3))
8 | def test_isoredshifts(incl):
9 | """
10 | Attention:
11 | Only inclinations close to pi/2 have large redshifts
12 |
13 | # TODO: calc min and max redshifts for a given mass
14 | """
15 |
16 | bh = BlackHole(incl=incl, mass=1.)
17 | try:
18 | zs = [-.1, 0, .2]
19 | ax = bh.plot_isoredshifts(zs, c="white")
20 | # plt.show() # Uncomment to see the plot
21 | except AssertionError as e:
22 | raise ValueError("Failed for incl={}".format(incl)) from e
23 | return None
24 |
25 | if __name__ == "__main__":
26 | test_isoredshifts(np.pi/2-0.1)
--------------------------------------------------------------------------------
/tests/test_black_hole.py:
--------------------------------------------------------------------------------
1 | from luminet.black_hole import BlackHole
2 | import numpy as np
3 | import pytest
4 |
5 | @pytest.mark.parametrize("mass", [1., 2.])
6 | @pytest.mark.parametrize("incl", np.linspace(np.pi/3, np.pi/2, 3))
7 | def test_varying_mass(mass, incl):
8 | """
9 | Test if black hole can be created with a mass other than 1
10 | """
11 |
12 | bh = BlackHole(incl=incl, mass=mass)
13 | radii = np.linspace(6*mass, 60*mass, 10)
14 | bh.calc_isoradials(direct_r=radii, ghost_r=radii) # calculate some isoradials, should be quick enough
15 | for isoradial in bh.isoradials:
16 | assert not any(np.isnan(isoradial.impact_parameters)), "Isoradials contain nan values"
17 | assert not any(np.isnan(isoradial.angles)), "Isoradials contain nan values"
18 | return None
19 |
20 |
21 | if __name__ == "__main__":
22 | test_varying_mass(1., np.pi/3)
23 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=source
11 | set BUILDDIR=build
12 |
13 | %SPHINXBUILD% >NUL 2>NUL
14 | if errorlevel 9009 (
15 | echo.
16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
17 | echo.installed, then set the SPHINXBUILD environment variable to point
18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
19 | echo.may add the Sphinx directory to PATH.
20 | echo.
21 | echo.If you don't have Sphinx installed, grab it from
22 | echo.https://www.sphinx-doc.org/
23 | exit /b 1
24 | )
25 |
26 | if "%1" == "" goto help
27 |
28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
29 | goto end
30 |
31 | :help
32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
33 |
34 | :end
35 | popd
36 |
--------------------------------------------------------------------------------
/docs/_templates/python/data.rst:
--------------------------------------------------------------------------------
1 | {% if obj.display %}
2 | {% if is_own_page %}
3 | {{ obj.id }}
4 | {{ "=" * obj.id | length }}
5 |
6 | {% endif %}
7 | .. py:{{ obj.type }}:: {% if is_own_page %}{{ obj.id }}{% else %}{{ obj.name }}{% endif %}
8 | {% if obj.annotation is not none %}
9 |
10 | :type: {% if obj.annotation %} {{ obj.annotation }}{% endif %}
11 | {% endif %}
12 | {% if obj.value is not none %}
13 |
14 | {% if obj.value.splitlines()|count > 1 %}
15 | :value: Multiline-String
16 |
17 | .. raw:: html
18 |
19 | Show Value
20 |
21 | .. code-block:: python
22 |
23 | {{ obj.value|indent(width=6,blank=true) }}
24 |
25 | .. raw:: html
26 |
27 |
28 |
29 | {% else %}
30 | :value: {{ obj.value|truncate(100) }}
31 | {% endif %}
32 | {% endif %}
33 |
34 | {% if obj.docstring %}
35 |
36 | {{ obj.docstring|indent(3) }}
37 | {% endif %}
38 | {% endif %}
39 |
--------------------------------------------------------------------------------
/docs/source/bibliography.bib:
--------------------------------------------------------------------------------
1 | @ARTICLE{Luminet_1979,
2 | author = {{Luminet}, J. -P.},
3 | title = {Image of a spherical black hole with thin accretion disk.},
4 | journal = {Astronomy \& Astrophysics},
5 | keywords = {Black Holes (Astronomy), Stellar Mass Accretion, Doppler Effect, Optics, Radiation Distribution, Spheres, Astrophysics, Accretion Disks:Black Holes, Black Holes:Models},
6 | year = {1979},
7 | month = {May},
8 | volume = {75},
9 | pages = {228-235},
10 | adsurl = {https://ui.adsabs.harvard.edu/abs/1979A&A....75..228L},
11 | adsnote = {Provided by the SAO/NASA Astrophysics Data System}
12 | }
13 |
14 | @ARTICLE{Page_1974,
15 | title = "Disk-accretion onto a black hole. Time-averaged structure of
16 | accretion disk",
17 | author = "Page, Don N and Thorne, Kip S",
18 | journal = "Astrophys. J.",
19 | publisher = "American Astronomical Society",
20 | volume = 191,
21 | pages = "499",
22 | month = jul,
23 | year = 1974,
24 | language = "en"
25 | }
26 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Bjorge Meulemeester
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/docs/_templates/macros.rst:
--------------------------------------------------------------------------------
1 | {% macro _render_item_name(obj, sig=False) -%}
2 | :py:obj:`{{ obj.name }} <{{ obj.id }}>`
3 | {%- if sig -%}
4 | \ (
5 | {%- for arg in obj.obj.args -%}
6 | {%- if arg[0] %}{{ arg[0]|replace('*', '\*') }}{% endif -%}{{ arg[1] -}}
7 | {%- if not loop.last %}, {% endif -%}
8 | {%- endfor -%}
9 | ){%- endif -%}
10 | {%- endmacro %}
11 |
12 | {% macro _item(obj, sig=False, label='') %}
13 | * - {{ _render_item_name(obj, sig) }}
14 | - {% if label %}:summarylabel:`{{ label }}` {% endif %}{% if obj.summary %}{{ obj.summary }}{% else %}\-{% endif +%}
15 | {% endmacro %}
16 |
17 | {% macro auto_summary(objs, title='') -%}
18 | .. list-table:: {{ title }}
19 | :header-rows: 0
20 | :widths: auto
21 | :class: summarytable
22 |
23 | {% for obj in objs -%}
24 | {%- set sig = (obj.type in ['method', 'function'] and not 'property' in obj.properties) -%}
25 |
26 | {%- if 'property' in obj.properties -%}
27 | {%- set label = 'prop' -%}
28 | {%- elif 'classmethod' in obj.properties -%}
29 | {%- set label = 'class' -%}
30 | {%- elif 'abstractmethod' in obj.properties -%}
31 | {%- set label = 'abc' -%}
32 | {%- elif 'staticmethod' in obj.properties -%}
33 | {%- set label = 'static' -%}
34 | {%- else -%}
35 | {%- set label = '' -%}
36 | {%- endif -%}
37 |
38 | {{- _item(obj, sig=sig, label=label) -}}
39 | {%- endfor -%}
40 |
41 | {% endmacro %}
42 |
--------------------------------------------------------------------------------
/tests/test_black_hole_math.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | import pytest
3 |
4 | from luminet import black_hole_math as bhmath
5 |
6 | N_inclines = 2
7 | N_angles = 2
8 | N_radii = 2
9 |
10 |
11 | @pytest.mark.parametrize("mass", [1.0, 2.5, 4.0])
12 | @pytest.mark.parametrize(
13 | "incl", [np.random.randint(0, np.pi) for _ in range(N_inclines)]
14 | )
15 | @pytest.mark.parametrize(
16 | "angle", [np.random.randint(0.0, 2 * np.pi) for _ in range(N_angles)]
17 | )
18 | @pytest.mark.parametrize(
19 | "radius", [np.random.randint(6.0, 60.0) for _ in range(N_radii)]
20 | )
21 | @pytest.mark.parametrize(
22 | "order", [0.0, 1.0, 2.0]
23 | ) # test potential higher orders as well
24 | class TestParametrized:
25 |
26 | def test_calc_periastron(self, mass, incl, angle, radius, order):
27 | """
28 | Test the method for calculating the impact parameter with varying input parameters
29 | """
30 | bhmath.solve_for_perigee(
31 | radius=radius * mass,
32 | incl=incl,
33 | alpha=angle,
34 | bh_mass=mass,
35 | order=order,
36 | )
37 |
38 | def test_get_b_from_periastron(self, mass, incl, angle, radius, order):
39 | b = bhmath.solve_for_impact_parameter(
40 | radius=radius * mass,
41 | incl=incl,
42 | alpha=angle,
43 | order=order,
44 | bh_mass=mass,
45 | )
46 | assert (not np.isnan(b)) and (b is not None), (
47 | f"Calculating impact parameter failed. with"
48 | f"M={mass}, incl={incl}, alpha={angle}, R={radius}, "
49 | f"order={order}"
50 | )
51 | return None
--------------------------------------------------------------------------------
/luminet/solver.py:
--------------------------------------------------------------------------------
1 | """Scipy solvers.
2 |
3 | This module provides light wrappers to scipy solvers.
4 | """
5 |
6 | from functools import partial
7 | from typing import Callable, Dict
8 |
9 | import numpy as np
10 | import scipy.optimize as opt
11 | from scipy.interpolate import interp1d
12 |
13 |
14 | def improve_solutions(
15 | func: Callable,
16 | x: np.ndarray,
17 | y: np.ndarray,
18 | kwargs: Dict,
19 | ) -> np.ndarray:
20 | """Find the root of a function.
21 |
22 | Uses brentq to find the root of a function.
23 |
24 | Args:
25 | func (Callable): function to find the root of
26 | x (np.ndarray): x values
27 | y (np.ndarray): y values
28 | kwargs (Dict): keyword arguments for the function
29 |
30 | Returns:
31 | float: Root of the function i.e. where :math:`y = 0`
32 | """
33 | assert len(x) == len(y) == 2, "x and y must have length 2"
34 | assert np.sign(y[0]) != np.sign(y[1]), "No sign change in y"
35 |
36 | x = opt.brentq(partial(func, **kwargs), x[0], x[1])
37 | # x = opt.ridder(partial(func, **kwargs), x[0], x[1])
38 | # x = opt.bisect(partial(func, **kwargs), x[0], x[1])
39 | return x
40 |
41 |
42 | def root_2d(
43 | func: Callable,
44 | x0: np.ndarray,
45 | args: tuple = (),
46 | method: str = 'hybr',
47 | tol: float = 1e-6,
48 | ):
49 | """
50 | Find the root of a function of two variables.
51 |
52 | :meta private:
53 | """
54 | res = opt.root(func, x0, args=args, method=method, tol=tol)
55 | if not res.success:
56 | return np.array([np.nan, np.nan])
57 | return res.x
58 |
59 | def interpolator(x, y):
60 | return interp1d(x, y, kind="cubic")
61 |
--------------------------------------------------------------------------------
/luminet/spatial.py:
--------------------------------------------------------------------------------
1 | """Spatial operations
2 |
3 | Simple convenience functions for polar coordinates that are often used in this package.
4 | """
5 |
6 | import numpy as np
7 | from scipy.spatial.distance import pdist
8 | from typing import Tuple
9 |
10 | def polar_to_cartesian(
11 | th: float | np.ndarray,
12 | radius: float | np.ndarray,
13 | rotation : float = 0) -> float | np.ndarray:
14 | """Convert polar to cartesian coordinates.
15 |
16 | Args:
17 | th (float | np.ndarray): angle in radians
18 | radius (float | np.ndarray): radius
19 | rotation (float): Amount of radians to rotate the result. Useful when messing with the orientation of polar plots.
20 |
21 | Returns:
22 | Tuple[float | :class:`~np.ndarray`]: x and y coordinates
23 | """
24 | x = radius * np.cos(th + rotation)
25 | y = - radius * np.sin(th + rotation)
26 | return x, y
27 |
28 |
29 | def cartesian_to_polar(x: float | np.ndarray, y: float | np.ndarray) -> float | np.ndarray:
30 | """Convert cartesian to polar coordinates.
31 |
32 | Args:
33 | x (float | :class:`~np.ndarray`): x coordinate(s)
34 | y (float | :class:`~np.ndarray`): y coordinate(s)
35 |
36 | Returns:
37 | Tuple[float | :class:`~np.ndarray`]: angle in radians and radius
38 | """
39 | R = np.hypot(x, y)
40 | th = np.arctan2(y, x) % (2*np.pi)
41 | return th, R
42 |
43 | def polar_cartesian_distance(p1: Tuple[float], p2: Tuple[float]) -> float:
44 | """Calculate cartesian distance between two polar coordinates
45 |
46 | Args:
47 | p1 (Tuple[float]): Two-tuple containing the angle and radius of point 1.
48 | p1 (Tuple[float]): Two-tuple containing the angle and radius of point 2.
49 |
50 | Returns:
51 | float: Cartesian distance between the two points.
52 |
53 | """
54 | return pdist((p1, p2))[0]
--------------------------------------------------------------------------------
/luminet/viz.py:
--------------------------------------------------------------------------------
1 | """Visualization routines for the project.
2 |
3 | Provides convenience functions for plotting colored lines.
4 | """
5 |
6 | import matplotlib.collections as mcoll
7 | import matplotlib.pyplot as plt
8 | import numpy as np
9 |
10 | def make_segments(x, y):
11 | """
12 | Create list of line segments from x and y coordinates, in the correct format
13 | for LineCollection: an array of the form numlines x (points per line) x 2 (x
14 | and y) array
15 | """
16 |
17 | points = np.array([x, y]).T.reshape(-1, 1, 2)
18 | segments = np.concatenate([points[:-1], points[1:]], axis=1)
19 | return segments
20 |
21 |
22 | def colorline(ax, x, y, z, norm, cmap, linewidth=3, **kwargs):
23 | """Plot a line that changes color along the way.
24 |
25 | Args:
26 | ax (:class:`~matplotlib.axes.Axes`): Where to plot the line on
27 | x (array-like): x-coordinates (or angles in polar)
28 | y (array-like): y-coordinates (or radii in polar)
29 | z (array-like): color values.
30 | norm (tuple): Min and max of the colorscale.
31 | cmap (str): Name of the colormap.
32 | linewidth (float): width of the line.
33 | kwargs (optional): Additional keyword arguments to pass to :class:`matplotlib.collections.LineCollection`.
34 |
35 | See also:
36 | http://nbviewer.ipython.org/github/dpsanders/matplotlib-examples/blob/master/colorline.ipynb
37 |
38 | See also:
39 | http://matplotlib.org/examples/pylab_examples/multicolored_line.html
40 |
41 | Returns:
42 | :class:`~matpltolib.axes.Axes`: Axes with plotted line.
43 |
44 | """
45 | cmap = plt.get_cmap(cmap)
46 | norm = plt.Normalize(*norm)
47 | segments = make_segments(x, y)
48 | lc = mcoll.LineCollection(
49 | segments,
50 | cmap=cmap,
51 | linewidth=linewidth,
52 | capstyle="round",
53 | norm=norm,
54 | **kwargs
55 | )
56 | lc.set_array(z)
57 | ax.add_collection(lc)
58 | # mx = max(segments[:][:, 1].flatten())
59 | # _ax.set_ylim((0, mx))
60 | return ax
61 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | authors = [{ name = "bgmeulem", email="bjorge.meulemeester@mpinb.mpg.de"}]
3 | description = "A leightweight Python package for calculating and plotting Swarzschild black holes with thin accretion disk."
4 | name = "luminet"
5 | version = "0.2.1"
6 | requires-python = ">=3.5"
7 | # PyPI dependencies
8 | dependencies = [
9 | "matplotlib>=3.10.0,<4",
10 | "numpy>=2.2.2,<3",
11 | "scipy>=1.15.1,<2",
12 | "pandas>=2.2.3,<3",
13 | ]
14 |
15 | classifiers = [
16 | "Development Status :: 4 - Beta",
17 | "Intended Audience :: Education",
18 | "Topic :: Scientific/Engineering :: Astronomy",
19 | "Topic :: Scientific/Engineering :: Physics",
20 | "Topic :: Scientific/Engineering :: Visualization",
21 | "License :: OSI Approved :: MIT License",
22 | "Programming Language :: Python :: 3 :: Only",
23 | ]
24 | readme = "README.md"
25 | license = { text = "MIT" }
26 |
27 | [project.urls]
28 | "Homepage" = "https://luminet.readthedocs.io/en/latest/"
29 | "Documentation" = "https://luminet.readthedocs.io/en/latest/"
30 | "Bug Reports" = "https://github.com/bgmeulem/luminet/issues/new"
31 | "Source" = "https://github.com/bgmeulem/luminet"
32 |
33 | [dependency-groups]
34 | build = ["build>=1.2.2.post1,<2"]
35 |
36 | [tool.setuptools]
37 | packages = ["luminet"]
38 |
39 | [tool.pixi.workspace]
40 | preview = ["pixi-build"]
41 | channels = ["https://prefix.dev/conda-forge"]
42 | platforms = ["win-64", "linux-64", "osx-arm64", "osx-64"]
43 |
44 | [tool.pixi.package.build]
45 | backend = { name = "pixi-build-python", version = "0.1.*" }
46 | channels = [
47 | "https://prefix.dev/pixi-build-backends",
48 | "https://prefix.dev/conda-forge",
49 | ]
50 |
51 | [tool.pixi.package.host-dependencies]
52 | hatchling = "*"
53 | setuptools = "*"
54 |
55 | [tool.pixi.activation]
56 | env = { "PYTHONPATH" = "$PYTHONPATH:$(pwd)/src" }
57 |
58 | [tool.pixi.environments]
59 | docs = ["docs"]
60 | build = ["build"]
61 | test = ["test"]
62 |
63 | [tool.pixi.feature.test.tasks]
64 | test = { cmd = "coverage run -m pytest", cwd = "tests"}
65 |
66 | [tool.pixi.feature.docs.tasks]
67 | build_docs = {cmd = ["make", "html"], description = "Build the documentation", cwd="docs"}
68 | host_docs = {cmd = ["python", "-m", "http.server", "8000"], description = "Host the documentation", cwd="docs/build/html"}
69 |
70 | [tool.pixi.feature.docs.dependencies]
71 | sphinx = ">=8.1.3,<9"
72 | pygments = ">=2.19.1,<3"
73 | furo = ">=2024.8.6,<2025"
74 | toml = ">=0.10.2,<0.11"
75 | sphinxcontrib-bibtex = ">=2.6.3,<3"
76 | sphinx-copybutton = ">=0.5.2,<0.6"
77 | sphinx-inline-tabs = ">=2023.4.21,<2024"
78 | sphinx-autoapi = ">=3.4.0,<4"
79 |
80 | [tool.pixi.feature.test.dependencies]
81 | pytest = ">=8.3.4,<9"
82 | coverage = ">=7.6.12,<8"
83 |
84 | [tool.pixi.feature.docs.pypi-dependencies]
85 | catppuccin = { version = ">=2.3.4, <3", extras = ["pygments"] }
86 | sphinx-paramlinks = ">=0.6.0, <0.7"
87 |
88 | [tool.pixi.feature.build.dependencies]
89 | twine = "*"
90 | pip = ">=25.0.1,<26"
91 |
92 | [tool.pixi.dependencies]
93 | toml = ">=0.10.2,<0.11"
94 |
--------------------------------------------------------------------------------
/docs/_templates/python/class.rst:
--------------------------------------------------------------------------------
1 | {% set short_name = obj.id.split('.') | last | escape %}
2 |
3 | {% if obj.display %}
4 | {% if is_own_page %}
5 | {{ obj.short_name }}
6 | {{ "=" * short_name | length }}
7 |
8 | {% endif %}
9 | {% set visible_children = obj.children|selectattr("display")|list %}
10 | {% set own_page_children = visible_children|selectattr("type", "in", own_page_types)|list %}
11 | {% if is_own_page and own_page_children %}
12 | .. toctree::
13 | :hidden:
14 |
15 | {% for child in own_page_children %}
16 | {{ child.id.split('.') | last }} <{{ child.include_path }}>
17 | {% endfor %}
18 |
19 | {% endif %}
20 | .. py:{{ obj.type }}:: {% if is_own_page %}{{ obj.id }}{% else %}{{ obj.short_name }}{% endif %}{% if obj.args %}({{ obj.args }}){% endif %}
21 |
22 | {% for (args, return_annotation) in obj.overloads %}
23 | {{ " " * (obj.type | length) }} {{ obj.short_name }}{% if args %}({{ args }}){% endif %}
24 |
25 | {% endfor %}
26 | {% if obj.bases %}
27 | {% if "show-inheritance" in autoapi_options %}
28 |
29 | Bases: {% for base in obj.bases %}{{ base|link_objs }}{% if not loop.last %}, {% endif %}{% endfor %}
30 | {% endif %}
31 |
32 |
33 | {% if "show-inheritance-diagram" in autoapi_options and obj.bases != ["object"] %}
34 | .. autoapi-inheritance-diagram:: {{ obj.obj["full_name"] }}
35 | :parts: 1
36 | {% if "private-members" in autoapi_options %}
37 | :private-bases:
38 | {% endif %}
39 |
40 | {% endif %}
41 | {% endif %}
42 | {% if obj.docstring %}
43 |
44 | {{ obj.docstring|indent(3) }}
45 | {% endif %}
46 | {% for obj_item in visible_children %}
47 | {% if obj_item.type not in own_page_types %}
48 |
49 | {{ obj_item.render()|indent(3) }}
50 | {% endif %}
51 | {% endfor %}
52 | {% if is_own_page and own_page_children %}
53 | {% set visible_attributes = own_page_children|selectattr("type", "equalto", "attribute")|list %}
54 | {% if visible_attributes %}
55 | Attributes
56 | ----------
57 |
58 | .. autoapisummary::
59 |
60 | {% for attribute in visible_attributes %}
61 | {{ attribute.id }}
62 | {% endfor %}
63 |
64 |
65 | {% endif %}
66 | {% set visible_exceptions = own_page_children|selectattr("type", "equalto", "exception")|list %}
67 | {% if visible_exceptions %}
68 | Exceptions
69 | ----------
70 |
71 | .. autoapisummary::
72 |
73 | {% for exception in visible_exceptions %}
74 | {{ exception.id }}
75 | {% endfor %}
76 |
77 |
78 | {% endif %}
79 | {% set visible_classes = own_page_children|selectattr("type", "equalto", "class")|list %}
80 | {% if visible_classes %}
81 | Classes
82 | -------
83 |
84 | .. autoapisummary::
85 |
86 | {% for klass in visible_classes %}
87 | {{ klass.id }}
88 | {% endfor %}
89 |
90 |
91 | {% endif %}
92 | {% set visible_methods = own_page_children|selectattr("type", "equalto", "method")|list %}
93 | {% if visible_methods %}
94 | Methods
95 | -------
96 |
97 | .. autoapisummary::
98 |
99 | {% for method in visible_methods %}
100 | {{ method.id }}
101 | {% endfor %}
102 |
103 |
104 | {% endif %}
105 | {% endif %}
106 | {% endif %}
107 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 | on:
3 | push:
4 | tags:
5 | - "v[0-9]+.[0-9]+.[0-9]+"
6 | - "v[0-9]+.[0-9]+.[0-9]+a[0-9]+"
7 | - "v[0-9]+.[0-9]+.[0-9]+b[0-9]+"
8 | - "v[0-9]+.[0-9]+.[0-9]+rc[0-9]+"
9 | workflow_dispatch:
10 |
11 | env:
12 | PACKAGE: "luminet"
13 | PYTHON_VERSION: "3.8"
14 |
15 | jobs:
16 | waiting_room:
17 | name: Waiting Room
18 | runs-on: ubuntu-latest
19 | needs: [conda_build, pip_install]
20 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
21 | environment:
22 | name: deploy
23 | steps:
24 | - run: echo "All builds have finished, have been approved, and ready to publish"
25 |
26 | pip_build:
27 | name: Build sdist & wheel
28 | runs-on: "ubuntu-latest"
29 | steps:
30 | - uses: actions/checkout@v3
31 | - uses: prefix-dev/setup-pixi@v0.8.2
32 | with:
33 | pixi-version: v0.41.3
34 | cache: true
35 | auth-host: prefix.dev
36 | auth-token: ${{ secrets.PREFIX_DEV_TOKEN }}
37 | - name: Build package
38 | run: |
39 | pixi r -e build python -m build
40 | pixi r -e build twine check dist/*
41 | - uses: actions/upload-artifact@v4
42 | if: always()
43 | with:
44 | name: pip
45 | path: dist/
46 | if-no-files-found: error
47 |
48 | pip_install:
49 | name: Test pip install from wheel
50 | runs-on: "ubuntu-latest"
51 | needs: [pip_build]
52 | steps:
53 | - uses: actions/checkout@v3
54 | - uses: actions/download-artifact@v4
55 | with:
56 | name: pip
57 | path: dist/
58 | - uses: prefix-dev/setup-pixi@v0.8.2
59 | with:
60 | pixi-version: v0.41.3
61 | cache: true
62 | auth-host: prefix.dev
63 | auth-token: ${{ secrets.PREFIX_DEV_TOKEN }}
64 | - name: Install package
65 | run: |
66 | pixi r -e build python -m pip install dist/*.whl
67 | - name: Import package
68 | run: pixi r python -c "import $PACKAGE; print($PACKAGE.__version__)"
69 |
70 | pip_publish:
71 | name: Publish to PyPI
72 | runs-on: ubuntu-latest
73 | needs: [waiting_room]
74 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
75 | permissions:
76 | id-token: write
77 | contents: write
78 | environment: PyPI
79 | steps:
80 | - uses: actions/download-artifact@v4
81 | with:
82 | name: pip
83 | path: dist/
84 | - name: Publish to PyPI
85 | uses: pypa/gh-action-pypi-publish@release/v1
86 |
87 | conda_build:
88 | name: Build .conda package
89 | runs-on: "ubuntu-latest"
90 | steps:
91 | - uses: actions/checkout@v3
92 | - uses: prefix-dev/setup-pixi@v0.8.2
93 | with:
94 | pixi-version: v0.41.3
95 | cache: true
96 | auth-host: prefix.dev
97 | auth-token: ${{ secrets.PREFIX_DEV_TOKEN }}
98 | - name: Build .conda
99 | run: pixi build -o dist/
100 | - uses: actions/upload-artifact@v4
101 | if: always()
102 | with:
103 | name: conda
104 | path: "dist/*.conda"
105 | if-no-files-found: error
106 |
107 | conda_publish:
108 | name: Publish to conda-forge
109 | runs-on: ubuntu-latest
110 | needs: [waiting_room]
111 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
112 | defaults:
113 | run:
114 | shell: bash -el {0}
115 | steps:
116 | - uses: actions/download-artifact@v4
117 | with:
118 | name: conda
119 | path: dist/
120 | - name: Set environment variables
121 | run: |
122 | echo "TAG=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
123 | echo "CONDA_FILE=$(ls dist/*.conda)" >> $GITHUB_ENV
124 | - uses: conda-incubator/setup-miniconda@v3
125 | with:
126 | miniconda-version: "latest"
127 | channels: "conda-forge"
128 | - name: conda setup
129 | run: |
130 | conda install -y anaconda-client
131 | - name: conda dev upload
132 | if: contains(env.TAG, 'a') || contains(env.TAG, 'b') || contains(env.TAG, 'rc')
133 | run: |
134 | anaconda --token ${{ secrets.ANACONDA_TOKEN }} upload --skip-existing --user bgmeulem --label=dev $CONDA_FILE
135 | - name: conda main upload
136 | if: (!(contains(env.TAG, 'a') || contains(env.TAG, 'b') || contains(env.TAG, 'rc')))
137 | run: |
138 | anaconda --token ${{ secrets.ANACONDA_TOKEN }} upload --skip-existing --user bgmeulem --label=dev --label=main $CONDA_FILE
139 |
--------------------------------------------------------------------------------
/docs/_templates/python/module.rst:
--------------------------------------------------------------------------------
1 | {% set short_name = obj.id.split('.') | last | escape %}
2 |
3 | {% if obj.display %}
4 | {% if is_own_page %}
5 | {{ short_name }}
6 | {{ "=" * short_name|length }}
7 |
8 | .. py:module:: {{ obj.name }}
9 |
10 | {% if obj.docstring %}
11 | .. autoapi-nested-parse::
12 |
13 | {{ obj.docstring|indent(3) }}
14 |
15 | {% endif %}
16 |
17 | {% block submodules %}
18 | {% set visible_subpackages = obj.subpackages|selectattr("display")|list %}
19 | {% set visible_submodules = obj.submodules|selectattr("display")|list %}
20 | {% set visible_submodules = (visible_subpackages + visible_submodules)|sort %}
21 | {% if visible_submodules %}
22 | Submodules
23 | ----------
24 |
25 | .. toctree::
26 | :maxdepth: 1
27 |
28 | {% for submodule in visible_submodules %}
29 | {{ submodule.id.split('.') | last }} <{{ submodule.include_path }}>
30 | {% endfor %}
31 |
32 |
33 | {% endif %}
34 | {% endblock %}
35 | {% block content %}
36 | {% set visible_children = obj.children|selectattr("display")|list %}
37 | {% if visible_children %}
38 | {% set visible_attributes = visible_children|selectattr("type", "equalto", "data")|list %}
39 | {% if visible_attributes %}
40 | {% if "attribute" in own_page_types or "show-module-summary" in autoapi_options %}
41 | Attributes
42 | ----------
43 |
44 | {% if "attribute" in own_page_types %}
45 | .. toctree::
46 | :hidden:
47 |
48 | {% for attribute in visible_attributes %}
49 | {{ attribute.id.split('.') | last }} <{{ attribute.include_path }}>
50 | {% endfor %}
51 |
52 | {% endif %}
53 | .. autoapisummary::
54 |
55 | {% for attribute in visible_attributes %}
56 | {{ attribute.id }}
57 | {% endfor %}
58 | {% endif %}
59 |
60 |
61 | {% endif %}
62 | {% set visible_exceptions = visible_children|selectattr("type", "equalto", "exception")|list %}
63 | {% if visible_exceptions %}
64 | {% if "exception" in own_page_types or "show-module-summary" in autoapi_options %}
65 | Exceptions
66 | ----------
67 |
68 | {% if "exception" in own_page_types %}
69 | .. toctree::
70 | :hidden:
71 |
72 | {% for exception in visible_exceptions %}
73 | {{ exception.id.split('.') | last }} <{{ excpetion.include_path }}>
74 | {% endfor %}
75 |
76 | {% endif %}
77 | .. autoapisummary::
78 |
79 | {% for exception in visible_exceptions %}
80 | {{ exception.id }}
81 | {% endfor %}
82 | {% endif %}
83 |
84 |
85 | {% endif %}
86 | {% set visible_classes = visible_children|selectattr("type", "equalto", "class")|list %}
87 | {% if visible_classes %}
88 | {% if "class" in own_page_types or "show-module-summary" in autoapi_options %}
89 | Classes
90 | -------
91 |
92 | {% if "class" in own_page_types %}
93 | .. toctree::
94 | :hidden:
95 |
96 | {% for klass in visible_classes %}
97 | {{ klass.id.split('.') | last }} <{{ klass.include_path }}>
98 | {% endfor %}
99 |
100 | {% endif %}
101 | .. autoapisummary::
102 |
103 | {% for klass in visible_classes %}
104 | {{ klass.id }}
105 | {% endfor %}
106 | {% endif %}
107 |
108 |
109 | {% endif %}
110 | {% set visible_functions = visible_children|selectattr("type", "equalto", "function")|list %}
111 | {% if visible_functions %}
112 | {% if "function" in own_page_types or "show-module-summary" in autoapi_options %}
113 | Functions
114 | ---------
115 |
116 | {% if "function" in own_page_types %}
117 | .. toctree::
118 | :hidden:
119 |
120 | {% for function in visible_functions %}
121 | {{ function.id.split('.') | last }} <{{ function.include_path }}>
122 | {% endfor %}
123 |
124 | {% endif %}
125 | .. autoapisummary::
126 |
127 | {% for function in visible_functions %}
128 | {{ function.id }}
129 | {% endfor %}
130 | {% endif %}
131 |
132 |
133 | {% endif %}
134 | {% set this_page_children = visible_children|rejectattr("type", "in", own_page_types)|list %}
135 | {% if this_page_children %}
136 | {{ obj.type|title }} Contents
137 | {{ "-" * obj.type|length }}---------
138 |
139 | {% for obj_item in this_page_children %}
140 | {{ obj_item.render()|indent(0) }}
141 | {% endfor %}
142 | {% endif %}
143 | {% endif %}
144 | {% endblock %}
145 | {% else %}
146 | .. py:module:: {{ obj.name }}
147 |
148 | {% if obj.docstring %}
149 | .. autoapi-nested-parse::
150 |
151 | {{ obj.docstring|indent(6) }}
152 |
153 | {% endif %}
154 | {% for obj_item in visible_children %}
155 | {{ obj_item.render()|indent(3) }}
156 | {% endfor %}
157 | {% endif %}
158 | {% endif %}
159 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # luminet
4 |  [](https://luminet.readthedocs.io/en/latest/?badge=latest) [](https://pypi.org/project/luminet)
5 | [](https://anaconda.org/bgmeulem/luminet)
6 | 
7 |
8 | Simulate and visualize Swarzschild black holes, based on the methods described in Luminet (1979).
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | ## ⚡ Install
21 | `luminet` is available from [PyPI](https://pypi.org/project/luminet/) and [Anaconda](https://anaconda.org/bgmeulem/luminet):
22 | ```shell
23 | pixi add --pypi luminet
24 | ```
25 |
26 |
27 | ## 📖 [Documentation](https://luminet.readthedocs.io/en/latest/index.html)
28 |
29 | ## 🔩 Usage
30 |
31 | All variables in this repo are in natural units: $G=c=1$
32 |
33 | ```python
34 | from luminet.black_hole import BlackHole
35 | bh = BlackHole(
36 | mass=1,
37 | incl=1.4, # inclination in radians
38 | acc=1, # accretion rate
39 | outer_edge=40
40 | )
41 | ```
42 | To create an image:
43 | ```python
44 | ax = bh.plot() # Create image like above
45 | ```
46 |
47 | To sample photons on the accretion disk:
48 | ```python
49 | bh.sample_photons(100)
50 | bh.photons
51 | ```
52 | ```
53 | radius alpha impact_parameter z_factor flux_o
54 | 10.2146 5.1946 1.8935 1.1290 1.8596e-05
55 | ... (99 more)
56 | ```
57 |
58 | Note that sampling is biased towards the center of the black hole, since this is where most of the luminosity comes from.
59 |
60 |
61 | ## 📝 Background
62 | Swarzschild black holes have an innermost stable orbit of $6M$, and a photon sphere at $3M$. This means that
63 | the accretion disk orbiting the black hole emits photons at radii $r>6M$. As long as the photon perigee in curved space remains larger than $3M$ (also called the photon sphere), the photon is not captured by the black hole and can in theory be seen from some observer frame $(b, \alpha)$. The spacetime curvature is most easily interpreted as a lensing effect between the black hole frame $(r, \alpha)$ and the observer frame $(b, \alpha)$. The former are 2D polar coordinates that span the accretion disk area, and the latter are 2D polar coordinates that span the "photographic plate" of the observer frame. Think of the latter as a literal CCD camera. The photon orbit perigee and the radius in observer frame $b$ are directly related:
64 |
65 | $$b^2 = \frac{P^3}{P-2M}$$
66 |
67 | This makes many equations significantly more straightforward.
68 |
69 | The relationship between the angles of both coordinate systems is trivial, but the relationship between the radii in the two reference frames is given by the monstruous Equation 13:
70 |
71 | $$\frac{1}{r} = - \frac{Q - P + 2M}{4MP} + \frac{Q-P+6M}{4MP}{sn}^2\left( \frac{\gamma}{2}\sqrt{\frac{Q}{P}} + F(\zeta_\infty, k) \right)$$
72 |
73 | Here, $F$ is an incomplete Jacobian elliptic integral of the first kind, $k$ and $Q$ are a function of the perigee $P$, $\zeta$ are trigonometric functions of $P$, and $\gamma$ satisfies:
74 |
75 | $$\cos(\gamma) = \frac{\cos(\alpha)}{\sqrt{\cos^2\alpha + \cot^2\theta_0}}$$
76 |
77 | In curved spacetime, there are usually multiple photon orbits that originate from the same coordinate and project to the ibserver frame (see e.g. gravitational lensing and Einstein crosses). Photon orbits that curve around the black hole and reach the observer frame are called "higher order" images, or "ghost" images. In this case, $\gamma$ satisfies:
78 |
79 | $$2n\pi - \gamma = 2\sqrt{\frac{Q}{P}} \left( 2K(k) - F(\zeta_\infty, k) - F(\zeta_r, k) \right)$$
80 |
81 | These ghost photons are what you see on the lower half of the image above, as well as the barely visible halo of light that wraps thinly around the photon sphere. For inclinations that are less edge-on, this ghost image is less pronounced though.
82 |
83 | This repo uses `scipy.optimize.brentq` to solve these equations, and provides convenient API to the concepts presented in Luminet (1979). The `BlackHole` class is the most obvious one, but it's also educative to play around with e.g. the `Isoradial` class: lines in observer frame describing photons emitted from the same radius in the black hole frame. The `Isoredshift` class provides lines of equal redshift in the observer frame.
84 |
85 | ## 📕 Bibliography
86 | [1] Luminet, J.-P., [“Image of a spherical black hole with thin accretion disk.”](https://ui.adsabs.harvard.edu/abs/1979A%26A....75..228L/abstract), Astronomy and Astrophysics, vol. 75, pp. 228–235, 1979.
87 |
88 | [2] J.-P. Luminet, [“An Illustrated History of Black Hole Imaging : Personal Recollections (1972-2002).”](https://arxiv.org/abs/1902.11196) arXiv, 2019. doi: 10.48550/ARXIV.1902.11196.
89 |
90 | [3] Don N Page and Kip S Thorne. [Disk-accretion onto a black hole. time-averaged structure of accretion disk.](https://ui.adsabs.harvard.edu/abs/1974ApJ...191..499P/abstract) Astrophys. J., 191:499, July 1974.
91 |
--------------------------------------------------------------------------------
/luminet/isoredshift.py:
--------------------------------------------------------------------------------
1 | """Lines of equal redshift in the observer plane"""
2 |
3 | import numpy as np
4 | from luminet.spatial import polar_cartesian_distance
5 | import logging
6 | logger = logging.getLogger(__name__)
7 |
8 |
9 | class Isoredshift:
10 | """Leightwieght dataclass to store and visualize lines of equal redshift in the observer plane.
11 |
12 | This class is rarely initialized by the user. It is however often used by the :class:`~luminet.black_hole.BlackHole`
13 | class to handle isoredshifts.
14 |
15 | Isoredshift lines usually have two solutions, more or less in each hemisphere of the observer plane.
16 | A notable exception is closed isoredshifts, which will have only a single solution at the tip.
17 | """
18 | def __init__(
19 | self,
20 | redshift,
21 | order = 0,
22 | angles = None,
23 | impact_parameters = None,
24 | ir_radii = None
25 | ):
26 | """
27 | Args:
28 | incl (float): Inclination angle of the observer
29 | redshift (float): redshift value
30 | bh_mass (float): mass of the black hole
31 | """
32 | self.redshift = redshift
33 | """float: redshift value"""
34 | self.order = order
35 | """int: order of the image associated with this isoredshift"""
36 |
37 | # Isoredshift attributes: normally set by BlackHole
38 | self.angles = None
39 | """np.ndarray: angles of the isoredshifts"""
40 | self.impact_parameters = None
41 | """np.ndarray: radii of the isoredshifts in the observer plane."""
42 | self.ir_radii = ir_radii or None
43 | """np.ndarray: radii of the isoradials used to calculate the isoredshifts"""
44 |
45 | if angles is not None: self.set_angles(angles)
46 | if impact_parameters is not None: self.set_impact_parameters(impact_parameters)
47 |
48 | def set_angles(self, angle_pairs):
49 | r"""Set the angular coordinates of this isoredshift
50 |
51 | Convenience method to initialize coordinates from the solver results.
52 | Solver results return two-tuples, which are unpacked here.
53 |
54 | Args:
55 | angle_pairs (List[tuple]): Array of two-tuples of angles :math:`\alpha`.
56 | """
57 | self.angles = np.array(list(zip(*angle_pairs)))
58 |
59 | def set_impact_parameters(self, impact_parameter_pairs):
60 | """Set the observed radial coordinates of this isoredshift
61 |
62 | Convenience method to initialize coordinates from the solver results.
63 | Solver results return two-tuples, which are unpacked here.
64 |
65 | Args:
66 | impact_parameter_pairs (List[tuple]): Array of two-tuples of impact parameters :math:`b`.
67 | """
68 | self.impact_parameters = np.array(list(zip(*impact_parameter_pairs)))
69 |
70 | def _clean(self):
71 | """Remove None values from the coordinates"""
72 | nanmask0 = [a != None for a in self.angles[0]]
73 | angles0 = self.angles[0][nanmask0]
74 | impact_parameters0 = self.impact_parameters[0][nanmask0]
75 |
76 | nanmask1 = [a != None for a in self.angles[1]]
77 | angles1 = self.angles[1][nanmask1]
78 | impact_parameters1 = self.impact_parameters[1][nanmask1]
79 |
80 | self.angles = np.array([angles0, angles1])
81 | self.impact_parameters = np.array([impact_parameters0, impact_parameters1])
82 |
83 |
84 | def _get_last_points(self):
85 | """Get the last not-None coordinate of each subredshift
86 | """
87 | a1 = [a for a in self.angles[0] if a is not None]
88 | a1 = a1[-1] if a1 else None
89 | b1 = [b for b in self.impact_parameters[0] if b is not None]
90 | b1 = b1[-1] if b1 else None
91 | p1 = (a1, b1)
92 |
93 | a2 = [a for a in self.angles[1] if a is not None]
94 | a2 = a2[-1] if a2 else None
95 | b2 = [b for b in self.impact_parameters[1] if b is not None]
96 | b2 = b2[-1] if b2 else None
97 | p2 = (a2, b2)
98 |
99 | return p1, p2
100 |
101 | def _is_close(self, tol=5e-2):
102 | """Check if the isoredshift is (almost) a closed one.
103 |
104 | Args:
105 | tol (float):
106 | Tolerance below which the gap between the two
107 | sub-isoredshifts is considered to be closed.
108 |
109 | Returns:
110 | bool: True if the Euclidean distance between the two end-points is below :paramref:`tol`
111 | """
112 | # last not-None coordinates of each subredshift
113 | p1, p2 = self._get_last_points()
114 | if any([c is None for c in [*p1, *p2]]): return False
115 | if polar_cartesian_distance(p1, p2) < tol:
116 | return True
117 | return False
118 |
119 | def _join(self):
120 | """Join the isoredshift if it is (almost) a closed one."""
121 | self._clean()
122 | self.angles = np.array([*self.angles[0], *self.angles[1][::-1]]), np.array([])
123 | self.impact_parameters = np.array([*self.impact_parameters[0], *self.impact_parameters[1][::-1]]), np.array([])
124 |
125 | def plot(self, ax, **kwargs):
126 | """Plot the isoredshift on an ax
127 |
128 | Args:
129 | ax (:class:`matplotlib.axes.Axes`): Ax object to plot on.
130 | kwargs (optional): Optional keyword arguments to pass to :func:`matplotlib.pyplot.plot`
131 | """
132 | if self._is_close(): self._join()
133 | for n in range(len(self.angles)):
134 | ax.plot(self.angles[n], self.impact_parameters[n], label=self.redshift, **kwargs)
135 | return ax
--------------------------------------------------------------------------------
/docs/source/conf.py:
--------------------------------------------------------------------------------
1 | # Configuration file for the Sphinx documentation builder.
2 | #
3 | # For the full list of built-in configuration values, see the documentation:
4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html
5 |
6 | # -- Project information -----------------------------------------------------
7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
8 |
9 | import sys, os, toml
10 | # Parse pyproject.toml to get the release version
11 | project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
12 | sys.path.insert(0, os.path.abspath(os.path.join(project_root, 'luminet')))
13 | pyproject_path = os.path.join(project_root, 'pyproject.toml')
14 | with open(pyproject_path, 'r') as f:
15 | pyproject_data = toml.load(f)
16 | release = pyproject_data['project']['version']
17 | version = release
18 | project = 'Luminet'
19 | author = 'Bjorge Meulemeester, J. P. Luminet'
20 | copyright = '2025, Bjorge Meulemeester'
21 |
22 |
23 | # -- General configuration ------------------------------------------------
24 |
25 | extensions = [
26 | 'sphinx.ext.autodoc', # Core library for html generation from docstrings
27 | # 'sphinx.ext.autosummary', # Create neat summary tables
28 | "autoapi.extension", # Generate API documentation from code
29 | 'sphinx.ext.napoleon', # Preprocess docstrings to convert Google-style docstrings to reST
30 | 'sphinx_paramlinks', # Parameter links
31 | 'sphinx.ext.todo', # To-do notes
32 | 'sphinx.ext.viewcode',
33 | 'sphinx.ext.intersphinx', # Link to other project's documentation
34 | 'sphinxcontrib.bibtex', # For citations
35 | 'sphinx.ext.mathjax', # For math equations
36 | 'sphinx_copybutton', # For copying code snippets
37 | 'sphinx_inline_tabs', # For inline tabs
38 | # 'sphinxext.opengraph', # For OpenGraph metadata, only enable when the site is actually hosted. See https://github.com/wpilibsuite/sphinxext-opengraph for config options when that happens.
39 | ]
40 |
41 |
42 | autoapi_own_page_level = "method"
43 | autoapi_type = "python"
44 | autoapi_keep_files = True
45 | autoapi_add_toctree_entry = False # we use a manual autosummary directive in api_reference.rst thats included in the toctree
46 | autoapi_generate_api_docs = True
47 | # generate the .rst stub files. The template directives don't do this.
48 | autoapi_options = [
49 | "members",
50 | "undoc-members",
51 | "show-module-summary",
52 | ]
53 | autoapi_dirs = ['../../luminet']
54 | autoapi_template_dir = "../_templates"
55 | templates_dir = ["../_templates"]
56 | toc_object_entries_show_parents = 'hide' # short toc entries
57 |
58 | default_domain = "python"
59 | intersphinx_mapping = {
60 | 'numpy': ('https://numpy.org/doc/stable/', None),
61 | 'scipy': ('https://docs.scipy.org/doc/scipy/reference/', None),
62 | 'matplotlib': ('https://matplotlib.org/stable/', None),
63 | }
64 |
65 |
66 | # -- bibtex files ----------------------------------------------------------
67 | bibtex_bibfiles = ['bibliography.bib']
68 |
69 | # -- Napoleon settings -----------------------------------------------------
70 | napoleon_google_docstring = True
71 | napoleon_include_init_with_doc = True
72 | napoleon_include_private_with_doc = False
73 | napoleon_include_special_with_doc = False
74 | napoleon_use_admonition_for_examples = False
75 | napoleon_use_admonition_for_notes = False
76 | napoleon_use_admonition_for_references = False
77 | napoleon_use_ivar = False
78 | napoleon_use_param = False # use a single ":parameters:" section instead of ":param arg1: description" for each argument
79 | napoleon_use_rtype = False # if True, separate return type from description. otherwise, it's included in the description inline
80 | napoleon_preprocess_types = False # otherwise custom argument types will not work
81 | napoleon_type_aliases = None
82 | napoleon_attr_annotations = True
83 |
84 | ## Include Python objects as they appear in source files
85 | ## Default: alphabetically ('alphabetical')
86 | # autodoc_member_order = 'bysource'
87 |
88 | ## Generate autodoc stubs with summaries from code
89 | paramlinks_hyperlink_param = 'name'
90 |
91 | # The suffix(es) of source filenames.
92 | # You can specify multiple suffix as a list of string:
93 | # source_suffix = ['.rst', '.md']
94 | source_suffix = '.rst'
95 |
96 | # The encoding of source files.
97 | source_encoding = 'utf-8-sig'
98 |
99 | # The master toctree document.
100 | master_doc = 'index'
101 |
102 | # If true, '()' will be appended to :func: etc. cross-reference text.
103 | #add_function_parentheses = True
104 |
105 | # If true, the current module name will be prepended to all description
106 | # unit titles (such as .. function::).
107 | add_module_names = False # less verbose for nested packages
108 |
109 | # If true, sectionauthor and moduleauthor directives will be shown in the
110 | # output. They are ignored by default.
111 | #show_authors = False
112 |
113 | # The name of the Pygments (syntax highlighting) style to use.
114 | sys.path.append(os.path.abspath("../_pygments"))
115 | pygments_style = 'style.LightStyle'
116 | pygments_dark_style = 'material' # furo specific
117 |
118 | # A list of ignored prefixes for module index sorting.
119 | #modindex_common_prefix = []
120 |
121 | # If true, keep warnings as "system message" paragraphs in the built documents.
122 | #keep_warnings = False
123 |
124 | # If true, `todo` and `todoList` produce output, else they produce nothing.
125 | todo_include_todos = False
126 |
127 | # -- Options for HTML output ----------------------------------------------
128 |
129 | # The theme to use for HTML and HTML Help pages. See the documentation for
130 | # a list of builtin themes.
131 | html_theme = "furo"
132 |
133 | # Theme options are theme-specific and customize the look and feel of a theme
134 | # further. For a list of options available for each theme, see the
135 | # documentation.
136 | html_static_path = ['../../assets', '../../docs/_static/_images/']
137 | html_theme_options = {
138 | "dark_logo": "isofluxlines_white.png",
139 | "light_logo": "isofluxlines_black.png",
140 | "sidebar_hide_name": True,
141 | "light_css_variables": {
142 | "color-brand-primary": "#000000", # black instead of blue
143 | "color-foreground-secondary": "#797979", # slightly more muted than default
144 | "color-sidebar-background": "#F2F1ED",
145 | "color-sidebar-item-background--hover": "#E5E3DC",
146 | "color-background-hover": "#E5E3DC",
147 | "color-highlight-on-target": "#FDF8EB",
148 | },
149 | "dark_css_variables": {
150 | "color-brand-primary": "#fefaee", # Off-white
151 | "color-foreground-primary": "#E6E1D4",
152 | "color-brand-content": "#FFB000", # Gold instead of dark blue
153 | "color-sidebar-background": "#1A1C1E",
154 | "color-sidebar-item-background--hover": "#1e2124",
155 | "color-link": "#FFC23E",
156 | "color-highlight-on-target": "#2D222B",
157 | "color-link--visited": "#a58abf",
158 | },
159 | }
160 |
161 | # Add any paths that contain custom themes here, relative to this directory.
162 | #html_theme_path = []
163 |
164 | # The name for this set of Sphinx documents. If None, it defaults to
165 | # " v documentation".
166 | #html_title = None
167 |
168 | # A shorter title for the navigation bar. Default is the same as html_title.
169 | #html_short_title = None
170 |
171 | # The name of an image file (relative to this directory) to place at the top
172 | # of the sidebar.
173 | #html_logo = None
174 |
175 | # The name of an image file (within the static path) to use as favicon of the
176 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
177 | # pixels large.
178 | #html_favicon = None
179 |
180 | # Add any paths that contain custom static files (such as style sheets) here,
181 | # relative to this directory. They are copied after the builtin static files,
182 | # so a file named "default.css" will overwrite the builtin "default.css".
183 | # html_static_path = ['_static']
184 | html_css_files = [
185 | 'default.css', # relative to html_static_path defined above
186 | ]
187 |
188 |
189 | # Add any extra paths that contain custom files (such as robots.txt or
190 | # .htaccess) here, relative to this directory. These files are copied
191 | # directly to the root of the documentation.
192 | #html_extra_path = []
193 |
194 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
195 | # using the given strftime format.
196 | #html_last_updated_fmt = '%b %d, %Y'
197 |
198 | # If true, SmartyPants will be used to convert quotes and dashes to
199 | # typographically correct entities.
200 | #html_use_smartypants = True
201 |
202 | # Additional templates that should be rendered to pages, maps page names to
203 | # template names.
204 | #html_additional_pages = {}
205 |
206 | # If false, no module index is generated.
207 | #html_domain_indices = True
208 |
209 | # If false, no index is generated.
210 | #html_use_index = True
211 |
212 | # If true, the index is split into individual pages for each letter.
213 | #html_split_index = False
214 |
215 | # If true, links to the reST sources are added to the pages.
216 |
217 | ## I don't like links to page reST sources
218 | html_show_sourcelink = True
219 |
220 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
221 | #html_show_sphinx = True
222 |
223 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
224 | #html_show_copyright = True
225 |
226 | # If true, an OpenSearch description file will be output, and all pages will
227 | # contain a tag referring to it. The value of this option must be the
228 | # base URL from which the finished HTML is served.
229 | #html_use_opensearch = ''
230 |
231 | # This is the file name suffix for HTML files (e.g. ".xhtml").
232 | #html_file_suffix = None
233 |
234 | # Language to be used for generating the HTML full-text search index.
235 | # Sphinx supports the following languages:
236 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja'
237 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr'
238 | #html_search_language = 'en'
239 |
240 | # A dictionary with options for the search language support, empty by default.
241 | # Now only 'ja' uses this config value
242 | #html_search_options = {'type': 'default'}
243 |
244 | # The name of a javascript file (relative to the configuration directory) that
245 | # implements a search results scorer. If empty, the default will be used.
246 | #html_search_scorer = 'scorer.js'
247 | # -- General configuration ---------------------------------------------------
248 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
249 |
--------------------------------------------------------------------------------
/luminet/isoradial.py:
--------------------------------------------------------------------------------
1 | r"""Isoradial lines.
2 |
3 | This module provides the :py:class:`Isoradial` class, which is used to calculate and visualize isoradial lines.
4 | An isoradial line is a line of constant radius in the black hole frame :math:`(r, \alpha)`.
5 | This can be used to calculate how such line appears in the observer's plane :math:`(b, \alpha)`, essentailly
6 | visualizing the spacetime curvature.
7 | """
8 |
9 | from matplotlib.axes import Axes
10 | import numpy as np
11 |
12 | from luminet import black_hole_math as bhmath
13 | from luminet.solver import improve_solutions, interpolator
14 | from luminet.viz import colorline
15 | from luminet.spatial import polar_to_cartesian
16 |
17 |
18 | class Isoradial:
19 | """Calculate and visualize isoradial lines.
20 |
21 | Isoradials are lines of equal distance to the black hole. They appear however distorted on the observer plane.
22 |
23 | The purpose of this class is not only to provide convenient access to such properties, but also
24 | to serve as an interface to most of the :class:`~luminet.black_hole.BlackHole`'s computation.
25 | Many, if not all of the properties of the :class:`~luminet.black_hole.BlackHole` class are solved
26 | on a line, and that line is almost always the isoradial line. Finding coordinates of a certain flux or redshift
27 | is done by finding it on the isoradial line. For this reason, this class implements a handful of solvers for these
28 | properties, and needs to inherit some of the parent black hole's properties, such as mass and inclination.
29 | """
30 | def __init__(
31 | self,
32 | radius: float,
33 | incl: float,
34 | bh_mass: float,
35 | order: int = 0,
36 | angular_resolution: int | None = None,
37 | ):
38 | r"""
39 | Args:
40 | radius (float): Radius of the isoradial in the black hole frame :math:`(r, \alpha)`
41 | incl (float): Inclination angle of the observer with respect to the black hole.
42 | :math:`0` degrees corresponds to the observer looking top-down on the black hole.
43 | :math:`\pi/2` corresponds to the observer looking at the black hole edge-on.
44 | bh_mass (float): Mass of the black hole
45 | order (int): Order of the isoradial.
46 | :math:`0` corresponds to direct images,
47 | :math:`1` to the first-order image i.e. "ghost" image.
48 | Default is :math:`0`.
49 | acc (float): Accretion rate of the black hole. Default is None.
50 | angular_resolution (int): Amount of :math:`\alpha` subdivisions for this isoradial.
51 | """
52 | assert (
53 | radius >= 6.0 * bh_mass
54 | ), """
55 | Radius should be at least 6 times the mass of the black hole.
56 | No orbits are stable below this radius for Swarzschild black holes.
57 | """
58 | self.bh_mass = bh_mass
59 | """float: mass of the black hole containing this isoradial"""
60 | self.incl = incl
61 | """float: inclination of observer's plane"""
62 | self.radius = radius
63 | """float: Radius to the black hole in the black hole reference frame."""
64 | self.order = order
65 | """int: order of the image this isoradial is associated with"""
66 | self.angular_resolution = angular_resolution if angular_resolution is not None else 100
67 | r"""Amount of subdivisions in :math:`\alpha`"""
68 |
69 | self.impact_parameters = []
70 | """np.ndarray: Radial coordinate of the isoradial in the observer plane :math:`b`."""
71 | self.angles = []
72 | r"""np.ndarray: Angular coordinate of the isoradial (in both black hole and observer frame) :math:`\alpha`."""
73 | self.redshift_factors = None
74 | """np.ndarray: Redshift factors of the isoradial :math:`(1 + z)`."""
75 |
76 | self.calculate()
77 |
78 | def calculate_coordinates(self):
79 | """Calculates the angles :math:`alpha` and radii :math:`b` of the isoradial.
80 |
81 | Saves these values in the :py:attr:`angles` and :py:attr:`impact_parameters` attributes.
82 |
83 | Returns:
84 | Tuple[np.ndarray]:
85 | Tuple containing the angles and radiifor the image on the observer plane.
86 | """
87 |
88 | angles = []
89 | impact_parameters = []
90 | t = np.linspace(0, 2 * np.pi, self.angular_resolution)
91 | for alpha in t:
92 | b = bhmath.solve_for_impact_parameter(
93 | radius=self.radius,
94 | incl=self.incl,
95 | alpha=alpha,
96 | bh_mass=self.bh_mass,
97 | order=self.order,
98 | )
99 | assert (
100 | b > 0
101 | ), "Impact parameter should be positive, but it wasnt for: R={}, alpha={}, incl={}".format(
102 | self.radius, alpha, self.incl
103 | )
104 | if b is np.nan:
105 | impact_parameters.append(bhmath.ellipse(self.radius, alpha, self.incl))
106 | else:
107 | impact_parameters.append(b)
108 | angles.append(alpha)
109 |
110 | # flip image if necessary
111 | self.angles = np.array(angles)
112 | self.impact_parameters = np.array(impact_parameters)
113 | return angles, impact_parameters
114 |
115 | def calc_redshift_factors(self) -> np.ndarray:
116 | """Calculates the redshift factor :math:`(1 + z)` over the isoradial
117 |
118 | Saves these values in the :py:attr:`redshift_factors` attribute.
119 |
120 | Returns:
121 | :class:`~numpy.ndarray`: Redshift factors of the isoradial
122 | """
123 | redshift_factors = bhmath.calc_redshift_factor(
124 | radius=self.radius,
125 | angle=self.angles,
126 | incl=self.incl,
127 | bh_mass=self.bh_mass,
128 | b=self.impact_parameters,
129 | )
130 | self.redshift_factors = np.array(redshift_factors)
131 | return redshift_factors
132 |
133 | def calculate(self):
134 | """Calculates the coordinates and redshift factors on the isoradial line.
135 |
136 | See also:
137 | :meth:`calculate_coordinates` and
138 | :meth:`calc_redshift_factors`
139 |
140 | Returns:
141 | :class:`~luminet.isoradial.Isoradial`:
142 | The :class:`~luminet.isoradial.Isoradial` object itself, but with calculated coordinates and redshift factors.
143 | """
144 | self.calculate_coordinates()
145 | self.calc_redshift_factors()
146 | return self
147 |
148 | def get_b_from_angle(self, angle: float | np.ndarray) -> float | np.ndarray:
149 | r"""Get the impact parameter :math:`b` for a given angle :math:`\alpha` on the isoradial.
150 |
151 | This method does not calculate the impact parameter, but rather finds the closest
152 | impact parameter to the given angle.
153 |
154 | Args:
155 | angle (float | np.ndarray): Angle :math:`\alpha` in radians.
156 |
157 | See also:
158 | :py:meth:`calc_b_from_angle` to explicitly solve for the impact parameter.
159 |
160 | Returns:
161 | float | :class:`~numpy.ndarray`: The impact parameter :math:`b` for the given angle :math:`\alpha`.
162 | """
163 | angles_array = np.array(self.angles)
164 | impact_parameters_array = np.array(self.impact_parameters)
165 |
166 | def find_closest_radii(ang):
167 | indices = np.argmin(np.abs(angles_array - ang), axis=-1)
168 | return impact_parameters_array[indices]
169 |
170 | if isinstance(angle, (float, int)):
171 | return find_closest_radii(angle)
172 | else:
173 | angle = np.asarray(angle)
174 | return np.vectorize(find_closest_radii, otypes=[float])(angle)
175 |
176 | def solve_for_b_from_angle(self, angle: float) -> float:
177 | r"""Calculate the impact parameter :math:`b` for a given angle :math:`\alpha` on the isoradial.
178 |
179 | This method solves for the impact parameter :math:`b` for a given angle :math:`\alpha` on the isoradial.
180 |
181 | Args:
182 | angle (float): Angle :math:`\alpha` in radians.
183 |
184 | Returns:
185 | float: The impact parameter :math:`b` for the given angle :math:`\alpha`.
186 | """
187 | b = bhmath.solve_for_impact_parameter(
188 | radius=self.radius,
189 | incl=self.incl,
190 | alpha=angle,
191 | bh_mass=self.bh_mass,
192 | order=self.order,
193 | )
194 | return b
195 |
196 | def plot(self, ax: Axes, z=None, cmap=None, norm=None, **kwargs) -> Axes:
197 | """Plot the isoradial.
198 |
199 | Args:
200 | ax (:py:class:`~matplotlib.axes.Axes`): The axis on which the isoradial should be plotted
201 | z (array-like, optional): The color values to be used. if no color values are passed, the isoradial is plotted as-is.
202 | cmap (str, optional): The colormap to be used. Only used if ``z`` is not None. Must be a valid matplotlib colormap string. Default is "Greys_r"
203 | norm (tuple, optional): The normalization to be used. Default is min-max of z, if passed.
204 | **kwargs: Additional arguments to be passed to the colorline function
205 |
206 | Returns:
207 | :py:class:`~matplotlib.axes.Axes`: The axis with the isoradial plotted.
208 | """
209 |
210 | if z is None:
211 | ax = ax.plot(self.angles, self.impact_parameters, **kwargs)
212 | else:
213 | norm = norm or (min(z), max(z))
214 | cmap = cmap or "Greys_r"
215 | ax = colorline(
216 | ax, self.angles, self.impact_parameters, z=z, cmap=cmap, norm=norm, **kwargs
217 | )
218 |
219 | return ax
220 |
221 | def _has_redshift(self, z):
222 | """Calculate if the class theoretically contains redshift value :math:`z`
223 |
224 | :meta private:
225 | """
226 | # TODO: math
227 |
228 | def interpolate_redshift_locations(self, redshift):
229 | """Calculates which location on the isoradial has some redshift value (not redshift factor)
230 |
231 | In general, either two or zero solutions exist.
232 | A notable exception is the tip of an isoredshift, which may only touch the isoradial and not intersect, yielding only one solution.
233 |
234 | Args:
235 | redshift (float): Redshift value
236 |
237 | Returns:
238 | Tuple[:class:`~numpy.ndarray`]: 2-tuple containing the angles and radii for the redshift value.
239 | """
240 | # Find all zero crossings
241 | angles = np.linspace(0, 2 * np.pi, 100)
242 | impact_parameters = [self.solve_for_b_from_angle(angle) for angle in angles]
243 | b_interp = interpolator(angles, impact_parameters)
244 |
245 | # function to solve for
246 | func = (
247 | lambda angle: redshift
248 | + 1
249 | - bhmath.calc_redshift_factor(
250 | self.radius,
251 | angle,
252 | self.incl,
253 | self.bh_mass,
254 | b_interp(angle),
255 | )
256 | )
257 |
258 | values = [func(angle) for angle in angles]
259 | sign_changes = np.where(np.diff(np.sign(values)))[0]
260 |
261 | solutions = [None, None] # Initialize with None to ensure two solutions
262 | for idx in sign_changes:
263 | # Find the root of the function in the interval
264 | angle0, angle1 = angles[idx], angles[idx + 1]
265 | z0, z1 = values[idx], values[idx + 1]
266 | try:
267 | angle = improve_solutions(func, (angle0, angle1), (z0, z1), kwargs={})
268 | # split solutions based on their angle: useful for plotting later on
269 | y_value = polar_to_cartesian(angle, b_interp(angle))[1]
270 | if z0 < 0:
271 | solutions[0] = angle
272 | elif z1 < 0:
273 | solutions[1] = angle
274 | except Exception as e:
275 | # If brentq fails to find a root in the interval, skip it
276 | pass
277 |
278 | # Calculate corresponding b values for the found angles
279 | b_values = [
280 | self.solve_for_b_from_angle(angle) if angle is not None else None
281 | for angle in solutions
282 | ]
283 |
284 | assert (
285 | len(b_values) == len(solutions) == 2
286 | ), "Should have found 2 solutions, or at least padded the second solution with None"
287 | return np.array(solutions), np.array(b_values)
288 |
--------------------------------------------------------------------------------
/luminet/black_hole.py:
--------------------------------------------------------------------------------
1 | """Black hole class for calculating and visualizing a Swarzschild black hole."""
2 |
3 | from functools import partial
4 | from multiprocessing import Pool
5 | from typing import List, Tuple
6 |
7 | import matplotlib.pyplot as plt
8 | from matplotlib.axes import Axes
9 | from matplotlib.figure import Figure
10 | import numpy as np
11 | import pandas as pd
12 |
13 | from luminet import black_hole_math as bhmath
14 | from luminet.isoradial import Isoradial
15 | from luminet.isoredshift import Isoredshift
16 |
17 |
18 | class BlackHole:
19 | """Black hole class for calculating and visualizing a Swarzschild black hole.
20 | """
21 |
22 | def __init__(
23 | self,
24 | mass=1.0,
25 | incl=1.4,
26 | acc=1.0,
27 | outer_edge=None,
28 | angular_resolution=200,
29 | radial_resolution=200
30 | ):
31 | """
32 | Args:
33 | mass (float): Mass of the black hole in natural units :math:`G = c = 1`
34 | incl (float): Inclination of the observer's plane in radians
35 | acc (float): Accretion rate in natural units
36 | """
37 | self.incl = incl
38 | """float: Inclination angle of the observer"""
39 | self.mass = mass
40 | """float: Mass of the black hole"""
41 | self.acc = acc # accretion rate, in natural units
42 | """float: Accretion rate of the black hole"""
43 | self.max_flux = self._calc_max_flux()
44 | """float: Maximum flux of the black hole, as emitted by the isoriadial R ~ 9.55. See :cite:t:`Luminet_1979`"""
45 | self.critical_b = 3 * np.sqrt(3) * self.mass
46 | r"""float: critical impact parameter for the photon sphere :math:`3 \sqrt{3} M`"""
47 | self.angular_resolution = angular_resolution
48 | """int: Angular resolution to use when calculating or plotting the black hole or related properties. Default is 200."""
49 | self.radial_resolution = radial_resolution
50 | """int: Radial resolution to use when calculating or plotting the black hole or related properties. Default is 200."""
51 |
52 |
53 | self.isoradial_template = partial(
54 | Isoradial,
55 | incl=self.incl,
56 | bh_mass=self.mass,
57 | angular_resolution=self.angular_resolution,
58 | )
59 | """callable: partial function to create an isoradial with some radius and order."""
60 |
61 | self.disk_outer_edge = (
62 | outer_edge if outer_edge is not None else 30.0 * self.mass
63 | )
64 | """float: outer edge of the accretion disk. Default is :math:`30 M`."""
65 | self.disk_inner_edge = 6.0 * self.mass
66 | """float: inner edge of the accretion disk i.e. :math:`6 M`."""
67 | self.disk_apparent_outer_edge = self._calc_outer_isoradial()
68 | """Isoradial: isoradial that defines the outer edge of the accretion disk."""
69 | self.disk_apparent_inner_edge = self._calc_inner_isoradial()
70 | """Isoradial: isoradial that defines the inner edge of the accretion disk."""
71 | self.disk_apparent_inner_edge_ghost = self._calc_inner_isoradial(order=1)
72 | """Isoradial: isoradial that defines the inner edge of the ghost image."""
73 | self.disk_apparent_outer_edge_ghost = self._calc_outer_isoradial(order=1)
74 | """Isoradial: isoradial that defines the outer edge of the ghost image."""
75 |
76 | self.isoradials = []
77 | """List[Isoradial]: list of calculated isoradials"""
78 | self.isoredshifts = []
79 | """List[Isoredshift]: list of calculated isoredshifts"""
80 |
81 | def _calc_max_flux(self):
82 | r"""Get the maximum intrinsic flux emitted by the black hole
83 |
84 | Max flux happens at radius ~ 9.55, which yields a max flux of:
85 | :math:`\frac{3M\dot{M}}{8\pi}*1.146*10^{-4}`.
86 |
87 | See also:
88 | Please refer to Eq15 in :cite:t:`Luminet_1979` for more info on this magic number.
89 | """
90 | return 3 * self.mass * self.acc * 1.146e-4 / (8 * np.pi)
91 |
92 | def _calc_inner_isoradial(self, order=0):
93 | """Calculate the isoradial that defines the inner edge of the accretion disk"""
94 | ir = self.isoradial_template(radius=self.disk_inner_edge, order=order)
95 | ir.calculate()
96 | return ir
97 |
98 | def _calc_outer_isoradial(self, order=0):
99 | """Calculate the isoradial that defines the outer edge of the accretion disk"""
100 | ir = self.isoradial_template(radius=self.disk_outer_edge, order=order)
101 | ir.calculate()
102 | return ir
103 |
104 | def _calc_apparent_outer_edge(self, angle):
105 | return self.disk_apparent_outer_edge.get_b_from_angle(angle)
106 |
107 | def _calc_apparent_inner_edge(self, angle):
108 | """Get the apparent inner edge of the accretion disk at some angle"""
109 | return self.disk_apparent_inner_edge.get_b_from_angle(angle)
110 |
111 | def _get_fig_ax(self, polar=True) -> Tuple[Figure, Axes]:
112 | """Fetch a figure set up for plotting black holes and associated attributes.
113 |
114 | This figure has the following properties:
115 |
116 | - Polar coordinates
117 | - Black background
118 | - No axes
119 | - Scaled between :math:`R = [0, max]`, where max is the largest :math:`b` of the largest isoradial
120 |
121 | This function should be called after all necessary isoradials to plot something have already been calculated (for proper scaling).
122 | """
123 | if polar:
124 | fig, ax = plt.subplots(subplot_kw={"projection": "polar"})
125 | ax.set_theta_zero_location("S") # theta=0 at the bottom
126 | else:
127 | fig, ax = plt.subplots()
128 | fig.patch.set_facecolor("black")
129 | ax.set_facecolor("black")
130 | ax.grid()
131 | plt.axis("off") # command for hiding the axis.
132 | # Remove padding between the figure and the axes
133 | plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
134 | sorted_ir_direct = sorted(
135 | [ir for ir in self.isoradials if ir.order == 0],
136 | key=lambda x: x.radius)
137 | sorted_ir_ghost = sorted(
138 | [ir for ir in self.isoradials if ir.order == 1],
139 | key=lambda x: x.radius)
140 | if sorted_ir_direct: biggest_ir = sorted_ir_direct[-1]
141 | elif sorted_ir_ghost: biggest_ir = sorted_ir_ghost[-1]
142 | else: raise ValueError("Can't fetch the figure, as no isoradials have been calculated yet.")
143 | ax.set_ylim((0, 1.1*max(biggest_ir.impact_parameters)))
144 | return fig, ax
145 |
146 | def _is_ir_calculated(self, radius, order):
147 | return radius in [ir.radius for ir in self.isoradials if ir.order == order]
148 |
149 | def _calc_isoredshift(self, redshift, order=0):
150 | """Calculate a single isoredshift
151 |
152 | Args:
153 | redshift (int|float): The redshift value for which to calculate the `Isoredshift` for.
154 | order (int, optional): The order of the image associated with this `Isoredshift`.
155 | """
156 | direct_irs = [ir for ir in self.isoradials if ir.order == order]
157 | with Pool() as p:
158 | solutions = p.starmap(
159 | _call_calc_redshift_locations,
160 | [(ir, redshift) for ir in direct_irs]
161 | )
162 | angle_pairs, b_pairs = zip(*solutions)
163 |
164 | iz = Isoredshift(
165 | redshift=redshift,
166 | angles=angle_pairs,
167 | impact_parameters=b_pairs,
168 | ir_radii=[ir.radius for ir in direct_irs]
169 | )
170 |
171 | self.isoredshifts.append(iz)
172 |
173 | def calc_isoredshifts(self, redshifts=None, order=0):
174 | """Calculate isoredshifts for a list of redshift values
175 |
176 | This method creates an array of `Isoradials` whose coordinates will be lazily computed.
177 | These no-coordinate isoradials are used by the `Isoredshift` to calculate the locations
178 | of redshift values along these isoradials.
179 |
180 | Args:
181 | redshifts (List[float]): list of redshift values
182 |
183 | Returns:
184 | List[:class:`~luminet.isoredshift.Isoredshift`]: list of calculated isoredshifts
185 | """
186 | # Don't recalculate isoredshifts that have already been calculated
187 | redshifts = [z for z in redshifts if z not in [irz.redshift for irz in self.isoredshifts]]
188 |
189 | radii = np.linspace(self.disk_inner_edge, self.disk_outer_edge, self.radial_resolution)
190 | if order == 0: self.calc_isoradials(direct_r=radii, ghost_r=[])
191 | elif order == 1: self.calc_isoradials(direct_r=[], ghost_r=radii)
192 | else: raise ValueError("Orders other than 0 (direct) or 1 (ghost) are not supported yet.")
193 |
194 | for z in redshifts: self._calc_isoredshift(z, order=order)
195 |
196 | def calc_isoradials(
197 | self, direct_r: List[int | float], ghost_r: List[int | float]
198 | ) -> List[Isoradial]:
199 | """Calculate isoradials for a list of radii for the direct image and/or ghost image.
200 |
201 | These calculations are parallellized using the :py:class:`multiprocessing.Pool` class.
202 |
203 | Args:
204 | direct_r (List[int | float]): list of radii for the direct image
205 | ghost_r (List[int | float]): list of radii for the ghost image
206 |
207 | Returns:
208 | List[:class:`~luminet.isoradial.Isoradial`]: list of calculated isoradials
209 | """
210 | # Filter out isoradials that have already been calculated:
211 | direct_r = [r for r in direct_r if not self._is_ir_calculated(r, order=0)]
212 | ghost_r = [r for r in ghost_r if not self._is_ir_calculated(r, order=1)]
213 |
214 | # calc ghost images
215 | with Pool() as pool:
216 | isoradials = pool.starmap(
217 | Isoradial,
218 | [
219 | (
220 | r,
221 | self.incl,
222 | self.mass,
223 | 1,
224 | self.angular_resolution,
225 | )
226 | for r in ghost_r
227 | ],
228 | )
229 | self.isoradials.extend(isoradials)
230 |
231 | with Pool() as pool:
232 | isoradials = pool.starmap(
233 | Isoradial,
234 | [
235 | (
236 | r,
237 | self.incl,
238 | self.mass,
239 | 0,
240 | self.angular_resolution,
241 | )
242 | for r in direct_r
243 | ],
244 | )
245 | self.isoradials.extend(isoradials)
246 | self.isoradials.sort(key=lambda x: (1 - x.order, x.radius))
247 |
248 | def plot_isoradials(
249 | self,
250 | direct_r: List[int | float],
251 | ghost_r: List[int | float] | None = None,
252 | color_by="flux",
253 | ax: Axes | None = None,
254 | **kwargs,
255 | ) -> Axes:
256 | """Plot multiple isoradials.
257 |
258 | This method can be used to plot one or more isoradials.
259 | If the radii are close to each other, the isoradials will be plotted on top of each other,
260 | essentially visualizing the entire black hole.
261 |
262 | Args:
263 | direct_r (List[int | float]): list of radii for the direct image
264 | ghost_r (List[int | float]): list of radii for the ghost image
265 | color (str): color scheme for the isoradials. Default is 'flux'.
266 | kwargs (optional): additional keyword arguments for the :meth:`luminet.isoradial.Isoradial.plot` method.
267 | ax (:class:`~matplotlib.axes.Axes`, optional): Axes object to plot on.
268 | Useful for when you want to plot multiple things one a single canvas.
269 |
270 |
271 | Example::
272 |
273 | from luminet.black_hole import BlackHole
274 |
275 | direct_irs = [6, 10, 15, 20]
276 | ghost_irs = [6, 20, 50, 100] # ghost_r can go to infinity
277 | ax = bh.plot_isoradials(direct_irs, ghost_irs, lw=1, colors='white')
278 |
279 | .. image:: /../_static/_images/isoradials.png
280 | :align: center
281 |
282 | Returns:
283 | :py:class:`~matplotlib.axes.Axes`: The plotted isoradials.
284 | """
285 |
286 | ghost_r = ghost_r if ghost_r is not None else []
287 | self.calc_isoradials(direct_r, ghost_r)
288 | if ax is None: _, ax = self._get_fig_ax()
289 |
290 | if color_by == "redshift":
291 | if not "cmap" in kwargs:
292 | kwargs["cmap"] = "RdBu_r"
293 | mx = np.max([np.max(z) for z in zs])
294 | norm = (-mx, mx)
295 | elif color_by == "flux":
296 | if not "cmap" in kwargs:
297 | kwargs["cmap"] = "Greys_r"
298 | zs = [
299 | bhmath.calc_flux_observed(
300 | ir.radius, self.acc, self.mass, ir.redshift_factors
301 | )
302 | for ir in self.isoradials
303 | ]
304 | mx = np.max([np.max(z) for z in zs])
305 | norm = (0, mx)
306 |
307 | for z, ir in zip(zs, self.isoradials):
308 | if ir.radius in direct_r and ir.order == 0:
309 | ax = ir.plot(ax, z=z, norm=norm, zorder= ir.radius, **kwargs)
310 | elif ir.radius in ghost_r and ir.order == 1:
311 | ax = ir.plot(ax, z=z, norm=norm, zorder= -ir.radius, **kwargs)
312 |
313 | return ax
314 |
315 | def plot(self, **kwargs) -> Axes:
316 | """Plot the black hole
317 |
318 | This is a wrapper method to plot the black hole.
319 | It simply calls the :meth:`~luminet.black_hole.BlackHole.plot_isoradials` method with a dense range of isoradials,
320 | as specified in :attr:`radial_resolution`
321 |
322 | Example::
323 |
324 | from luminet.black_hole import BlackHole
325 |
326 | bh = BlackHole()
327 | bh.plot()
328 |
329 | .. image:: /../_static/_images/bh.png
330 | :align: center
331 |
332 | Returns:
333 | :class:`~matplotlib.axes.Axes`: The axis with the isoradials plotted.
334 | """
335 |
336 | radii = np.linspace(self.disk_inner_edge, self.disk_outer_edge, self.radial_resolution)
337 | ax = self.plot_isoradials(direct_r=radii, ghost_r=radii, color_by="flux", **kwargs)
338 | return ax
339 |
340 | def plot_isoredshifts(self, redshifts=None, order=0, ax=None, **kwargs) -> Axes:
341 | """Plot isoredshifts for a list of redshift values
342 |
343 | Args:
344 | redshifts (List[float]): list of redshift values
345 | kwargs (optional): additional keyword arguments for the :meth:`luminet.isoredshift.Isoredshift.plot` method.
346 | order (int): The order of the image to plot siofluxlines for. Default is :math:`0`.
347 | ax (:class:`~matplotlib.axes.Axes`, optional): Axes object to plot on.
348 | Useful for when you want to plot multiple things one a single canvas.
349 |
350 | Example::
351 |
352 | from luminet.black_hole import BlackHole
353 |
354 | bh = BlackHole()
355 | redshifts = [-.2, -.1, 0., .1, .2, .3, .4]
356 | ax = bh.plot_isoredshifts(redshifts, c='white')
357 | ax = bh.disk_apparent_inner_edge.plot(ax=ax, c='white')
358 |
359 | .. image:: /../_static/_images/isoredshifts.png
360 | :align: center
361 |
362 | Returns:
363 | :py:class:`~matplotlib.axes.Axes`: The plotted isoredshifts.
364 | """
365 | self.calc_isoredshifts(redshifts=redshifts, order=order)
366 | if ax is None: fig, ax = self._get_fig_ax()
367 | for isoredshift in self.isoredshifts:
368 | ax = isoredshift.plot(ax, **kwargs)
369 | return ax
370 |
371 | def plot_isofluxlines(self, mask_inner=True, mask_outer=True, normalize=True, order=0, ax=None, **kwargs) -> Axes:
372 | """Plot lines of equal flux.
373 |
374 | Args:
375 | normalize (bool): Whether to normalize the fluxlines by the maximum flux or not. Defaults to True.
376 | mask_inner (bool):
377 | Whether to place a mask over the apparent inner edge, where the direct image produces no flux.
378 | Useful to mitigate matplotlib tricontour artifacts. Default is ``True``
379 | mask_outer (bool):
380 | Whether to place a mask over the apparent outer edge, where we are not capturing photons from.
381 | Useful to mitigate matplotlib tricontour artifacts. Default is ``True``.
382 | order (int): The order of the image to plot siofluxlines for. Default is :math:`0`.
383 | ax (:class:`~matplotlib.axes.Axes`, optional): Axes object to plot on.
384 | Useful for when you want to plot multiple things one a single canvas.
385 | kwargs (optional): Other keyword arguments to pass to :py:func:`~matplotlib.pyplot.tricontour`.
386 |
387 | Hint:
388 | Normalizing the isofluxlines makes it easier to define specific levels.
389 |
390 | Hint:
391 | Levels in logspace tend to produce nicer results than linearly increasing levels.
392 |
393 | Example::
394 |
395 | from luminet.black_hole import BlackHole
396 |
397 | bh = BlackHole(incl=1.4, radial_resolution=200)
398 | levels = [.05, .1, .15, .2, .25, .3, .6, .9, 1.2, 1.5, 1.8, 2.1]
399 | ax = bh.plot_isofluxlines(colors='white', levels=levels, linewidths=1)
400 |
401 | .. image:: /../_static/_images/isofluxlines.png
402 | :align: center
403 |
404 | Returns:
405 | :class:`matplotlib.axes.Axes`: The plotted isofluxlines.
406 | """
407 | radii = np.linspace(self.disk_inner_edge, self.disk_outer_edge, self.radial_resolution)
408 | if order == 0: self.calc_isoradials(direct_r=radii, ghost_r=[])
409 | elif order == 1: self.calc_isoradials(direct_r=[], ghost_r=radii)
410 | else: raise ValueError("Orders other than 0 (direct) or 1 (ghost) are not supported yet.")
411 |
412 | irs = [ir for ir in self.isoradials if ir.order == order]
413 | a = np.array([float(angle) for ir in irs for angle in ir.angles])
414 | b = np.array([float(r) for ir in irs for r in ir.impact_parameters])
415 | zs = np.array([
416 | flux
417 | for ir in irs
418 | for flux in bhmath.calc_flux_observed(
419 | r=ir.radius,
420 | acc=self.acc,
421 | bh_mass=self.mass,
422 | redshift_factor=ir.redshift_factors
423 | )
424 | ])
425 | if normalize: zs /= self.max_flux
426 |
427 | if ax is None: fig, ax = self._get_fig_ax()
428 | contour = plt.tricontour(
429 | a, b, zs,
430 | **kwargs
431 | )
432 | if mask_inner:
433 | ax.fill_between(
434 | self.disk_apparent_inner_edge.angles,
435 | 0,
436 | self.disk_apparent_inner_edge.impact_parameters,
437 | color='k',
438 | zorder=len(contour.levels) + 1
439 | )
440 | if mask_outer:
441 | max_r = ax.get_ylim()[-1]
442 | ax.fill_between(
443 | self.disk_apparent_outer_edge.angles,
444 | self.disk_apparent_outer_edge.impact_parameters,
445 | max_r,
446 | color='k',
447 | zorder=len(contour.levels) + 1
448 | )
449 | return ax
450 |
451 | def sample_photons(self, n_points=1000) -> Tuple[pd.DataFrame]:
452 | r"""Sample points on the accretion disk.
453 |
454 | Photons are appended as class-level attributes.
455 | Each photon is a :class:`pandas.series.Series` with the following properties:
456 |
457 | - ``radius``: radius of the photon on the accretion disk :math:`r`
458 | - ``alpha``: angle of the photon on the accretion disk :math:`\alpha`
459 | - ``impact_parameter``: impact parameter of the photon :math:`b`
460 | - ``z_factor``: redshift factor of the photon :math:`1+z`
461 | - ``flux_o``: observed flux of the photon :math:`F_o`
462 |
463 | Args:
464 | n_points (int): Amount of photons to sample.
465 |
466 | Attention:
467 | Sampling is not done uniformly, but biased towards the
468 | center of the accretion disk, as this is where most of the luminosity comes from.
469 |
470 | Returns:
471 | Tuple[:class:`~pandas.dataframe.DataFrame`]:
472 | Dataframes containing photons for both direct and ghost image.
473 | """
474 | n_points = int(n_points)
475 | min_radius_ = self.disk_inner_edge
476 | max_radius_ = self.disk_outer_edge
477 | with Pool() as p:
478 | photons = p.starmap(
479 | sample_photon,
480 | [
481 | (min_radius_, max_radius_, self.incl, self.mass, 0)
482 | for _ in range(n_points)
483 | ],
484 | )
485 | with Pool() as p:
486 | ghost_photons = p.starmap(
487 | sample_photon,
488 | [
489 | (min_radius_, max_radius_, self.incl, self.mass, 1)
490 | for _ in range(n_points)
491 | ],
492 | )
493 |
494 | df = pd.DataFrame(photons)
495 | df["z_factor"] = bhmath.calc_redshift_factor(
496 | df["radius"],
497 | df["alpha"],
498 | self.incl,
499 | self.mass,
500 | df["impact_parameter"],
501 | )
502 | df["flux_o"] = bhmath.calc_flux_observed(
503 | df["radius"], self.acc, self.mass, df["z_factor"]
504 | )
505 |
506 | df_ghost = pd.DataFrame(ghost_photons)
507 | df_ghost["z_factor"] = bhmath.calc_redshift_factor(
508 | df_ghost["radius"],
509 | df_ghost["alpha"],
510 | self.incl,
511 | self.mass,
512 | df_ghost["impact_parameter"],
513 | )
514 | df_ghost["flux_o"] = bhmath.calc_flux_observed(
515 | df_ghost["radius"], self.acc, self.mass, df_ghost["z_factor"]
516 | )
517 |
518 | self.photons = df
519 | self.ghost_photons = df_ghost
520 |
521 | return self.photons, self.ghost_photons
522 |
523 |
524 | def sample_photon(min_r, max_r, incl, bh_mass, n):
525 | r"""Sample a random photon from the accretion disk
526 |
527 | Each photon is a dictionary with the following properties:
528 |
529 | - ``radius``: radius of the photon on the accretion disk :math:`r`
530 | - ``alpha``: angle of the photon on the accretion disk :math:`\alpha`
531 | - ``impact_parameter``: impact parameter of the photon :math:`b`
532 | - ``z_factor``: redshift factor of the photon :math:`1+z`
533 |
534 | This function is used in :meth:`~luminet.black_hole.BlackHole.sample_photons` to sample
535 | photons on the accretion disk of a black hole in a parallellized manner.
536 |
537 | Attention:
538 | Photons are not sampled uniformly on the accretion disk, but biased towards the center.
539 | Black holes have more flux delta towards the center, and thus we need more precision there.
540 | This makes the triangulation with hollow mask in the center also very happy.
541 |
542 | Args:
543 | min_r: minimum radius of the accretion disk
544 | max_r: maximum radius of the accretion disk
545 | incl: inclination of the observer wrt the disk
546 | bh_mass: mass of the black hole
547 | n: order of the isoradial
548 |
549 | Returns:
550 | Dict: Dictionary containing all basic properties of a single photon from the accretion disk.
551 | """
552 | alpha = np.random.random() * 2 * np.pi
553 |
554 | # Bias sampling towards circle center (even sampling would be sqrt(random))
555 | r = min_r + (max_r - min_r) * np.random.random()
556 | b = bhmath.solve_for_impact_parameter(r, incl, alpha, bh_mass, n)
557 | assert (
558 | b is not np.nan
559 | ), f"b is nan for r={r}, alpha={alpha}, incl={incl}, M={bh_mass}, n={n}"
560 | # f_o = flux_observed(r, acc_r, bh_mass, redshift_factor_)
561 | return {
562 | "radius": r,
563 | "alpha": alpha,
564 | "impact_parameter": b,
565 | }
566 |
567 | def _call_calc_redshift_locations(ir, redshift):
568 | """Helper function for multiprocessing"""
569 | return ir.interpolate_redshift_locations(redshift)
570 |
--------------------------------------------------------------------------------
/luminet/black_hole_math.py:
--------------------------------------------------------------------------------
1 | """Math routines for :cite:t:`Luminet_1979`.
2 |
3 | This module contains the mathematical routines to calculate the trajectory of photons around
4 | a Swarzschild black hole, as described in :cite:t:`Luminet_1979`.
5 | """
6 | import numpy as np
7 | from scipy.special import ellipj, ellipk, ellipkinc
8 |
9 | from luminet.solver import improve_solutions
10 |
11 | def calc_q(p: float, bh_mass: float) -> float:
12 | r"""Convert perigee :math:`P` to :math:`Q`
13 |
14 | The variable :math:`Q` has no explicit physical meaning, but makes
15 | many equations more readable.
16 |
17 | .. math::
18 |
19 | Q = \sqrt{(P - 2M)(P + 6M)}
20 |
21 | Args:
22 | periastron (float): Periastron distance
23 | bh_mass (float): Black hole mass
24 |
25 | Returns:
26 | float: :math:`Q`
27 | """
28 | if p < 2.0 * bh_mass:
29 | return np.nan
30 | return np.sqrt((p - 2.0 * bh_mass) * (p + 6.0 * bh_mass))
31 |
32 |
33 | def calc_b_from_perigee(p: float, bh_mass: float) -> float:
34 | r"""Get impact parameter :math:`b` from the photon perigee :math:`P`
35 |
36 |
37 | .. math::
38 |
39 | b = \sqrt{\frac{P^3}{P - 2M}}
40 |
41 | Args:
42 | p (float): Perigee distance
43 | bh_mass (float): Black hole mass
44 |
45 | Attention:
46 | :cite:t:`Luminet_1979` has a typo here.
47 | The fraction on the right hand side equals :math:`b^2`, not :math:`b`.
48 | You can verify this by filling in :math:`u_2` in Equation 3.
49 | Only this way do the limits :math:`P -> 3M` and :math:`P >> M` hold true,
50 | as well as the value for :math:`b_c`. The resulting images of the paper are correct though.
51 |
52 |
53 | Returns:
54 | float: Impact parameter :math:`b`
55 | """
56 | if p <= 2.0 * bh_mass:
57 | return np.nan
58 | return np.sqrt(p**3 / (p - 2.0 * bh_mass))
59 |
60 |
61 | def calc_k(periastron: float, bh_mass: float) -> float:
62 | r"""Calculate the modulus of the elliptic integral
63 |
64 | The modulus is defined as:
65 |
66 | .. math::
67 |
68 | k = \sqrt{\frac{Q - P + 6M}{2Q}}
69 |
70 | Args:
71 | periastron (float): Periastron distance
72 | bh_mass (float): Black hole mass
73 |
74 | Returns:
75 | float: Modulus of the elliptic integral
76 |
77 | Attention:
78 | Mind the typo in :cite:t:`Luminet_1979`. The numerator should be in brackets. The resulting images of the paper are correct though.
79 |
80 | """
81 | q = calc_q(periastron, bh_mass)
82 | if q is np.nan:
83 | return np.nan
84 | # WARNING: Paper has an error here. There should be brackets around the numerator.
85 | return np.sqrt((q - periastron + 6 * bh_mass) / (2 * q))
86 |
87 |
88 | def calc_k_squared(p: float, bh_mass: float):
89 | r"""Calculate the squared modulus of elliptic integral
90 |
91 | .. math::
92 |
93 | k^2 = m = \frac{Q - P + 6M}{2Q}
94 |
95 | Attention:
96 | :cite:t:`Luminet_1979` uses the non-squared modulus in the elliptic integrals.
97 | This is just a convention. However, ``scipy`` asks for the squared modulus :math:`m=k^2`, not the modulus.
98 |
99 | Args:
100 | p (float): Perigee distance
101 | bh_mass (float): Black hole mass
102 | """
103 | q = calc_q(p, bh_mass)
104 | if q is np.nan:
105 | return np.nan
106 | # WARNING: Paper has an error here. There should be brackets around the numerator.
107 | return (q - p + 6 * bh_mass) / (2 * q)
108 |
109 |
110 | def calc_zeta_inf(p: float, bh_mass: float) -> float:
111 | r"""Calculate :math:`\zeta_\infty`
112 |
113 | This is used in the Jacobi incomplete elliptic integral :math:`F(\zeta_\infty, k)`
114 |
115 | .. math::
116 |
117 | \zeta_\infty = \arcsin \left( \sqrt{\frac{Q - P + 2M}{Q - P + 6M}} \right)
118 |
119 | Args:
120 | p (float): Perigee distance
121 | bh_mass (float): Black hole mass
122 |
123 | Returns:
124 | float: :math:`\zeta_\infty`
125 | """
126 | q = calc_q(p, bh_mass)
127 | if q is np.nan:
128 | return np.nan
129 | arg = (q - p + 2 * bh_mass) / (q - p + 6 * bh_mass)
130 | z_inf = np.arcsin(np.sqrt(arg))
131 | return z_inf
132 |
133 |
134 | def calc_zeta_r(p: float, r: float, bh_mass: float) -> float:
135 | r"""Calculate :math:`\zeta_r`
136 |
137 | This is used for the Jacobi incomplete elliptic integral for higher-order images.
138 |
139 | .. math::
140 |
141 | \zeta_r = \arcsin \left( \sqrt{\frac{Q - P + 2M + \frac{4MP}{r}}{Q - P + 6M}} \right)
142 |
143 | Args:
144 | p (float): Perigee distance
145 | r (float): Radius in the black hole frame.
146 | bh_mass (float): Black hole mass
147 |
148 | Returns:
149 | float: :math:`\zeta_r`
150 | """
151 | q = calc_q(p, bh_mass)
152 | if q is np.nan:
153 | return np.nan
154 | a = (q - p + 2 * bh_mass + (4 * bh_mass * p) / r) / (
155 | q - p + (6 * bh_mass)
156 | )
157 | s = np.arcsin(np.sqrt(a))
158 | return s
159 |
160 |
161 | def calc_cos_gamma(alpha: float, incl: float) -> float:
162 | r"""Calculate :math:`\cos(\gamma)`
163 |
164 | This is used in the argument of the Jacobi elliptic integrals.
165 |
166 | .. math::
167 |
168 | \cos(\gamma) = \frac{\cos(\alpha)}{\sqrt{\cos(\alpha)^2 + \frac{1}{\tan(\theta_0)^2}}}
169 |
170 | Args:
171 | alpha (float): Angle in the black hole frame
172 | incl (float): Inclination of the observer :math:`\theta_0`
173 |
174 | Returns:
175 | float: :math:`\cos(\gamma)`
176 | """
177 | return np.cos(alpha) / np.sqrt(np.cos(alpha) ** 2 + 1 / (np.tan(incl) ** 2))
178 |
179 |
180 | def calc_sn(
181 | p: float,
182 | angle: float,
183 | bh_mass: float,
184 | incl: float,
185 | order: int = 0,
186 | ) -> float:
187 | r"""Calculate the elliptic function :math:`\text{sn}`
188 |
189 | For direct images, this is:
190 |
191 | .. math::
192 |
193 | \text{sn} \left( \frac{\gamma}{2 \sqrt{P/Q}} + F(\zeta_{\infty}, k) \right)
194 |
195 | For higher order images, this is:
196 |
197 | .. math::
198 |
199 | \text{sn} \left( \frac{\gamma - 2n\pi}{2 \sqrt{P/Q}} - F(\zeta_{\infty}, k) + 2K(k) \right)
200 |
201 | Here, :math:`F` is the incomplete elliptic integral of the first kind,
202 | and :math:`K` is the complete elliptic integral of the first kind.
203 | Elliptic integrals and elliptic functions are related:
204 |
205 | .. math::
206 |
207 | u &= F(\phi,m) \\
208 | \text{sn}(u|m) &= sin(\phi)
209 |
210 |
211 | Attention:
212 | Note that ``scipy`` uses the modulus :math:`m = k^2` in the elliptic integrals,
213 | not the modulus :math:`k`.
214 |
215 | Args:
216 | p (float): Perigee distance
217 | angle (float): Angle in the black hole frame :math:`\alpha`
218 | bh_mass (float): Black hole mass
219 | incl (float): Inclination of the observer :math:`\theta_0`
220 | order (int): Order of the image. Default is :math:`0` (direct image).
221 |
222 | Returns:
223 | float: Value of the elliptic integral :math:`\text{sn}`
224 | """
225 | q = calc_q(p, bh_mass)
226 | if q is np.nan:
227 | return np.nan
228 | z_inf = calc_zeta_inf(p, bh_mass)
229 | m = calc_k_squared(p, bh_mass) # mpmath takes m = k² as argument.
230 | ell_inf = ellipkinc(z_inf, m) # Elliptic integral F(zeta_inf, k)
231 | g = np.arccos(calc_cos_gamma(angle, incl))
232 |
233 | if order == 0: # higher order image
234 | ellips_arg = g / (2.0 * np.sqrt(p / q)) + ell_inf
235 | elif order > 0: # direct image
236 | ell_k = ellipk(m) # calculate complete elliptic integral of mod m = k²
237 | ellips_arg = (
238 | (g - 2.0 * order * np.pi) / (2.0 * np.sqrt(p / q))
239 | - ell_inf
240 | + 2.0 * ell_k
241 | )
242 | else:
243 | raise NotImplementedError(
244 | "Only 0 and positive integers are allowed for the image order."
245 | )
246 |
247 | sn, _, _, _ = ellipj(ellips_arg, m)
248 | return sn
249 |
250 |
251 | def calc_radius(
252 | p: float,
253 | ir_angle: float,
254 | bh_mass: float,
255 | incl: float,
256 | order: int = 0,
257 | ) -> float:
258 | """Calculate the radius on the black hole accretion disk from a photon's perigee value.
259 |
260 | Args:
261 | p (float): Periastron distance. This is directly related to the observer coordinate frame :math:`b`
262 | ir_angle (float): Angle of the observer/bh coordinate frame.
263 | bh_mass (float): Black hole mass
264 | incl (float): Inclination of the black hole
265 | order (int): Order of the image. Default is :math:`0` (direct image).
266 |
267 | Attention:
268 | This is not the equation used to solve for the perigee value :math:`P`.
269 | For the equation that is optimized in order to convert between black hole and observer frame,
270 | see :py:meth:`perigee_optimization_function`.
271 |
272 | Returns:
273 | float: Black hole frame radius :math:`r` of the photon trajectory.
274 | """
275 | sn = calc_sn(p, ir_angle, bh_mass, incl, order)
276 | q = calc_q(p, bh_mass)
277 |
278 | term1 = -(q - p + 2.0 * bh_mass)
279 | term2 = (q - p + 6.0 * bh_mass) * sn * sn
280 |
281 | return 4.0 * bh_mass * p / (term1 + term2)
282 |
283 |
284 | def perigee_optimization_function(
285 | p: float,
286 | ir_radius: float,
287 | ir_angle: float,
288 | bh_mass: float,
289 | incl: float,
290 | order: int = 0,
291 | ) -> float:
292 | r"""Cost function for the optimization of the periastron value.
293 |
294 | This function is optimized to find the periastron value that solves Equation 13 in cite:t:`Luminet1979`:
295 |
296 | .. math::
297 |
298 | 4 M P - r (Q - P + 2 M) + r (Q - P + 6 M) \text{sn}^2 \left( \frac{\gamma}{2 \sqrt{P/Q}} + F(\zeta_{\infty}, k) \right) = 0
299 |
300 | When the above equation is zero, the photon perigee value :math:`P` is correct.
301 |
302 | See also:
303 | :py:meth:`solve_for_perigee` to calculate the perigee of a photon orbit, given an accretion disk radius of origin :math:`R`.
304 |
305 | Args:
306 | periastron (float): Periastron distance
307 | ir_radius (float): Radius in the black hole frame
308 | ir_angle (float): Angle in the black hole frame
309 | bh_mass (float): Black hole mass
310 | incl (float): Inclination of the black hole
311 | order (int): Order of the image. Default is :math:`0` (direct image).
312 |
313 | Returns:
314 | float: Cost function value. Should be zero when the photon perigee value is correct.
315 | """
316 | q = calc_q(p, bh_mass)
317 | if q is np.nan:
318 | return np.nan
319 | sn = calc_sn(p, ir_angle, bh_mass, incl, order)
320 | term1 = -(q - p + 2.0 * bh_mass)
321 | term2 = (q - p + 6.0 * bh_mass) * sn * sn
322 | zero_opt = 4.0 * bh_mass * p - ir_radius * (term1 + term2)
323 | return zero_opt
324 |
325 |
326 | def solve_for_perigee(
327 | radius: float,
328 | incl: float,
329 | alpha: float,
330 | bh_mass: float,
331 | order: int = 0,
332 | ) -> float:
333 | r"""Calculate the perigee of a photon trajectory, when the black hole coordinates are known.
334 |
335 | This photon perigee can be converted to an impact parameter :math:`b`, yielding the observer frame coordinates :math:`(b, \alpha)`.
336 |
337 | See also:
338 | :py:meth:`perigee_optimization_function` for the optimization function used.
339 |
340 | See also:
341 | :py:meth:`solve_for_impact_parameter` to also convert periastron distance to impact parameter :math:`b` (observer frame).
342 |
343 | Args:
344 | radius (float): radius on the accretion disk (BH frame)
345 | incl (float): inclination of the black hole
346 | alpha: angle along the accretion disk (BH frame and observer frame)
347 | bh_mass (float): mass of the black hole
348 | order (int): Order of the image. Default is :math:`0` (direct image).
349 |
350 | Returns:
351 | float: Perigee distance :math:`P` of the photon
352 | """
353 |
354 | if radius <= 3 * bh_mass:
355 | return np.nan
356 |
357 | # Get an initial range for the possible periastron: must span the solution
358 | min_periastron = (
359 | 3.0 * bh_mass + order * 1e-5
360 | ) # higher order images go to inf for P -> 3M
361 | periastron_initial_guess = np.linspace(
362 | min_periastron,
363 | radius, # Periastron cannot be bigger than the radius by definition.
364 | 2,
365 | )
366 |
367 | # Check if the solution is in the initial range
368 | y = np.array(
369 | [
370 | perigee_optimization_function(periastron_guess, radius, alpha, bh_mass, incl, order)
371 | for periastron_guess in periastron_initial_guess
372 | ]
373 | )
374 | assert not any(np.isnan(y)), "Initial guess contains nan values"
375 |
376 | # If the solution is not in the initial range it likely doesnt exist for these input parameters
377 | # can happen for high inclinations and small radii -> photon orbits have P<3M, but the photon
378 | # does not travel this part of the orbit.
379 | if np.sign(y[0]) == np.sign(y[1]):
380 | return np.nan
381 |
382 | kwargs_eq13 = {
383 | "ir_radius": radius,
384 | "ir_angle": alpha,
385 | "bh_mass": bh_mass,
386 | "incl": incl,
387 | "order": order,
388 | }
389 | periastron = improve_solutions(
390 | func=perigee_optimization_function,
391 | x=periastron_initial_guess,
392 | y=y,
393 | kwargs=kwargs_eq13,
394 | )
395 | return periastron
396 |
397 |
398 | def solve_for_impact_parameter(
399 | radius,
400 | incl,
401 | alpha,
402 | bh_mass,
403 | order=0,
404 | ) -> float:
405 | r"""Calculate observer coordinates of a BH frame photon.
406 |
407 | This method solves Equation 13 to get the photon perigee distance for a given coordinate on the black hole accretion
408 | disk :math:`(r, \alpha)`.
409 | The observer coordinates :math:`(b, \alpha)` are then calculated from the perigee distance.
410 |
411 | Attention:
412 | Photons that originate from close to the black hole, and the front of the accretion disk, have orbits whose
413 | perigee is below :math:`3M` (and thus would be absorbed by the black hole), but still make it to the camera in the observer frame.
414 | These photons are not absorbed by the black hole, since they simply never actually travel the part of their orbit that lies below :math:`3M`
415 |
416 | Args:
417 | radius (float): radius on the accretion disk (BH frame)
418 | incl (float): inclination of the black hole
419 | alpha: angle along the accretion disk (BH frame and observer frame)
420 | bh_mass (float): mass of the black hole
421 | order (int): Order of the image. Default is :math:`0` (direct image).
422 |
423 | Returns:
424 | float: Impact parameter :math:`b` of the photon
425 | """
426 | # alpha_obs is flipped alpha/bh if n is odd
427 | if order % 2 == 1:
428 | alpha = (alpha + np.pi) % (2 * np.pi)
429 |
430 | periastron_solution = solve_for_perigee(radius, incl, alpha, bh_mass, order)
431 |
432 | # Photons that have no perigee and are not due to the exception described above are simply absorbed
433 | if periastron_solution is np.nan:
434 | if order == 0 and ((alpha < np.pi / 2) or (alpha > 3 * np.pi / 2)):
435 | # Photons with small R in the lower half of the image originate from photon orbits that
436 | # have a perigee < 3M. However, these photons are not absorbed by the black hole and do in fact reach the camera,
437 | # since they never actually travel this forbidden part of their orbit.
438 | # --> Return the newtonian limit i.e. just an ellipse, like the rings of saturn that are visible in front of saturn.
439 | return ellipse(radius, alpha, incl)
440 | else:
441 | return np.nan
442 | b = calc_b_from_perigee(periastron_solution, bh_mass)
443 | return b
444 |
445 |
446 | def ellipse(r, a, incl) -> float:
447 | r"""Equation of an ellipse
448 |
449 | This equation can be used for calculations in the Newtonian limit (large :math:`P \approx b`)
450 | It is also used to interpolate photons that originate from close to the black hole, and the front of the accretion disk.
451 | In this case, their perigee theoretically lies below :math:`3M`, but they are not absorbed by the black hole, as
452 | they travel away from the black hole, and never actually reach the part of their orbit that lies below :math:`3M`.
453 |
454 | Args:
455 | r (float): radius on the accretion disk (BH frame)
456 | a (float): angle along the accretion disk (BH frame and observer frame)
457 | incl (float): inclination of the black hole
458 |
459 | Returns:
460 | float: Impact parameter :math:`b` of the photon trajectory in the observer frame, which is in this case identical to the radius in the black hole frame :math:`r`
461 |
462 | """
463 | a = (a + np.pi / 2) % (
464 | 2 * np.pi
465 | ) # rotate 90 degrees for consistency with rest of the code
466 | major_axis = r
467 | minor_axis = abs(major_axis * np.cos(incl))
468 | eccentricity = np.sqrt(1 - (minor_axis / major_axis) ** 2)
469 | return minor_axis / np.sqrt((1 - (eccentricity * np.cos(a)) ** 2))
470 |
471 |
472 | def calc_Z1(bh_mass, a):
473 | r"""Calculate :math:`Z1` for Kerr black holes.
474 |
475 | The variable :math:`Z1` is used to calculate the innermost orbit for Kerr black holes.
476 |
477 | .. math::
478 |
479 | Z_1 \equiv 1 + \sqrt[3]{1-a_*^2}\left[ \sqrt[3]{1+a_*} + \sqrt[3]{1-a_*} \right]
480 |
481 | Args:
482 | bh_mass (float): Mass of the black hole.
483 | a (float): Specific angular momentum of the black hole. Should always be between :math:`-1` and :math:`1`. :math:`a > 0` if the accretion disk orbits in the same direction as the hole rotates; :math:`a < 0` if it orbits in the opposite direction.
484 |
485 | See also:
486 | :cite:t:`Page_1974` Equation 15l
487 |
488 | See also:
489 | :meth:`calc_innermost_orbit` for the calculation of the innermost orbit of Kerr black holes.
490 | """
491 | a_ = a/bh_mass
492 | return 1 + (1-a_**2)**(1/3)*((1+a_)**(1/3) + (1 - a_)**(1/3))
493 |
494 |
495 | def calc_Z2(bh_mass, a):
496 | r"""Calculate :math:`Z2` for Kerr black holes.
497 |
498 | The variable :math:`Z2` is used to calculate the innermost orbit for Kerr black holes.
499 |
500 | .. math::
501 |
502 | Z_2 \equiv \sqrt{3a_*^2+Z_1^2}
503 |
504 | Args:
505 | bh_mass (float): Mass of the black hole.
506 | a (float): Specific angular momentum of the black hole. Should always be between :math:`-1` and :math:`1`. :math:`a > 0` if the accretion disk orbits in the same direction as the hole rotates; :math:`a < 0` if it orbits in the opposite direction.
507 |
508 | See also:
509 | :cite:t:`Page_1974` Equation 15m
510 |
511 | See also:
512 | :meth:`calc_innermost_orbit` for the calculation of the innermost orbit of Kerr black holes.
513 | """
514 | Z1 = calc_Z1(bh_mass, a)
515 | a_ = a/bh_mass
516 | return np.sqrt(3*a_**2 + Z1**2)
517 |
518 |
519 | def calc_innermost_stable_orbit(bh_mass, a):
520 | r"""Calculcate the innermost stable orbit :math:`r_{ms}` for a Kerr black hole.
521 |
522 | A larger specific angular momentum :math:`a` will yield innermost orbits closer to the black hole.
523 |
524 | .. math::
525 |
526 | \begin{align*}
527 | r_{ms} &= Mx_0^2 \\
528 | x_0^2 &= 3 + Z_2 - sgn(a_*)\sqrt{(3-Z_1)(3+Z_1+2Z_2) }
529 | \end{align*}
530 |
531 | Args:
532 | bh_mass (float): Mass of the black hole.
533 | a (float): Specific angular momentum of the black hole. Should always be between :math:`-1` and :math:`1`. :math:`a > 0` if the accretion disk orbits in the same direction as the hole rotates; :math:`a < 0` if it orbits in the opposite direction.
534 |
535 | See also:
536 | :cite:t:`Page_1974`
537 |
538 | See also:
539 | :meth:`calc_Z1` and :meth:`calc_Z2`.
540 | """
541 | Z1 = calc_Z1(bh_mass, a)
542 | Z2 = calc_Z2(bh_mass, a)
543 | a_ = a/bh_mass
544 | return bh_mass*(
545 | 3 + Z2 - np.sign(a_)*((3-Z1)*(3+Z1+2*Z2))**.5
546 | )
547 |
548 |
549 | def calc_x0(bh_mass, a):
550 | r"""Calculcate :math:`x_0` for Kerr black holes.
551 |
552 | .. math::
553 |
554 | x_0 = \sqrt{\frac{r_{ms}}{M}}
555 |
556 | Args:
557 | bh_mass (float): Mass of the black hole.
558 | a (float): Specific angular momentum of the black hole. Should always be between :math:`-1` and :math:`1`. :math:`a > 0` if the accretion disk orbits in the same direction as the hole rotates; :math:`a < 0` if it orbits in the opposite direction.
559 |
560 | See also:
561 | :cite:t:`Page_1974` Equation 15k
562 |
563 | See also:
564 | meth:`calc_innermost_orbit` for the calculation of :math:`r_{ms}`
565 | """
566 | rms = calc_innermost_stable_orbit(bh_mass, a)
567 | return np.sqrt(rms/bh_mass)
568 |
569 |
570 | def calc_f_kerr(bh_mass, a, r):
571 | r"""Calculate the :math:`f`-function from :cite:t:`Page_1974` (Equation 12)
572 |
573 | The :math:`f`-function is used when calculating the relationship between intrinsic flux and radius for an accretion disk:
574 |
575 | .. math::
576 |
577 | F_s(r) = \frac{\dot{M}_0}{4\pi}e^{-(\nu + \psi + \mu)}f
578 |
579 | Here, :math:`\nu`, :math:`\psi` and :math:`\mu` are metric coefficients (functions of :math:`r`) of the Kerr metric. :math:`\dot{M}_0` is the radius-independent, time-averaged rate at which mass flows inward. Defining the innermost stable orbit as :math:`r_{ms}`, :math:`x=\sqrt{r/M}=\sqrt{r^*}`, :math:`x_0=\sqrt{r_{ms}/M}` and :math:`a^*=a/M`, the :math:`f`-function is defined as:
580 |
581 | .. math::
582 |
583 | \begin{align*}
584 | f = &\frac{3}{2M}\frac{1}{x^2(x^3 - 3x + 2a^*)}\Bigg[ x - x_0 - \frac{3}{2}a^*\ln\left(\frac{x}{x_0}\right) \\
585 | &- \frac{3(x_1 - a^*)^2}{x_1(x_1-x_2)(x_1-x_3)}\ln\left(\frac{x-x_1}{x_0-x_1}\right) \\
586 | &- \frac{3(x_2 - a^*)^2}{x_2(x_2-x_1)(x_2-x_3)}\ln\left(\frac{x-x_2}{x_0-x_2}\right) \\
587 | &- \frac{3(x_3 - a^*)^2}{x_3(x_3-x_1)(x_3-x_2)}\ln\left(\frac{x-x_3}{x_0-x_3}\right) \Bigg]
588 | \end{align*}
589 |
590 | , where
591 |
592 | .. math::
593 |
594 | \begin{align*}
595 | x_1 &= 2\cos(\frac{1}{3}\cos^{-1}(a_*) - \frac{\pi}{3}) \\
596 | x_2 &= 2\cos(\frac{1}{3}\cos^{-1}(a_*) + \frac{\pi}{3}) \\
597 | x_3 &= -2\cos(\frac{1}{3}\cos^{-1}(a_*)) \\
598 | \end{align*}
599 |
600 | For a Swarzschild black hole, :math:`a=0` and these simplify to:
601 |
602 | .. math::
603 |
604 | \begin{align*}
605 | x_1 &= \sqrt{3} \\
606 | x_2 &= 0 \\
607 | x_3 &= - \sqrt{3} \\
608 | f &= \frac{3}{2M}\frac{1}{{r^{*}}^{1.5}(r^*-3)}\left[x - x_0 + \frac{\sqrt{3}}{2}\ln\left( \frac{(\sqrt{6} - \sqrt{3})(\sqrt{r^*}+\sqrt{3})}{(\sqrt{6} + \sqrt{3})(\sqrt{r^*} - \sqrt{3})} \right) \right]
609 | \end{align*}
610 |
611 | Args:
612 | bh_mass (float): Mass of the black hole.
613 | a (float): Specific angular momentum of the black hole. Should always be between :math:`-1` and :math:`1`. :math:`a > 0` if the accretion disk orbits in the same direction as the hole rotates; :math:`a < 0` if it orbits in the opposite direction.
614 | r (float): Radius of the orbit
615 |
616 | Attention:
617 | :cite:t:`Luminet_1979` has a mistake in Equation 15. The factor in fromt of the :math:`log` should be :math:`\sqrt{3}/2` instead of :math:`\sqrt{3}/3`. This can be verified by solving :cite:t:`Page_1974` Equation 15n. The resulting images of the paper are correct though.
618 |
619 | See also:
620 | :cite:t:`Page_1974` for more information.
621 |
622 | See also:
623 | :meth:`calc_flux_intrinsic_kerr` for the calculation of the intrinsic flux.
624 |
625 | See also:
626 | :meth:`calc_innermost_stable_orbit` for the calculation of :math:`r_{ms}`
627 | """
628 | a_ = a/bh_mass
629 | x = np.sqrt(r/bh_mass)
630 | x0 = calc_x0(bh_mass, a)
631 | x1 = 2*np.cos(np.arccos(a_)/3 - np.pi/3)
632 | x2 = 2*np.cos(np.arccos(a_)/3 + np.pi/3)
633 | x3 = -2*np.cos(np.arccos(a_)/3)
634 | A = 3 * (2*bh_mass)**-1 * (x**2 * (x**3 - 3*x + 2*a_))**-1
635 | f = A * (
636 | x - x0 - 1.5*a_*np.log(x/x0)
637 | - 3*(x1-a_)**2 * np.log((x-x1)/(x0-x1)) / (x1*(x1-x2)*(x1-x3))
638 | - 3*(x2-a_)**2 * np.log((x-x2)/(x0-x2)) / (x2*(x2-x1)*(x2-x3))
639 | - 3*(x3-a_)**2 * np.log((x-x3)/(x0-x3)) / (x3*(x3-x1)*(x3-x2))
640 | )
641 | return f
642 |
643 |
644 | def calc_flux_intrinsic_kerr(bh_mass, a, r, acc):
645 | r"""Calculate the intrinsic flux of the accretion disk of a Kerr black hole, in function of the accretion rate, specific angular momentum, and radius of emission.
646 |
647 | The intrinsic flux is not redshift-corrected. Observed photons will have a flux that deviates from this by a factor of :math:`1/(1+z)^4`
648 |
649 | The intrinsic flux in function of the radius is defined as:
650 |
651 | .. math::
652 |
653 | F_s(r) &= \frac{\dot{M_0}}{4\pi}e^{-(\nu+\psi+\mu)}f \\
654 |
655 | where
656 |
657 | .. math::
658 |
659 | \begin{align*}
660 | e^{\nu+\psi+\mu} &= r \\
661 | f &= -\Omega_{,r}(E^{\dagger}-\Omega L^\dagger)^{-2}\int_{r_{ms}}^r(E^\dagger \ - \Omega L^\dagger)L^\dagger_{,r}dr
662 | \end{align*}
663 |
664 | Args:
665 | bh_mass (float): Mass of the black hole.
666 | a (float): Specific angular momentum of the black hole. Should always be between :math:`-1` and :math:`1`. :math:`a > 0` if the accretion disk orbits in the same direction as the hole rotates; :math:`a < 0` if it orbits in the opposite direction.
667 | r (float): Radius of the orbit.
668 | acc (float): (initial) accretion rate of the black hole :math:`\dot{M}_0`
669 |
670 | See also:
671 | :meth:`calc_f_kerr` for an algebraic expression of the :math:`f` function.
672 |
673 | """
674 | f = calc_f_kerr(bh_mass=bh_mass, a=a, r=r)
675 | exp_nupsimu = r
676 | return acc * f / exp_nupsimu / 4 / np.pi
677 |
678 |
679 | def calc_flux_intrinsic_swarzschild(bh_mass, r, acc):
680 | r"""Calculate the intrinsic flux of a photon.
681 |
682 | The intrinsic flux is not redshift-corrected. Observed photons will have a flux that deviates from this by a factor of :math:`1/(1+z)^4`
683 |
684 | .. math::
685 |
686 | F_s = \frac{3 M \dot{M}}{8 \pi (r^* - 3) {r^*}^{5/2}} \left( \sqrt{r^*} - \sqrt{6} + \frac{\sqrt{3}}{2} \log \left( \frac{(\sqrt{r^*} + \sqrt{3})(\sqrt{6}-\sqrt{3})}{(\sqrt{6} + \sqrt{3})(\sqrt{r^*}-\sqrt{3})} \right) \right)
687 |
688 | where :math:`r^*=r/M`
689 |
690 | Args:
691 | r (float): radius on the accretion disk (BH frame)
692 | acc (float): accretion rate
693 | bh_mass (float): mass of the black hole
694 |
695 | Returns:
696 | float: Intrinsic flux of the photon :math:`F_s`
697 |
698 | Attention:
699 | :cite:t:`Luminet_1979` has a mistake in Equation 15. The factor in fromt of the :math:`log` should be :math:`\sqrt{3}/2` instead of :math:`\sqrt{3}/3`. This can be verified by solving :cite:t:`Page_1974` Equation 15n. The resulting images of the paper are correct though.
700 | """
701 | r_ = r / bh_mass
702 | log_arg = (np.sqrt(r_) + np.sqrt(3)) * (np.sqrt(6) - np.sqrt(3)) / ((np.sqrt(r_) - np.sqrt(3)) * (np.sqrt(6) + np.sqrt(3)))
703 | A = 3 * bh_mass * acc / (8 * np.pi)/ ((r_ - 3) * r_**2.5)
704 | f = A * (np.sqrt(r_) - np.sqrt(6) + (np.sqrt(3)/2) * np.log(log_arg))
705 | return f
706 |
707 |
708 | def calc_flux_observed(r, acc, bh_mass, redshift_factor):
709 | r"""Calculate the observed bolometric flux of a photon :math:`F_o`
710 |
711 | .. math::
712 |
713 | F_o = \frac{F_s}{(1 + z)^4}
714 |
715 | Args:
716 | r (float): radius on the accretion disk (BH frame)
717 | acc (float): accretion rate
718 | bh_mass (float): mass of the black hole
719 | redshift_factor (float): gravitational redshift factor
720 |
721 | Returns:
722 | float: Observed flux of the photon :math:`F_o`
723 | """
724 | flux_intr = calc_flux_intrinsic_swarzschild(r=r, acc=acc, bh_mass=bh_mass)
725 | flux_observed = flux_intr / redshift_factor**4
726 | return flux_observed
727 |
728 |
729 | def calc_redshift_factor(radius, angle, incl, bh_mass, b):
730 | r"""
731 | Calculate the gravitational redshift factor (ignoring cosmological redshift):
732 |
733 | .. math::
734 |
735 | 1 + z = (1 - \Omega b \cos(\eta)) \left( -g_{tt} - 2 \Omega g_{t\phi} - \Omega^2 g_{\phi\phi} \right)^{-1/2}
736 |
737 | Attention:
738 | :cite:t:`Luminet_1979` does not have the correct equation for the redshift factor.
739 | The correct formula is given above. The resulting images of the paper are correct though.
740 | """
741 | # gff = (radius * np.sin(incl) * np.sin(angle)) ** 2
742 | # gtt = - (1 - (2. * M) / radius)
743 | z_factor = (
744 | 1.0 + np.sqrt(bh_mass / (radius**3)) * b * np.sin(incl) * np.sin(angle)
745 | ) * (1 - 3.0 * bh_mass / radius) ** -0.5
746 | return z_factor
747 |
--------------------------------------------------------------------------------