├── 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 | ![ci-badge](https://img.shields.io/appveyor/build/bgmeulem/luminet?label=ci&style=flat-square) [![Documentation Status](https://readthedocs.org/projects/luminet/badge/?version=latest&style=flat-square)](https://luminet.readthedocs.io/en/latest/?badge=latest) [![PyPI-badge](https://img.shields.io/pypi/v/luminet?pypiBaseUrl=https%3A%2F%2Fpypi.org&style=flat-square&logo=pypi&logoColor=white&link=https%3A%2F%2Fpypi.org%2Fproject%2Fluminet%2F)](https://pypi.org/project/luminet) 5 | [![Anaconda-Server Badge](https://anaconda.org/bgmeulem/luminet/badges/version.svg)](https://anaconda.org/bgmeulem/luminet) 6 | ![coverage](https://img.shields.io/codecov/c/github/bgmeulem/Luminet?style=flat-square) 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 | --------------------------------------------------------------------------------