├── docs ├── .nojekyll ├── index.html ├── source │ ├── _static │ │ ├── css │ │ │ └── custom.css │ │ ├── pygro-logo.png │ │ ├── pygro-banner.png │ │ ├── orbit-example-1.png │ │ ├── pygro-logo-dark.png │ │ ├── observer_example_1.png │ │ ├── observer_example_2.png │ │ ├── orbital-parameters.png │ │ ├── pygro-banner-dark.png │ │ ├── PygroGeodesicEngine.png │ │ ├── effective-potential.png │ │ ├── visualize_example_1.png │ │ ├── visualize_example_2.png │ │ ├── visualize_example_3.png │ │ ├── observer-illustration.png │ │ ├── orbital-parameters-dark.png │ │ ├── wormhole-configuration.png │ │ ├── effective-potential-dark.png │ │ ├── observer-illustration-angles.png │ │ ├── observer-illustration-dark.png │ │ ├── observer-illustration-angles-dark.png │ │ ├── observer-illustration-schwarzschild.png │ │ ├── null_geodesic │ │ │ ├── null_geodesic_example_1.png │ │ │ └── null_geodesic_example_2.png │ │ ├── time_like │ │ │ ├── time_like_geodesic_example_1.png │ │ │ └── time_like_geodesic_example_2.png │ │ └── observer-illustration-schwarzschild-dark.png │ ├── pygro-icon.ico │ ├── examples │ │ ├── orbital_fitting │ │ │ ├── results │ │ │ │ ├── S2_all.png │ │ │ │ └── Schwarzschild_posterior.png │ │ │ ├── data │ │ │ │ ├── tab_gillessen_vr.csv │ │ │ │ └── tab_gillessen_pos.csv │ │ │ └── S2_MCMC.py │ │ └── S2_MCMC.rst │ ├── orbit.rst │ ├── geodesic.rst │ ├── observer.rst │ ├── metricengine.rst │ ├── geodesicengine.rst │ ├── interpolators.rst │ ├── index.rst │ ├── integrators.rst │ ├── getting_started.rst │ ├── conf.py │ ├── geodesics │ │ ├── timelike_geodesic.rst │ │ └── null_geodesic.rst │ ├── visualize.rst │ ├── integrate_geodesic.rst │ ├── define_observer.rst │ ├── integrating_orbits.rst │ └── create_metric.rst ├── requirements.txt ├── Makefile └── make.bat ├── tests ├── __init__.py ├── __pycache__ │ ├── __init__.cpython-311.pyc │ └── metric_engine_test.cpython-311-pytest-8.3.4.pyc └── metric_engine_test.py ├── pygro ├── utils │ ├── __init__.py │ ├── constants.py │ ├── __pycache__ │ │ ├── __init__.cpython-39.pyc │ │ ├── kepler.cpython-311.pyc │ │ ├── kepler.cpython-39.pyc │ │ ├── __init__.cpython-310.pyc │ │ ├── __init__.cpython-311.pyc │ │ ├── __init__.cpython-312.pyc │ │ ├── rotations.cpython-310.pyc │ │ ├── rotations.cpython-311.pyc │ │ ├── rotations.cpython-312.pyc │ │ ├── rotations.cpython-39.pyc │ │ ├── pi_formatter.cpython-311.pyc │ │ └── pi_formatter.cpython-39.pyc │ ├── kepler.py │ ├── pi_formatter.py │ └── rotations.py ├── default_metrics │ ├── __pycache__ │ │ └── KerrBL.cpython-39.pyc │ ├── __init__.py │ └── Kerr-BL.metric ├── __init__.py ├── interpolators.py ├── observer.py ├── geodesic.py ├── orbit.py ├── geodesic_engine.py └── integrators.py ├── requirements.txt ├── __pycache__ └── test_sample.cpython-311-pytest-8.3.4.pyc ├── .gitignore ├── CHANGES.txt ├── pyproject.toml ├── .github └── workflows │ ├── documentation.yml │ └── release.yml └── README.md /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pygro/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pygro/utils/constants.py: -------------------------------------------------------------------------------- 1 | from astropy impo -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy>=2.2.2 2 | scipy>=1.15.1 3 | sympy>=1.11.1 4 | cython -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | .sidebar-tree { 2 | margin-bottom: 2rem; 3 | } -------------------------------------------------------------------------------- /docs/source/pygro-icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/pygro-icon.ico -------------------------------------------------------------------------------- /docs/source/_static/pygro-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/pygro-logo.png -------------------------------------------------------------------------------- /docs/source/_static/pygro-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/pygro-banner.png -------------------------------------------------------------------------------- /docs/source/_static/orbit-example-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/orbit-example-1.png -------------------------------------------------------------------------------- /docs/source/_static/pygro-logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/pygro-logo-dark.png -------------------------------------------------------------------------------- /docs/source/_static/observer_example_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/observer_example_1.png -------------------------------------------------------------------------------- /docs/source/_static/observer_example_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/observer_example_2.png -------------------------------------------------------------------------------- /docs/source/_static/orbital-parameters.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/orbital-parameters.png -------------------------------------------------------------------------------- /docs/source/_static/pygro-banner-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/pygro-banner-dark.png -------------------------------------------------------------------------------- /tests/__pycache__/__init__.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/tests/__pycache__/__init__.cpython-311.pyc -------------------------------------------------------------------------------- /docs/source/_static/PygroGeodesicEngine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/PygroGeodesicEngine.png -------------------------------------------------------------------------------- /docs/source/_static/effective-potential.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/effective-potential.png -------------------------------------------------------------------------------- /docs/source/_static/visualize_example_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/visualize_example_1.png -------------------------------------------------------------------------------- /docs/source/_static/visualize_example_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/visualize_example_2.png -------------------------------------------------------------------------------- /docs/source/_static/visualize_example_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/visualize_example_3.png -------------------------------------------------------------------------------- /docs/source/_static/observer-illustration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/observer-illustration.png -------------------------------------------------------------------------------- /docs/source/_static/orbital-parameters-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/orbital-parameters-dark.png -------------------------------------------------------------------------------- /docs/source/_static/wormhole-configuration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/wormhole-configuration.png -------------------------------------------------------------------------------- /pygro/utils/__pycache__/__init__.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/pygro/utils/__pycache__/__init__.cpython-39.pyc -------------------------------------------------------------------------------- /pygro/utils/__pycache__/kepler.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/pygro/utils/__pycache__/kepler.cpython-311.pyc -------------------------------------------------------------------------------- /pygro/utils/__pycache__/kepler.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/pygro/utils/__pycache__/kepler.cpython-39.pyc -------------------------------------------------------------------------------- /docs/source/_static/effective-potential-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/effective-potential-dark.png -------------------------------------------------------------------------------- /pygro/utils/__pycache__/__init__.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/pygro/utils/__pycache__/__init__.cpython-310.pyc -------------------------------------------------------------------------------- /pygro/utils/__pycache__/__init__.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/pygro/utils/__pycache__/__init__.cpython-311.pyc -------------------------------------------------------------------------------- /pygro/utils/__pycache__/__init__.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/pygro/utils/__pycache__/__init__.cpython-312.pyc -------------------------------------------------------------------------------- /pygro/utils/__pycache__/rotations.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/pygro/utils/__pycache__/rotations.cpython-310.pyc -------------------------------------------------------------------------------- /pygro/utils/__pycache__/rotations.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/pygro/utils/__pycache__/rotations.cpython-311.pyc -------------------------------------------------------------------------------- /pygro/utils/__pycache__/rotations.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/pygro/utils/__pycache__/rotations.cpython-312.pyc -------------------------------------------------------------------------------- /pygro/utils/__pycache__/rotations.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/pygro/utils/__pycache__/rotations.cpython-39.pyc -------------------------------------------------------------------------------- /__pycache__/test_sample.cpython-311-pytest-8.3.4.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/__pycache__/test_sample.cpython-311-pytest-8.3.4.pyc -------------------------------------------------------------------------------- /docs/source/_static/observer-illustration-angles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/observer-illustration-angles.png -------------------------------------------------------------------------------- /docs/source/_static/observer-illustration-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/observer-illustration-dark.png -------------------------------------------------------------------------------- /pygro/utils/__pycache__/pi_formatter.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/pygro/utils/__pycache__/pi_formatter.cpython-311.pyc -------------------------------------------------------------------------------- /pygro/utils/__pycache__/pi_formatter.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/pygro/utils/__pycache__/pi_formatter.cpython-39.pyc -------------------------------------------------------------------------------- /docs/source/_static/observer-illustration-angles-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/observer-illustration-angles-dark.png -------------------------------------------------------------------------------- /docs/source/examples/orbital_fitting/results/S2_all.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/examples/orbital_fitting/results/S2_all.png -------------------------------------------------------------------------------- /pygro/default_metrics/__pycache__/KerrBL.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/pygro/default_metrics/__pycache__/KerrBL.cpython-39.pyc -------------------------------------------------------------------------------- /docs/source/_static/observer-illustration-schwarzschild.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/observer-illustration-schwarzschild.png -------------------------------------------------------------------------------- /pygro/default_metrics/__init__.py: -------------------------------------------------------------------------------- 1 | from pygro.default_metrics.KerrBL import * 2 | from pygro.default_metrics.Yukawa import * 3 | from pygro.default_metrics.KerrSTVG import * -------------------------------------------------------------------------------- /docs/source/_static/null_geodesic/null_geodesic_example_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/null_geodesic/null_geodesic_example_1.png -------------------------------------------------------------------------------- /docs/source/_static/null_geodesic/null_geodesic_example_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/null_geodesic/null_geodesic_example_2.png -------------------------------------------------------------------------------- /docs/source/_static/time_like/time_like_geodesic_example_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/time_like/time_like_geodesic_example_1.png -------------------------------------------------------------------------------- /docs/source/_static/time_like/time_like_geodesic_example_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/time_like/time_like_geodesic_example_2.png -------------------------------------------------------------------------------- /docs/source/_static/observer-illustration-schwarzschild-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/_static/observer-illustration-schwarzschild-dark.png -------------------------------------------------------------------------------- /tests/__pycache__/metric_engine_test.cpython-311-pytest-8.3.4.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/tests/__pycache__/metric_engine_test.cpython-311-pytest-8.3.4.pyc -------------------------------------------------------------------------------- /docs/source/examples/orbital_fitting/results/Schwarzschild_posterior.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdellamonica/pygro/HEAD/docs/source/examples/orbital_fitting/results/Schwarzschild_posterior.png -------------------------------------------------------------------------------- /docs/source/orbit.rst: -------------------------------------------------------------------------------- 1 | ``Orbit`` 2 | ================================================================ 3 | 4 | .. autoclass:: pygro.orbit.Orbit() 5 | :members: 6 | 7 | .. automethod:: __init__ -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx==8.1.3 2 | sphinx_markdown_builder 3 | sphinx_rtd_theme==3.0.2 4 | furo 5 | nbsphinx==0.9.5 6 | sphinx_github_style 7 | sphinx_copybutton 8 | sympy 9 | numpy 10 | scipy 11 | ipython -------------------------------------------------------------------------------- /docs/source/geodesic.rst: -------------------------------------------------------------------------------- 1 | ``Geodesic`` 2 | ================================================================ 3 | 4 | The main object in PyGRO is the `geodesic` object which is used to describe the spacetime metric. 5 | 6 | .. autoclass:: pygro.geodesic.Geodesic 7 | :members: 8 | 9 | .. automethod:: __init__ -------------------------------------------------------------------------------- /pygro/utils/kepler.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from scipy.optimize import fsolve 3 | 4 | from astropy import constants, units 5 | 6 | def kepler_eq(E, e, M): 7 | return E-e*np.sin(E)-M 8 | 9 | def eccentric_anomaly(e, M, tol = 1e-15): 10 | return fsolve(kepler_eq, np.pi, args = (e, M), xtol = tol)[0] -------------------------------------------------------------------------------- /docs/source/observer.rst: -------------------------------------------------------------------------------- 1 | ``Observer`` 2 | ================================================================ 3 | 4 | A PyGRO object that defines a given observer in space-time. Useful to assign initial conditions to :py:class:`.Geodesic` objects. 5 | 6 | .. autoclass:: pygro.observer.Observer() 7 | :members: 8 | 9 | .. automethod:: __init__ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | dist/ 3 | pygro/__pycache__/ 4 | PyGRO.egg-info/ 5 | test.py 6 | pygro/default_metrics/__pycache__/ 7 | Test.ipynb 8 | .ipynb_checkpoints 9 | */.ipynb_checkpoints/* 10 | test_integrator.py 11 | *.metric 12 | docs/build 13 | docs/make.bat 14 | docs/Makefile 15 | docs/docsenv 16 | docs/docsenv2 17 | .vscode 18 | .gitignore 19 | .DS_Store 20 | **/.DS_Store 21 | setup.py -------------------------------------------------------------------------------- /pygro/__init__.py: -------------------------------------------------------------------------------- 1 | from pygro.metric_engine import Metric 2 | from pygro.geodesic_engine import GeodesicEngine 3 | from pygro.geodesic import Geodesic 4 | 5 | from pygro.orbit import Orbit 6 | from pygro.observer import Observer 7 | 8 | import logging 9 | 10 | PYGRO_LOGGING = logging.INFO 11 | 12 | logging.basicConfig(level=PYGRO_LOGGING, format="(PyGRO) {levelname}: {message}", style="{") -------------------------------------------------------------------------------- /docs/source/metricengine.rst: -------------------------------------------------------------------------------- 1 | ``Metric`` 2 | ================================================================ 3 | 4 | The main symbolic object in PyGRO is the ``pygro.Metric`` class which is used to describe the space-time metric and contains a variety of helper function to perform the symbolic calculations and obtain the geodesic equations. 5 | 6 | .. autoclass:: pygro.metric_engine.Metric() 7 | :members: 8 | 9 | .. automethod:: __init__ -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | PyGRO - Pyhton integrator for General Relativistic Orbits 2 | 3 | CHANGELOG 4 | |---------------------------------------------------------------------------| 5 | Version 1.0.1: 6 | - Added support for the Orbit API. 7 | 8 | Version 1.0.0: 9 | - First public release of PyGRO. 10 | 11 | Version 0.0.6: 12 | - Redefined the constants in the Metric() object and the way they are handled in the GeodesicEngine(). 13 | 14 | Version 0.0.3.2: 15 | 16 | - Added new method for metric definition from line element. 17 | 18 | -------------------------------------------------------------------------------- /docs/source/geodesicengine.rst: -------------------------------------------------------------------------------- 1 | ``GeodesicEngine`` 2 | ================================================================ 3 | 4 | The main object in PyGRO to perform numerical integrations of the geodesic equations is the :py:class:`.GeodesicEngine` class. 5 | 6 | .. autoclass:: pygro.geodesic_engine.GeodesicEngine 7 | :members: 8 | 9 | .. automethod:: __init__ 10 | 11 | .. autoclass:: pygro.geodesic_engine.StoppingCriterion 12 | :members: 13 | 14 | .. autoclass:: pygro.geodesic_engine.StoppingCriterionList 15 | :members: -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pygro" 3 | version = "1.0.1" 4 | description = "A Python Integrator for General Relativistic Orbits" 5 | authors = [ 6 | {name = "Riccardo Della Monica",email = "dellamonicariccardo@gmail.com"} 7 | ] 8 | license = {text = "GNU GPL-3.0 license"} 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | dependencies = [ 12 | "numpy>=2.2.2", 13 | "scipy>=1.15.1", 14 | "sympy>=1.11.1", 15 | ] 16 | 17 | 18 | [build-system] 19 | requires = ["poetry-core>=2.0.0,<3.0.0"] 20 | build-backend = "poetry.core.masonry.api" 21 | -------------------------------------------------------------------------------- /pygro/utils/pi_formatter.py: -------------------------------------------------------------------------------- 1 | from fractions import Fraction 2 | import numpy as np 3 | 4 | def pi_formatter(val, num): 5 | 6 | frac = Fraction(round(val/np.pi, 2)) 7 | 8 | num = frac.numerator 9 | den = frac.denominator 10 | 11 | if num == 0: return 0 12 | 13 | if num%den == 0: 14 | if num//den == 1: return r"$\pi$" 15 | if num//den == -1: return r"$-\pi$" 16 | return fr"{num//den}$\pi$" 17 | 18 | if num == 1: return fr"$\dfrac{{\pi}}{{{den}}}$" 19 | if num == -1: return fr"$-\dfrac{{\pi}}{{{den}}}$" 20 | 21 | return fr"$\dfrac{{{num}\pi}}{{{den}}}$" 22 | 23 | -------------------------------------------------------------------------------- /docs/source/interpolators.rst: -------------------------------------------------------------------------------- 1 | Interpolators 2 | ============= 3 | 4 | PyGRO implements several interpolation strategies for the integrated geodesic. The base interpolator class offers a way to compute the integrate geodesic at each value of the affine parameter on the integration interval. Whenever possible, :py:class:`~pygro.integrators.Integrator` classes implement dense output tailored to the order of the numerical integration involved in the scheme. 5 | 6 | When the dense output for the specific class is not available, it will fall back to cubic interpolation using Hermite polynomials which guarantees third-order accuracy for integrators of order :math:`p\geq3`. 7 | 8 | .. automodule:: pygro.interpolators 9 | :members: -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 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/source/examples/orbital_fitting/data/tab_gillessen_vr.csv: -------------------------------------------------------------------------------- 1 | 2000.487,1199,113 2 | 2002.418,-495,45 3 | 2002.421,-530,51 4 | 2003.271,-1571,59 5 | 2003.353,-1512,40 6 | 2003.446,-1428,51 7 | 2004.535,-1055,46 8 | 2004.537,-1056,37 9 | 2004.632,-1039,39 10 | 2005.158,-1001,77 11 | 2005.212,-960,37 12 | 2005.215,-910,54 13 | 2005.455,-839,60 14 | 2005.461,-907,43 15 | 2005.677,-774,77 16 | 2005.769,-860,58 17 | 2006.204,-702,42 18 | 2006.305,-718,77 19 | 2006.624,-658,57 20 | 2007.23,-586,57 21 | 2007.304,-537,57 22 | 2007.55,-505,57 23 | 2007.673,-482,57 24 | 2008.262,-394,27 25 | 2008.431,-425,62 26 | 2009.385,-241,45 27 | 2010.354,-134,27 28 | 2011.317,-3,34 29 | 2011.567,35,57 30 | 2012.21,185,34 31 | 2012.342,167,34 32 | 2012.494,195,34 33 | 2012.513,186,34 34 | 2012.705,190,45 35 | 2013.262,313,23 36 | 2013.655,361,45 37 | 2013.726,384,34 38 | 2014.185,490,28 39 | 2014.263,515,34 40 | 2014.521,568,17 41 | 2015.299,765,23 42 | 2015.706,869,45 43 | 2016.284,1081,45 44 | 2016.519,1198,34 -------------------------------------------------------------------------------- /pygro/utils/rotations.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | def Rotate_x(theta): 4 | R = np.array([ 5 | [1, 0, 0], 6 | [0, np.cos(theta), -np.sin(theta)], 7 | [0, np.sin(theta), np.cos(theta)] 8 | ]) 9 | return R 10 | 11 | def Rotate_y(theta): 12 | R = np.array([ 13 | [np.cos(theta), 0, np.sin(theta)], 14 | [0, 1, 0], 15 | [-np.sin(theta), 0, np.cos(theta)] 16 | ]) 17 | return R 18 | 19 | def Rotate_z(theta): 20 | R = np.array([ 21 | [np.cos(theta), -np.sin(theta), 0], 22 | [np.sin(theta), np.cos(theta), 0], 23 | [0, 0, 1] 24 | ]) 25 | return R 26 | 27 | def cartesian_to_spherical_point(x, y, z): 28 | r = np.sqrt(x**2+y**2+z**2) 29 | return r, np.arccos(z/r), np.arctan2(y, x) 30 | 31 | def cartesian_to_spherical_vector(x, y, z, vx, vy, vz): 32 | r = np.sqrt(x**2+y**2+z**2) 33 | vr = (x*vx+y*vy+z*vz)/r 34 | vtheta = -((x**2+y**2)*vz-z*(x*vx+y*vy))/(r**2*np.sqrt(x**2+y**2)) 35 | vphi = -(vx*y-x*vy)/(x**2+y**2) 36 | return vr, vtheta, vphi -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: "Deploy docs" 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: write 10 | steps: 11 | - id: install_pandoc 12 | run: sudo apt-get install pandoc 13 | - uses: actions/checkout@v4 14 | with: 15 | persist-credentials: false 16 | - name: Set up Python 17 | uses: actions/setup-python@v3 18 | with: 19 | python-version: "3.x" 20 | - name: Install Sphinx & Dependencies 21 | run: pip install -r docs/requirements.txt 22 | - name: Build Documentation 23 | run: cd "$GITHUB_WORKSPACE/docs" && make html 24 | - name: Upload artifacts 25 | uses: actions/upload-artifact@v4 26 | with: 27 | name: html-docs 28 | path: docs/build/html/ 29 | - name: Deploy 30 | uses: peaceiris/actions-gh-pages@v4 31 | if: github.ref == 'refs/heads/master' 32 | with: 33 | github_token: ${{ secrets.GITHUB_TOKEN }} 34 | publish_dir: docs/build/html 35 | -------------------------------------------------------------------------------- /tests/metric_engine_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import pygro 3 | 4 | base_matric_initializer = dict( 5 | name = "Test Schwarzschild", 6 | coordinates = ["t", "r", "theta", "phi"], 7 | transform_functions = [ 8 | "t", 9 | "r*sin(theta)*cos(phi)", 10 | "r*sin(theta)*sin(phi)", 11 | "r*cos(theta)" 12 | ], 13 | line_element = "-(1-2*M/r)*dt**2+1/(1-2*M/r)*dr**2+r**2*(dtheta**2+sin(theta)**2*dphi**2)" 14 | ) 15 | 16 | def without(d, key): 17 | new_d = d.copy() 18 | new_d.pop(key) 19 | return new_d 20 | 21 | def test_line_element_base_inizialization(): 22 | initializer = base_matric_initializer.copy() 23 | 24 | metric = pygro.Metric( 25 | M = 1, 26 | **initializer, 27 | ) 28 | 29 | @pytest.mark.parametrize("initializer", [ 30 | without(base_matric_initializer, 'name'), 31 | without(base_matric_initializer, 'coordinates'), 32 | ]) 33 | def test_line_element_missing_arg(initializer): 34 | 35 | with pytest.raises(TypeError): 36 | metric = pygro.Metric( 37 | M = 1, 38 | **initializer 39 | ) 40 | 41 | def test_line_element_missing_coordinate(): 42 | 43 | initializer = base_matric_initializer.copy() 44 | initializer.update(coordinates = ["t", "r", "theta"]) 45 | 46 | with pytest.raises(ValueError): 47 | metric = pygro.Metric( 48 | M = 1, 49 | **initializer 50 | ) 51 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. image:: _static/pygro-banner.png 2 | :width: 100% 3 | :alt: PyGRO 4 | :class: only-light 5 | 6 | .. image:: _static/pygro-banner-dark.png 7 | :width: 100% 8 | :alt: PyGRO 9 | :class: only-dark 10 | 11 | PyGRO [pronounced /ˈpiɡro/, from the italian **pigro**: *lazy, indolent*] is a Python library that provides a series of tools to perform the numerical integration of the geodesic equations describing a particle or photon orbit in any metric theory of gravity, given an analytic expression of the metric tensor. 12 | 13 | Documentation 14 | ----------------- 15 | 16 | This documentation comes with a series of tutorials that will guide you through the different possibilities that PyGRO offers 17 | 18 | .. toctree:: 19 | :maxdepth: 2 20 | :caption: Tutorials: 21 | 22 | getting_started 23 | create_metric 24 | integrate_geodesic 25 | define_observer 26 | integrating_orbits 27 | visualize 28 | integrators 29 | 30 | Moreover in the example notebooks we investigate some specific situations: 31 | 32 | .. toctree:: 33 | :maxdepth: 2 34 | :caption: Example Notebooks: 35 | 36 | examples/Schwarzschild-Precession.ipynb 37 | examples/Schwarzschild-PhotonOrbit.ipynb 38 | examples/Schwarzschild-ISCO.ipynb 39 | examples/Wormhole.ipynb 40 | examples/Kerr-Photons.ipynb 41 | examples/S2_MCMC 42 | 43 | Finally we provide the users with a detailed API guide that offers support for all the classes and methods in PyGRO 44 | 45 | .. toctree:: 46 | :maxdepth: 2 47 | :caption: API: 48 | 49 | metricengine 50 | geodesicengine 51 | geodesic 52 | observer 53 | orbit 54 | interpolators 55 | 56 | Indices and tables 57 | ------------------ 58 | 59 | * :ref:`genindex` 60 | * :ref:`modindex` 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyGRO 2 | 3 | ![PyGRO](https://github.com/rdellamonica/pygro/blob/master/docs/source/_static/pygro-banner.png?raw=true#gh-light-mode-only) 4 | ![PyGRO](https://github.com/rdellamonica/pygro/blob/master/docs/source/_static/pygro-banner-dark.png?raw=true#gh-dark-mode-only) 5 | 6 | [](https://github.com/rdellamonica/pygro) 7 | [](https://rdellamonica.github.io/pygro/index.html) 8 | [](https://pypi.org/project/PyGRO) 9 | [![arXiv](https://img.shields.io/badge/arXiv-2504.20152-red)](https://arxiv.org/abs/2504.20152) 10 | [![DOI](https://zenodo.org/badge/334275937.svg)](https://zenodo.org/badge/latestdoi/334275937) 11 | 12 | ![watchers](https://img.shields.io/github/watchers/rdellamonica/pygro) ![stars](https://img.shields.io/github/stars/rdellamonica/pygro) 13 | 14 | [PyGRO](https://github.com/rdellamonica/pygro) is a Python library that provides methods and functions to perform the numerical integration of the geodesic equations describing a particle or photon orbit in any metric theory of gravity, given an analytic expression of the metric tensor. 15 | 16 | --- 17 | 18 | ## Installation 19 | 20 | PyGRO is distributed as a Python package that can be installed through the PyPi package manager via: 21 | 22 | ``` 23 | pip install pygro 24 | ``` 25 | 26 | or by cloning [PyGRO GitHub repository](https://github.com/rdellamonica/pygro). 27 | 28 | ## Documentation 29 | 30 | The complete documentation for PyGRO is available on [docs](https://rdellamonica.github.io/pygro/index.html). 31 | 32 | ## Attribution 33 | 34 | If using PyGRO for your project please attribute it appropriately by citing the following reference: 35 | 36 | ``` 37 | @article{pygro2025, 38 | title={PyGRO: a Python Integrator for General Relativistic Orbits}, 39 | author={Riccardo Della Monica}, 40 | year={2025}, 41 | eprint={2504.20152}, 42 | archivePrefix={arXiv}, 43 | primaryClass={gr-qc}, 44 | url={https://arxiv.org/abs/2504.20152}, 45 | } 46 | ``` 47 | 48 | ## Copyright 49 | 50 | © Copyright 2025 Riccardo Della Monica 51 | 52 | ## License 53 | 54 | This project is licensed under the GNU General Public License v3.0. See the [LICENSE](./LICENSE.txt) file for details. 55 | -------------------------------------------------------------------------------- /docs/source/integrators.rst: -------------------------------------------------------------------------------- 1 | Integrators 2 | =========== 3 | 4 | Here, we collect the documentations of the ordinary-differential-equations (ODE) integrators that are implemented in PyGRO. 5 | 6 | A series of different integration schemes is pre-built in PyGRO. These can be chosen at the moment of defining a :py:class:`.GeodesicEngine`. 7 | 8 | In particular, we have implemented a series of adaptive step-size `explicit Runge-Kutta methods `_: 9 | 10 | - **Runge-Kutta-Fehlberg4(5)**: (``integrator = "rkf45"``) embedded method from the Runge-Kutta family of the 4th order with error estiamtion of the 5th order. The implemented version is based on [1]_. 11 | - **Dormand-Prince5(4)**: (``integrator = "dp45"``) embedded method of the 5th order with error estiamtion of the 4th order. The implemented version is based on [2]_. It is the **default** choice in PyGRO when no ``integrator`` argument is passed to the :py:class:`.GeodesicEngine`. 12 | - **Cash-Karp**: (``integrator = "ck45"``) embedded method of the 4th order with error estiamtion of the 5th order. The implemented version is based on [3]_. 13 | - **Runge-Kutta-Fehlberg7(8)**: (``integrator = "rkf78"``) embedded method from the Runge-Kutta family of the 7th order with error estiamtion of the 8th order. The implemented version is based on [4]_. 14 | 15 | All the implemented methods refer to the general class of :py:class:`.ExplicitAdaptiveRungeKuttaIntegrator`, whose docuemntation is reported here: 16 | 17 | .. autoclass:: pygro.integrators.ExplicitAdaptiveRungeKuttaIntegrator() 18 | :members: 19 | 20 | In the future, we also plan to implement implicit and symplectic integrations schemes. 21 | 22 | .. rubric:: References 23 | 24 | .. [1] Fehlberg, E (1964). "New high-order Runge-Kutta formulas with step size control for systems of first and second-order differential equations". Zeitschrift für Angewandte Mathematik und Mechanik. 44 (S1): T17 - T29. `doi:10.1002/zamm.19640441310 `_. 25 | 26 | .. [2] Dormand, J.R.; Prince, P.J. (1980). "A family of embedded Runge-Kutta formulae". Journal of Computational and Applied Mathematics. 6 (1): 19-26. `doi:10.1016/0771-050X(80)90013-3 `_ 27 | 28 | .. [3] J. R. Cash, A. H. Karp. (1990). "A variable order Runge-Kutta method for initial value problems with rapidly varying right hand sides", ACM Transactions on Mathematical Software 16: 201-222. `doi:10.1145/79505.79507 `_ 29 | 30 | .. [4] Fehlberg, Erwin (1968) "Classical fifth-, sixth-, seventh-, and eighth-order Runge-Kutta formulas with stepsize control". NASA Technical Report 287. (`PDF `_). -------------------------------------------------------------------------------- /docs/source/getting_started.rst: -------------------------------------------------------------------------------- 1 | Getting started 2 | =============== 3 | 4 | Installation 5 | ---------------- 6 | 7 | PyGRO is distributed as a Python package that can be installed through the PyPi package manager via: 8 | 9 | .. code-block:: 10 | 11 | pip install pygro 12 | 13 | Alternatively, you can clone PyGRO's Github repo and install it manually: 14 | 15 | .. code-block:: 16 | 17 | git clone https://github.com/rdellamonica/pygro.git 18 | cd pygro 19 | python -m pip install . 20 | 21 | Requirements 22 | ---------------- 23 | 24 | To successfully use this package, ensure you have the following dependencies installed. These dependencies are specified in the ``requirements.txt`` file and can be installed easily using ``pip``. Below are the exact versions required for compatibility: 25 | 26 | - **numpy>=2.2.2** 27 | - **scipy>=1.15.1** 28 | - **sympy>=1.11.1** 29 | 30 | You can install these dependencies by running the following command in your terminal: 31 | 32 | .. code-block:: 33 | 34 | pip install -r requirements.txt 35 | 36 | Ensure your Python environment is properly set up and compatible with the above versions to avoid compatibility issues. 37 | 38 | License 39 | ---------------- 40 | 41 | PuGRO is licensed under the GNU General Public License (GPL), which ensures that the software remains free and open source. Under the terms of the GPL: 42 | 43 | 1. **Freedom to Use**: You can use the software for any purpose, whether personal, educational, or commercial. 44 | 2. **Freedom to Modify**: You can study the source code, make changes, and adapt the software to meet your needs. 45 | 3. **Freedom to Share**: You can redistribute copies of the original software to others. 46 | 4. **Freedom to Share Modifications**: You can distribute modified versions of the software, provided you also release them under the GPL. 47 | 48 | **Conditions**: 49 | - If you distribute the software (modified or unmodified), you must include the full source code or make it available, along with a copy of the GPL license. 50 | - Any modifications or derivative works you distribute must also be licensed under the GPL, ensuring they remain free and open source. 51 | - The software comes with no warranty, to the extent permitted by law. 52 | 53 | For more details, visit https://www.gnu.org/licenses/. 54 | 55 | Citing PyGRO 56 | -------------------- 57 | 58 | PyGRO will be accompanied by a scientific publication. 59 | 60 | Please cite it properly attribute it in your works: 61 | 62 | .. code-block:: latex 63 | 64 | @article{pygro2025, 65 | title={PyGRO: a Python Integrator for General Relativistic Orbits}, 66 | author={Riccardo Della Monica}, 67 | year={2025}, 68 | eprint={2504.20152}, 69 | archivePrefix={arXiv}, 70 | primaryClass={gr-qc}, 71 | url={https://arxiv.org/abs/2504.20152}, 72 | } 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /docs/source/examples/S2_MCMC.rst: -------------------------------------------------------------------------------- 1 | Fitting relativistic orbits to data 2 | =================================== 3 | 4 | In this example, we will go through how one can use PyGRO in synergy with standard fitting packages (like `emcee `_) to perform parameter estimation of a relativistic orbital model on observational data. 5 | 6 | In particular, we will study the orbit of the S2 star in the Galactic Center, orbiting the supermassive black hole Sagittarius A* and fit an orbital model based on the Schwarzschild metric to publicly available data in `Gillessen et al. (2017) `_ (which can be found in our `repo `_). 7 | 8 | The code below can be run to start the orbital fitting, adjusting the parameters of the `emcee `_ sampler to one's computational needs (we refer to the official documentation of the package for further info): 9 | 10 | .. literalinclude:: orbital_fitting/S2_MCMC.py 11 | :language: python 12 | 13 | When running the above code and letting in reach convergence, one obtains the following posterior (we only show the posteriors for the orbital parameters): 14 | 15 | .. image:: orbital_fitting/results/Schwarzschild_posterior.png 16 | :width: 100% 17 | :alt: S2 Posterior 18 | 19 | The credible intervals for all the parameters (reported in the table below) are perfectly compatible with those coming from a keplerian orbital model fitting (from e.g. `Gillessen et al. (2017) `_), thus validating the fitting methodology using PyGRO. 20 | 21 | .. list-table:: MCMC analysis results for S2 22 | :header-rows: 1 23 | :widths: auto 24 | 25 | * - Parameter (unit) 26 | - Best Fit Value 27 | - Error 28 | * - :math:`D` (kpc) 29 | - 8.29 30 | - 0.17 31 | * - :math:`M` (:math:`10^6 M_\odot`) 32 | - 4.29 33 | - 0.19 34 | * - :math:`t_p` (yr) 35 | - 2002.325 36 | - 0.005 37 | * - :math:`a` (arcsec) 38 | - 0.1250 39 | - 0.0009 40 | * - :math:`e` 41 | - 0.8830 42 | - 0.0018 43 | * - :math:`i` (:math:`^\circ`) 44 | - 134.72 45 | - 0.40 46 | * - :math:`\Omega` (:math:`^\circ`) 47 | - 227.28 48 | - 0.59 49 | * - :math:`\omega` (:math:`^\circ`) 50 | - 65.39 51 | - 0.57 52 | * - :math:`\alpha_0` (mas) 53 | - 0.24 54 | - 0.13 55 | * - :math:`\delta_0` (mas) 56 | - -0.17 57 | - 0.19 58 | * - :math:`v_{\alpha,0}` (mas/yr) 59 | - 0.110 60 | - 0.041 61 | * - :math:`v_{\delta,0}` (mas/yr) 62 | - 0.110 63 | - 0.051 64 | * - :math:`v_{\text{LOS},0}` (km/s) 65 | - -2.1 66 | - 4.1 67 | 68 | 69 | The set of astronomical observables obtained assuming the best fit parameters is shown below 70 | 71 | .. image:: orbital_fitting/results/S2_all.png 72 | :width: 100% 73 | :alt: S2 best fit orbit 74 | 75 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | 16 | sys.path.insert(0, os.path.abspath('../..')) 17 | 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = 'PyGRO' 22 | copyright = '2025, Riccardo Della Monica' 23 | author = 'Riccardo Della Monica' 24 | 25 | # The full version, including alpha/beta/rc tags 26 | release = '1.0.1' 27 | 28 | 29 | # -- General configuration --------------------------------------------------- 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | 'sphinx.ext.duration', 36 | 'sphinx.ext.doctest', 37 | 'sphinx.ext.autodoc', 38 | 'sphinx.ext.autosummary', 39 | 'sphinx.ext.mathjax', 40 | 'sphinx.ext.viewcode', 41 | "sphinx_github_style", 42 | 'sphinx_copybutton', 43 | "nbsphinx", 44 | ] 45 | 46 | 47 | 48 | linkcode_url = "https://github.com/rdellamonica/pygro" 49 | 50 | # Add any paths that contain templates here, relative to this directory. 51 | templates_path = ['_templates'] 52 | 53 | # List of patterns, relative to source directory, that match files and 54 | # directories to ignore when looking for source files. 55 | # This pattern also affects html_static_path and html_extra_path. 56 | exclude_patterns = [] 57 | 58 | 59 | # -- Options for HTML output ------------------------------------------------- 60 | 61 | 62 | html_title = "Documentation" 63 | html_favicon = 'pygro-icon.ico' 64 | 65 | # The theme to use for HTML and HTML Help pages. See the documentation for 66 | # a list of builtin themes. 67 | # 68 | html_theme = 'furo' 69 | 70 | # Add any paths that contain custom static files (such as style sheets) here, 71 | # relative to this directory. They are copied after the builtin static files, 72 | # so a file named "default.css" will overwrite the builtin "default.css". 73 | html_static_path = ['_static'] 74 | html_theme_options = { 75 | "light_logo": "pygro-logo.png", 76 | "dark_logo": "pygro-logo-dark.png", 77 | "source_repository": linkcode_url, 78 | "source_branch": "master", 79 | "source_directory": "docs/source", 80 | "footer_icons": [ 81 | { 82 | "name": "GitHub", 83 | "url": linkcode_url, 84 | "html": """ 85 | 86 | 87 | 88 | """, 89 | "class": "", 90 | }, 91 | ], 92 | } 93 | 94 | html_css_files = [ 95 | 'css/custom.css', 96 | ] -------------------------------------------------------------------------------- /docs/source/geodesics/timelike_geodesic.rst: -------------------------------------------------------------------------------- 1 | `Time-like geodesic integration` 2 | ================================================================ 3 | 4 | Let's now proceed to the numerical integration of the geodesic equations for a massive particle or, in other words, for the time-like case. 5 | 6 | We will retrace the same steps followed for the :doc:`null_geodesic` 7 | 8 | .. code-block:: 9 | 10 | geo = pygro.Geodesic("time-like", geo_engine) 11 | geo.set_starting_point(0, 175, np.pi/2, 0) 12 | geo.set_starting_4velocity(u1 = 0, u2 = 0, u3 = 2e-4) 13 | 14 | We have fixed the initial position at :math:`r=175M` on the equatorial plane of the Schwarzschild black hole. Again, we have made use of the :py:meth:`~pygro.geodesic.Geodesic.set_starting_4velocity` method to fix the 4-velocity of the geodesic at the initial time. We are considering a geodesic that initially has only a tangential component (:math:`u_\phi`) of the 4-velocity. 15 | 16 | Carrying out the integration reads: 17 | 18 | .. code-block:: 19 | 20 | geo_engine.integrate(geo, 50000, 1, verbose=True) 21 | 22 | which we stop at an affine parameter :math:`\tau=50000` (which, since we have normalized the geodesic to :math:`g_{\mu\nu}\dot{x}^\mu\dot{x}^\nu = -1`, corresponds to the proper time in units of :math:`M`). 23 | 24 | When we :doc:`../visualize` we get 25 | 26 | .. image:: ../_static/time_like/time_like_geodesic_example_1.png 27 | :width: 100% 28 | :alt: Time-like geodesic orbiting the central black hole 29 | 30 | correspoding to a :py:class:`.Geodesic` that quasi-periodically orbits the central black hole while experiencing periastron advance, as expected for a highly relativistic orbit in a Schwarzschild space-time. 31 | 32 | Again, we can play around with the initial conditions and see how the resulting geodesics change. 33 | 34 | For example, we can consider an array of geodesics all starting at the same point and with an initially tangent 4-velocity and change the value of the tangential component to fix different moduli of the initial spatial velocity. 35 | 36 | .. code-block:: 37 | 38 | u3_arr = np.linspace(1e-5, 5e-4, 20) 39 | geo_arr = [] 40 | 41 | for u3 in u3_arr: 42 | 43 | geo = pygro.Geodesic("time-like", geo_engine) 44 | geo.set_starting_point(0, 175, np.pi/2, 0) 45 | geo.set_starting_4velocity(u1 = 0, u2 = 0, u3 = u3) 46 | geo_engine.integrate(geo, 5000, 1) 47 | 48 | geo_arr.append(geo) 49 | 50 | In this case, we are varying the ``u3`` component of the :py:class:`.Geodesic`, while keeping everything else fixed (of course, the :py:meth:`~pygro.geodesic.Geodesic.set_starting_4velocity` function also changes the initial value of ``u0`` to satisfy the normalization condition) and carrying out the integration up to the same value of proper time (or stopping it at an horizon, given the :py:class:`StoppingCriterion` fixed in the :doc:`../integrate_geodesic` tutorial). 51 | 52 | We can :doc:`../visualize` and obtain: 53 | 54 | .. image:: ../_static/time_like/time_like_geodesic_example_2.png 55 | :width: 100% 56 | :alt: A bunch of time-like geodesic either plunging in the central black hole or orbiting it, depending on the value of the initial velocity. 57 | 58 | Some of the geodesics will be bound to che central black hole, describing quasi-periodic orbits around it (having stopped the integration at a fixed amount of proper time, some geodesics stop before having completed a full turn), other plunge into the horizon, not having enough angular momentum to survive the pull of the central black hole. 59 | 60 | As for the case of the null geodesics, the :math:`u_\phi` component of the 4-velocity has, in principle, no direct physical meaning. For this reason, if one wants to assign an initial spatial velocity on a physical direction in space and with a given initial spatial velocity relative to a specific space-time observer, the :py:class:`.Observer` class is the most appropriate, for which we refer to the :doc:`../define_observer` tutorial. -------------------------------------------------------------------------------- /pygro/interpolators.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from collections.abc import Iterable 3 | 4 | class Interpolator: 5 | """ 6 | Base class for interpolation methods. 7 | 8 | :param x: Array of x values. 9 | :type x: np.ndarray 10 | :param y: Array of y values. 11 | :type y: np.ndarray 12 | :param k: List of derivative arrays. 13 | :type k: list[np.ndarray] 14 | """ 15 | def __init__(self, x: np.ndarray, y: np.ndarray, k: list[np.ndarray]): 16 | self.x = x 17 | self.y = y 18 | self.k = k 19 | 20 | def _compute_interpolation(self, x: float) -> np.ndarray: 21 | """ 22 | Compute the interpolated value at a given x. 23 | 24 | :param x: Point at which to evaluate the interpolation. 25 | :type x: float 26 | :raises NotImplementedError: Must be implemented in subclasses. 27 | """ 28 | raise NotImplementedError 29 | 30 | def _interpolate(self, x: float) -> np.ndarray: 31 | """ 32 | Interpolate a value within the defined range. 33 | 34 | :param x: Point at which to interpolate. 35 | :type x: float 36 | :raises ValueError: If x is outside the integration range. 37 | :return: Interpolated value. 38 | :rtype: np.ndarray 39 | """ 40 | if not min(self.x) <= x <= max(self.x): 41 | raise ValueError(f"Provided value ({x}) is outside the integration range [{min(self.x)}, {max(self.x)}].") 42 | 43 | if x == self.x[0]: 44 | return self.y[0] 45 | 46 | if x == self.x[-1]: 47 | return self.y[-1] 48 | 49 | return self._compute_interpolation(x) 50 | 51 | def __call__(self, x: float) -> float | np.ndarray: 52 | """ 53 | Evaluate the interpolator at a given x. 54 | 55 | :param x: Point or array of points at which to evaluate. 56 | :type x: float or Iterable 57 | :return: Interpolated value(s). 58 | :rtype: float | np.ndarray 59 | """ 60 | if isinstance(x, Iterable): 61 | return np.array([self._interpolate(x_i) for x_i in x]) 62 | return np.array([self._interpolate(x)]) 63 | 64 | class LinearInterpolator(Interpolator): 65 | """ 66 | Placeholder for a linear interpolation scheme. Currently not implemented. 67 | """ 68 | pass 69 | 70 | class HermiteCubicInterpolator(Interpolator): 71 | """ 72 | Implements Hermite cubic interpolation. 73 | 74 | This method ensures smooth interpolation by using derivative information. 75 | """ 76 | def _compute_interpolation(self, x): 77 | idx = np.searchsorted(self.x, x)-1 78 | 79 | x_0 = self.x[idx] 80 | x_1 = self.x[idx+1] 81 | y_0 = self.y[idx] 82 | y_1 = self.y[idx+1] 83 | h = x_1-x_0 84 | k_0 = self.k[idx] 85 | k_1 = self.k[idx+1] 86 | 87 | t = (x-x_0)/h 88 | 89 | return (1 - t) * y_0 + t * y_1 + t * (t - 1) * ((1 - 2 * t) * (y_1 - y_0) + (t - 1) * h * k_0[0] + t * h * k_1[0]) 90 | 91 | class DP54DenseOutput(Interpolator): 92 | """ 93 | Dense output interpolator for the Dormand-Prince 5(4) method. 94 | """ 95 | b = np.array([35/384, 0, 500/1113, 125/192, -2187/6784, 11/84, 0]) 96 | 97 | def _compute_interpolation(self, x): 98 | idx = np.searchsorted(self.x, x)-1 99 | 100 | x_0 = self.x[idx] 101 | x_1 = self.x[idx+1] 102 | y_0 = self.y[idx] 103 | h = x_1-x_0 104 | k = self.k[idx] 105 | 106 | t = (x-x_0)/h 107 | 108 | b_0 = t**2 * (3 - 2*t) * self.b[0] + t * (t - 1)**2 - t**2 * (t - 1)**2 * 5 * (2558722523 - 31403016 * t) / 11282082432 109 | b_1 = 0 110 | b_2 = t**2 * (3 - 2*t) * self.b[2] + t**2 * (t - 1)**2 * 100 * (882725551 - 15701508 * t) / 32700410799 111 | b_3 = t**2 * (3 - 2*t) * self.b[3] - t**2 * (t - 1)**2 * 25 * (443332067 - 31403016 * t) / 1880347072 112 | b_4 = t**2 * (3 - 2*t) * self.b[4] + t**2 * (t - 1)**2 * 32805 * (23143187 - 3489224 * t) / 199316789632 113 | b_5 = t**2 * (3 - 2*t) * self.b[5] - t**2 * (t - 1)**2 * 55 * (29972135 - 7076736 * t) / 822651844 114 | b_6 = t**2 * (t - 1) + t**2 * (t - 1)**2 * 10 * (7414447 - 829305 * t)/29380423 115 | 116 | b = np.array([b_0, b_1, b_2, b_3, b_4, b_5, b_6]) 117 | 118 | return y_0 + h*np.dot(b, k) -------------------------------------------------------------------------------- /docs/source/examples/orbital_fitting/data/tab_gillessen_pos.csv: -------------------------------------------------------------------------------- 1 | 1992.224,-9.9,3.7,170.2,3.8 2 | 1994.314,-33.7,3.7,177.5,2.9 3 | 1995.534,-41.8,3.,170.3,3.5 4 | 1996.253,-47.6,2.9,162.2,2.6 5 | 1996.427,-50.3,1.7,160.2,4.4 6 | 1997.544,-63.3,2.9,128.7,2.5 7 | 1998.373,-69.2,3.5,120.4,2.5 8 | 1999.465,-71.2,3.7,104.2,3.5 9 | 2000.472,-58.7,4.1,62.,2.4 10 | 2000.523,-67.,2.5,56.5,2.5 11 | 2001.502,-52.4,3.2,22.,1.6 12 | 2002.25,-8.1,4.5,-14.4,4.5 13 | 2002.335,4.,3.,-9.7,3. 14 | 2002.393,13.7,4.3,-2.1,4.3 15 | 2002.409,15.5,3.7,-0.1,3.7 16 | 2002.412,14.7,3.7,0.2,3.7 17 | 2002.414,14.7,3.7,1.,3.7 18 | 2002.488,24.9,9.,12.9,8.4 19 | 2002.578,28.1,3.7,18.5,3.7 20 | 2002.66,31.4,3.6,24.7,3.6 21 | 2002.66,31.2,3.6,25.2,3.6 22 | 2003.214,38.6,0.4,64.5,0.4 23 | 2003.351,39.,0.4,72.9,0.4 24 | 2003.356,38.3,0.4,72.7,0.4 25 | 2003.446,38.2,0.6,77.7,0.6 26 | 2003.451,38.9,0.5,78.3,0.5 27 | 2003.452,39.1,0.4,78.4,0.4 28 | 2003.454,38.5,0.4,78.5,0.4 29 | 2003.454,38.9,0.4,79.7,0.4 30 | 2003.55,38.6,0.4,83.2,0.4 31 | 2003.676,38.3,0.4,89.7,0.4 32 | 2003.678,38.8,0.7,89.5,0.7 33 | 2003.761,37.7,0.5,94.7,0.5 34 | 2004.24,35.,1.,111.1,1. 35 | 2004.325,34.7,0.3,114.1,0.3 36 | 2004.347,33.9,0.4,115.5,0.4 37 | 2004.443,33.9,0.4,118.3,0.4 38 | 2004.511,33.,0.4,120.6,0.4 39 | 2004.513,33.2,0.4,121.,0.4 40 | 2004.516,33.4,0.5,121.,0.5 41 | 2004.516,33.1,0.6,121.,0.7 42 | 2004.574,32.2,0.4,122.7,0.4 43 | 2004.574,32.2,0.6,121.8,0.6 44 | 2004.664,31.5,0.3,125.1,0.3 45 | 2004.67,31.9,0.5,125.3,0.5 46 | 2004.73,31.1,0.3,127.2,0.3 47 | 2004.73,31.8,0.8,126.8,0.8 48 | 2005.27,26.,0.4,141.,0.4 49 | 2005.366,25.1,0.4,143.1,0.4 50 | 2005.371,24.7,0.4,143.3,0.4 51 | 2005.374,24.7,0.5,143.3,0.5 52 | 2005.467,24.3,0.4,144.9,0.4 53 | 2005.57,23.5,0.4,146.9,0.4 54 | 2005.576,23.,0.4,147.3,0.4 55 | 2006.324,15.7,0.9,159.7,0.7 56 | 2007.545,1.3,1.,173.6,0.8 57 | 2007.55,2.6,0.5,173.1,0.5 58 | 2007.686,1.,0.6,173.9,0.6 59 | 2007.687,0.4,0.6,173.9,0.6 60 | 2008.148,-6.,0.4,177.,0.4 61 | 2008.197,-6.5,0.4,177.,0.4 62 | 2008.268,-7.4,0.4,178.,0.4 63 | 2008.456,-9.7,0.4,178.2,0.3 64 | 2008.472,-9.4,0.4,178.6,0.4 65 | 2008.473,-9.,0.5,178.2,0.5 66 | 2008.593,-10.,1.6,177.6,1.4 67 | 2008.601,-11.8,0.4,178.2,0.4 68 | 2008.708,-12.6,0.4,179.2,0.4 69 | 2009.185,-18.4,0.8,179.1,0.8 70 | 2009.273,-19.1,0.4,179.2,0.4 71 | 2009.3,-19.6,0.4,179.3,0.4 72 | 2009.303,-19.3,0.4,179.6,0.4 73 | 2009.336,-19.4,0.4,179.2,0.4 74 | 2009.336,-19.5,0.4,179.2,0.4 75 | 2009.371,-19.7,0.4,179.,0.4 76 | 2009.502,-20.9,0.5,179.,0.5 77 | 2009.505,-21.2,0.4,179.2,0.4 78 | 2009.557,-21.9,0.4,179.5,0.4 79 | 2009.557,-21.3,0.4,179.4,0.4 80 | 2009.606,-22.3,0.4,179.5,0.4 81 | 2009.718,-23.9,0.4,179.1,0.4 82 | 2009.776,-24.1,0.4,179.,0.4 83 | 2010.234,-29.5,0.4,177.5,0.4 84 | 2010.239,-29.6,0.4,176.6,0.4 85 | 2010.239,-29.,0.4,177.1,0.4 86 | 2010.245,-29.3,0.4,176.8,0.4 87 | 2010.351,-30.6,0.4,176.6,0.4 88 | 2010.444,-31.8,0.4,176.1,0.4 89 | 2010.455,-31.8,0.4,175.9,0.4 90 | 2010.455,-31.4,0.4,176.1,0.4 91 | 2010.455,-31.2,0.5,175.4,0.5 92 | 2010.46,-31.3,0.6,176.1,0.6 93 | 2010.616,-31.8,0.4,174.4,0.4 94 | 2010.619,-33.9,0.4,175.1,0.4 95 | 2010.622,-33.3,0.4,175.1,0.4 96 | 2010.624,-33.2,0.4,175.3,0.4 97 | 2010.627,-33.5,0.4,174.9,0.4 98 | 2010.676,-33.6,0.6,174.1,0.6 99 | 2010.679,-34.2,0.4,174.4,0.4 100 | 2011.238,-39.7,0.4,170.7,0.4 101 | 2011.241,-39.7,1.9,170.6,1.9 102 | 2011.244,-39.1,0.4,170.4,0.4 103 | 2011.249,-39.5,0.5,170.3,0.5 104 | 2011.312,-40.3,0.4,169.8,0.4 105 | 2011.313,-38.9,0.5,169.9,0.5 106 | 2011.315,-40.5,0.4,169.7,0.4 107 | 2011.337,-40.4,0.3,169.3,0.3 108 | 2011.443,-41.4,0.4,168.4,0.4 109 | 2011.553,-42.5,0.6,167.6,0.6 110 | 2011.613,-42.7,0.7,166.9,0.7 111 | 2011.689,-44.,0.7,166.3,0.7 112 | 2011.695,-43.9,0.4,166.1,0.4 113 | 2011.695,-42.8,1.,166.2,1. 114 | 2011.698,-43.9,0.4,166.,0.4 115 | 2011.722,-44.1,0.4,165.6,0.4 116 | 2012.202,-48.,0.7,159.8,0.7 117 | 2012.339,-49.6,0.4,158.4,0.4 118 | 2012.497,-50.1,0.4,156.2,0.4 119 | 2012.533,-51.4,0.4,156.2,0.4 120 | 2012.544,-51.1,0.4,156.1,0.4 121 | 2012.552,-51.7,0.4,155.8,0.4 122 | 2012.552,-50.5,0.8,155.6,0.8 123 | 2012.604,-51.7,1.1,155.,1.1 124 | 2012.7,-52.,0.4,153.4,0.4 125 | 2013.161,-55.3,0.4,147.1,0.4 126 | 2013.24,-55.5,0.4,145.8,0.4 127 | 2013.317,-56.5,0.4,144.6,0.4 128 | 2013.366,-56.7,0.4,143.4,0.4 129 | 2013.42,-56.7,0.5,142.7,0.5 130 | 2013.437,-56.,0.7,142.7,0.7 131 | 2013.494,-57.3,0.5,140.9,0.5 132 | 2013.502,-57.3,0.4,141.1,0.4 133 | 2013.587,-57.9,0.4,139.5,0.4 134 | 2013.59,-57.3,1.1,139.3,1.1 135 | 2013.617,-58.3,0.6,139.,0.6 136 | 2015.432,-66.2,0.4,97.7,0.4 137 | 2015.517,-66.7,0.4,95.3,0.4 138 | 2015.706,-67.,0.5,90.1,0.5 139 | 2015.747,-66.8,0.4,88.5,0.4 140 | 2016.221,-66.1,0.4,73.3,0.4 141 | 2016.287,-66.,0.5,70.8,0.5 142 | 2016.325,-66.,0.8,69.7,0.8 143 | 2016.369,-66.2,0.8,68.7,0.8 144 | 2016.525,-64.4,0.5,62.7,0.5 145 | 2016.53,-65.1,0.5,62.7,0.5 -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | - "[0-9]+.[0-9]+.[0-9]+a[0-9]+" 8 | - "[0-9]+.[0-9]+.[0-9]+b[0-9]+" 9 | - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" 10 | 11 | env: 12 | PACKAGE_NAME: "pygro" 13 | OWNER: "rdellamonica" 14 | 15 | jobs: 16 | details: 17 | runs-on: ubuntu-latest 18 | outputs: 19 | new_version: ${{ steps.release.outputs.new_version }} 20 | suffix: ${{ steps.release.outputs.suffix }} 21 | tag_name: ${{ steps.release.outputs.tag_name }} 22 | steps: 23 | - uses: actions/checkout@v2 24 | 25 | - name: Extract tag and Details 26 | id: release 27 | run: | 28 | if [ "${{ github.ref_type }}" = "tag" ]; then 29 | TAG_NAME=${GITHUB_REF#refs/tags/} 30 | NEW_VERSION=$(echo $TAG_NAME | awk -F'-' '{print $1}') 31 | SUFFIX=$(echo $TAG_NAME | grep -oP '[a-z]+[0-9]+' || echo "") 32 | echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" 33 | echo "suffix=$SUFFIX" >> "$GITHUB_OUTPUT" 34 | echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" 35 | echo "Version is $NEW_VERSION" 36 | echo "Suffix is $SUFFIX" 37 | echo "Tag name is $TAG_NAME" 38 | else 39 | echo "No tag found" 40 | exit 1 41 | fi 42 | 43 | check_pypi: 44 | needs: details 45 | runs-on: ubuntu-latest 46 | steps: 47 | - name: Fetch information from PyPI 48 | run: | 49 | response=$(curl -s https://pypi.org/pypi/${{ env.PACKAGE_NAME }}/json || echo "{}") 50 | latest_previous_version=$(echo $response | jq --raw-output "select(.releases != null) | .releases | keys_unsorted | last") 51 | if [ -z "$latest_previous_version" ]; then 52 | echo "Package not found on PyPI." 53 | latest_previous_version="0.0.0" 54 | fi 55 | echo "Latest version on PyPI: $latest_previous_version" 56 | echo "latest_previous_version=$latest_previous_version" >> $GITHUB_ENV 57 | 58 | - name: Compare versions and exit if not newer 59 | run: | 60 | NEW_VERSION=${{ needs.details.outputs.new_version }} 61 | LATEST_VERSION=$latest_previous_version 62 | if [ "$(printf '%s\n' "$LATEST_VERSION" "$NEW_VERSION" | sort -rV | head -n 1)" != "$NEW_VERSION" ] || [ "$NEW_VERSION" == "$LATEST_VERSION" ]; then 63 | echo "The new version $NEW_VERSION is not greater than the latest version $LATEST_VERSION on PyPI." 64 | exit 1 65 | else 66 | echo "The new version $NEW_VERSION is greater than the latest version $LATEST_VERSION on PyPI." 67 | fi 68 | 69 | setup_and_build: 70 | needs: [details, check_pypi] 71 | runs-on: ubuntu-latest 72 | steps: 73 | - uses: actions/checkout@v2 74 | 75 | - name: Set up Python 76 | uses: actions/setup-python@v4 77 | with: 78 | python-version: "3.12" 79 | 80 | - name: Install Poetry 81 | run: | 82 | curl -sSL https://install.python-poetry.org | python3 - 83 | echo "$HOME/.local/bin" >> $GITHUB_PATH 84 | 85 | - name: Set project version with Poetry 86 | run: | 87 | poetry version ${{ needs.details.outputs.new_version }} 88 | 89 | - name: Install dependencies 90 | run: poetry install --sync --no-interaction 91 | 92 | - name: Build source and wheel distribution 93 | run: | 94 | poetry build 95 | 96 | - name: Upload artifacts 97 | uses: actions/upload-artifact@v4 98 | with: 99 | name: dist 100 | path: dist/ 101 | 102 | pypi_publish: 103 | name: Upload release to PyPI 104 | needs: [setup_and_build, details] 105 | runs-on: ubuntu-latest 106 | environment: 107 | name: release 108 | permissions: 109 | id-token: write 110 | steps: 111 | - name: Download artifacts 112 | uses: actions/download-artifact@v4 113 | with: 114 | name: dist 115 | path: dist/ 116 | 117 | - name: Publish distribution to PyPI 118 | uses: pypa/gh-action-pypi-publish@release/v1 119 | 120 | github_release: 121 | name: Create GitHub Release 122 | needs: [setup_and_build, details] 123 | runs-on: ubuntu-latest 124 | permissions: 125 | contents: write 126 | steps: 127 | - name: Checkout Code 128 | uses: actions/checkout@v4 129 | with: 130 | fetch-depth: 0 131 | 132 | - name: Download artifacts 133 | uses: actions/download-artifact@v4 134 | with: 135 | name: dist 136 | path: dist/ 137 | 138 | - name: Create GitHub Release 139 | id: create_release 140 | env: 141 | GH_TOKEN: ${{ github.token }} 142 | run: | 143 | gh release create ${{ needs.details.outputs.tag_name }} dist/* --title ${{ needs.details.outputs.tag_name }} --generate-notes 144 | -------------------------------------------------------------------------------- /docs/source/geodesics/null_geodesic.rst: -------------------------------------------------------------------------------- 1 | `Null geodesic integration` 2 | ================================================================ 3 | 4 | Null geodesics describe in General Relativity the space-time trajectory of massless particles (e.g. photons). Numerically integrating the equations of motions describing the dynamics of these particles allows performing *ray-tracing*, i.e. to reconstruct the paths that light-rays would follow on a curved space-time around a massive object. 5 | 6 | In this tutorial we will examine the procedure in PyGRO to perform this operation and obtain the numerically integrated trajectory of a photon (or a bunch of them) in space-time. 7 | 8 | Let's assume that we have a :py:class:`.Metric` object appropriately initialized following the :doc:`../create_metric` tutorial and thus representing the Schwarzschild metric. Moreover, we assume that the we have initialized a :py:class:`.GeodesicEngine` object and linked it to the Schwarzschild metric, as done in the :doc:`../integrate_geodesic` tutorial. This also means that we have set a :py:class:`.StoppingCriterion` to stop the integration if a geodesic goes very close to the event horizon of the metric (resulting in a ``Geodesic.exit = "horizon"`` exit). 9 | 10 | Now we can initialize a new null :py:class:`.Geodesic` in this set-up by simply defining a new object 11 | 12 | .. code-block:: 13 | 14 | geo = pygro.Geodesic("null", geo_engine) 15 | 16 | Before being able to integrate the geodesic, we have to fix an initial position and 4-velocity for the geodesic. These quantities are stored in the ``Geodesic.initial_x`` and ``Geodesic.initial_u`` properties, respectively. While we can fix these properties by hand, it is more convenient to do so using the helper functions of the :py:class:`.Geodesic` class, that guarantee the normalization constraint imposed by the geodesic type. 17 | 18 | We can hence use: 19 | 20 | .. code-block:: 21 | 22 | geo.set_starting_point(0, 50, np.pi/2, 0) 23 | geo.set_starting_4velocity(u1 = -1, u2 = 0, u3 = 0.01) 24 | 25 | to fix the initial position of the geodesic at a distance :math:`r=50M` from the black hole, on the equatorial plane (:math:`\theta = \pi/2`) and, without loss of generality at a coordinate time :math:`t=0` and along the line :math:`\phi = 0`. Using the :py:meth:`~pygro.geodesic.Geodesic.set_starting_4velocity` method, we have fixed an initial 4-velocity starting from its components :math:`u_r=-1`, :math:`u_\theta=0` and :math:`u_\phi=0.01`, and we are letting the internal helper function of the :py:class:`.Geodesic` autonomously retrieve the corresponding value of :math:`u_t` that satisfies the normalization condition at the given initial point. 26 | 27 | We will be alerted of the successful initialization of the :py:class:`.Geodesic` object by the output: 28 | 29 | .. code-block:: text 30 | 31 | >>> (PyGRO) INFO: Setting starting point 32 | >>> (PyGRO) INFO: Setting initial 4-velocity. 33 | 34 | Now we are ready to carry out the integration of the :py:class:`.Geodesic` following the scheme depicted in :doc:`../integrate_geodesic`. 35 | 36 | Hence, we can call the method: 37 | 38 | .. code-block:: 39 | 40 | geo_engine.integrate(geo, 50, 1, verbose=True, accuracy_goal = 11, precision_goal = 11) 41 | 42 | to integrate the :py:class:`.Geodesic` object ``geo`` up to a value of the affine parameter :math:`\tau = 50`, starting with an initial step of 1. The ``verbose = True`` keyword argument makes the integration verbose which will alert us of the status of the integration. We have also used specific precisiona and accuracy tolerances, for which we suggest reading the :doc:`../integrators` tutorial. In this case, we will be logged 43 | 44 | .. code-block:: text 45 | 46 | >>> (PyGRO) INFO: Starting integration. 47 | >>> (PyGRO) INFO: Integration completed in 0.038264 s with result 'done'. 48 | 49 | signaling that the integration has been succesful, and has not met any horizon or stopping criterion (since it has an exit string ``'done'``). 50 | 51 | We can visualize the resulting geodesic following the :doc:`../visualize` tutorial: 52 | 53 | .. image:: ../_static/null_geodesic/null_geodesic_example_1.png 54 | :width: 100% 55 | :alt: Null geodesic deflected by the central black hole 56 | 57 | The geodesic obtained by the integration propagates in the gravitational field of the Schwarzschild black hole and gets deflected by a small angle as it approaches the central object. 58 | 59 | We can try and repeat the integration by changing the initial 4-velocity, reducing its :math:`\theta` component (*e.g.* by a factor 5) and making it plunge into the horizon. 60 | 61 | .. code-block:: 62 | 63 | geo_2 = pygro.Geodesic("null", geo_engine, verbose = True) 64 | geo_2.set_starting_point(0, 50, np.pi/2, 0) 65 | geo_2.set_starting_4velocity(u1 = -1, u2 = 0, u3 = 0.002) 66 | geo_engine.integrate(geo_2, 100, 1) 67 | 68 | resulting in 69 | 70 | .. code-block:: text 71 | 72 | (PyGRO) INFO: Integration completed in 0.10361 s with result 'horizon'. 73 | 74 | which can be visualized as 75 | 76 | .. image:: ../_static/null_geodesic/null_geodesic_example_2.png 77 | :width: 100% 78 | :alt: Null geodesic being captured by the central black hole 79 | 80 | We can play around by changing the starting point of the geodesic and its 4-velocity to see how the the integrated geodesic changes depending on the initial data. Nevertheless, directly using the :py:meth:`~pygro.geodesic.Geodesic.set_starting_4velocity` to fix the initial components of the 4-velocity has its drawebacks as one directly fixes the derivatives of the space-time coordinates at the initial time which in principle have no physical meaning. A much more physical way of fixing the initial conditions is to choose a physical observer, using the :py:class:`.Observer` class and use it to fix initial conditions for null geodesics starting from a specific direction in the observer's reference frame. 81 | 82 | This will be explained in more details in the :doc:`../define_observer` tutorial. -------------------------------------------------------------------------------- /docs/source/visualize.rst: -------------------------------------------------------------------------------- 1 | Visualize the results 2 | =================================== 3 | 4 | Let's consider a :py:class:`.Geodesic` object that has been integrated by following the procedures shown in the previous tutorials (see :doc:`integrate_geodesic`). 5 | 6 | For example, let's consider the following geodesic in the Schwarzschild space-time: 7 | 8 | .. code-block:: 9 | 10 | geo = pygro.Geodesic("time-like", geo_engine) 11 | geo.set_starting_point(0, 175, np.pi/2, 0) 12 | geo.set_starting_4velocity(u1 = 0, u2 = 0, u3 = 2e-4) 13 | geo_engine.integrate(geo, 50000, 1) 14 | 15 | We want to visualize the results of the integration. 16 | 17 | First of all we can access the ``exit`` status of the geodesic 18 | 19 | .. code-block:: 20 | 21 | print(geo.exit) 22 | 23 | which is assigned by the :py:class:`.GeodesicEngine` object. In this case we obtain 24 | 25 | .. code-block:: text 26 | 27 | >>> 'done' 28 | 29 | signaling that the integration has been carried on for the entire interval of proper time indicated in the :py:meth:`~pygro.GeodesicEngine.integrate` method. If a :py:class:`.StoppingCriterion` has been met the ``exit`` status of the :py:class:`.Geodesic` will correspond to the exit message of the criterion that stopped the integration (see :doc:`integrate_geodesic`). 30 | 31 | Once the geodesic has been integrated, the properties ``Geodesic.tau``, ``Geodesic.x``, and ``Geodesic.u`` will be filled with the arrays of proper times, integrated space-time cooridinates and components of the 4-velocity along the geodesic, respectively. 32 | 33 | We can access them and plot them directly, by doing for example (using `Matplotlib `_) 34 | 35 | .. code-block:: 36 | 37 | import matplotlib.pyplot as plt 38 | 39 | fig, ax = plt.subplots() 40 | 41 | ax.plot(geo.tau, geo.x[:,1]) 42 | 43 | ax.set_xlabel(r"$\tau$ ($M$)") 44 | ax.set_ylabel(r"$r$ ($M$)") 45 | 46 | which outputs 47 | 48 | .. image:: _static/visualize_example_1.png 49 | :width: 100% 50 | :alt: Time-like geodesic visualization 51 | 52 | showing the radial component (``geo.x[:,1]``) as a function of the proper time (``geo.tau``). 53 | 54 | We can do the same with the other components, or with any component of the 4-velocity. For example, one can plot the 0-th component of the integrated geodesic (in our case :math:`\dot{t}`) 55 | 56 | .. code-block:: 57 | 58 | fig, ax = plt.subplots() 59 | 60 | ax.plot(geo.tau, geo.u[:,0]) 61 | 62 | ax.set_xlabel(r"$\tau$ ($M$)") 63 | ax.set_ylabel(r"$\dot{t}$ ($M$)") 64 | 65 | and obtain 66 | 67 | .. image:: _static/visualize_example_2.png 68 | :width: 100% 69 | :alt: Time-like geodesic visualization Einstein delay 70 | 71 | which clearly shows the Einstein delay (combination of gravitational redshift and Lorentz time-dilation) experienced by the orbit as it gets nearer of farther from the black hole. 72 | 73 | Another possibility is to use the :py:meth:`~pygro.metric_engine.Metric.transform` of the :py:class:`.Metric` object (which is available only if ``transform_functions`` have been passed when the metric has been initialized, see :doc:`create_metric`). This fuction acts on the integrated components of the geodesic and applies the transformation defined in the ``transform_functions``, returning the an array of transformed values. 74 | 75 | Having defined transformations to a pseudo-cartesian coordiante system (*i.e.* treating the Schwarzschild components as if they were actual spherical coordinates) one can transform the integrated geodesic by passing the transposed ``geo.x`` array, which is a :math:`4\times N` array, to the :py:meth:`~pygro.metric_engine.Metric.transform` method 76 | 77 | .. code-block:: 78 | 79 | t, x, y, z = metric.transform(geo.x.T) 80 | 81 | which one can directly plot 82 | 83 | .. code-block:: 84 | 85 | fig, ax = plt.subplots() 86 | 87 | ax.plot(x, y) 88 | 89 | # adding the central black hole 90 | 91 | theta = np.linspace(0, 2*np.pi, 150) 92 | x_bh = 2*np.cos(theta) 93 | y_bh = 2*np.sin(theta) 94 | 95 | ax.fill(x_bh, y_bh, color = "k") 96 | 97 | obtaining a nice representation of the orbit: 98 | 99 | .. image:: _static/time_like/time_like_geodesic_example_1.png 100 | :width: 100% 101 | :alt: Transformed time-like geodesic visualization 102 | 103 | Of course, one can do the same with photons (null geodesics) and obtain a representation of a bundle of geodesics fired by an observer towards the black hole, assigning a color to the conditionally, given the ``exit`` state of the geodesic 104 | 105 | .. code-block:: 106 | 107 | import matplotlib as mpl 108 | import matplotlib.pylot as plt 109 | 110 | # Computing geodesics 111 | 112 | phi_arr = np.linspace(-np.pi/2, np.pi/2, 101) 113 | 114 | observer = pygro.Observer(metric, [0, 50, np.pi/2, 0], coframe = ["sqrt(A(r))*dt", "-dr/sqrt(A(r))", "-r*sin(theta)*dphi", "-r*dtheta"]) 115 | 116 | geo_arr = [] 117 | 118 | for phi in phi_arr: 119 | geo = pygro.Geodesic("null", geo_engine, verbose = False) 120 | geo.initial_x = observer.x 121 | geo.initial_u = observer.from_f1(0, phi, type = geo.type) 122 | 123 | geo_engine.integrate(geo, 1000, 1, verbose = False) 124 | 125 | geo_arr.append(geo) 126 | 127 | # Plotting result 128 | 129 | fig, ax = plt.subplots(figsize = (7, 4.5)) 130 | 131 | cmap = plt.get_cmap('coolwarm') 132 | norm = mpl.colors.Normalize(vmin=-np.pi/2, vmax=np.pi/2) 133 | mappable = mpl.cm.ScalarMappable(norm=norm, cmap=cmap) 134 | 135 | for geo, phi in zip(geo_arr, phi_arr): 136 | t, x, y, z = metric.transform(geo.x.T) 137 | ax.plot(x, y, color = mappable.to_rgba(phi) if geo.exit != "horizon" else "k", linewidth = 1) # Color is assigned conditionally depending on wheter the geodesic hit the horizon 138 | 139 | # Adding the black hole 140 | 141 | theta = np.linspace(0, 2*np.pi, 150) 142 | x_bh = 2*np.cos(theta) 143 | y_bh = 2*np.sin(theta) 144 | 145 | ax.fill(x_bh, y_bh, color = "k") 146 | 147 | ax.set_xlabel(r'$x$ ($M$)') 148 | ax.set_ylabel(r'$y$ ($M$)') 149 | 150 | which gives out the following representation 151 | 152 | .. image:: _static/visualize_example_3.png 153 | :width: 100% 154 | :alt: Null geodesics visualization -------------------------------------------------------------------------------- /docs/source/integrate_geodesic.rst: -------------------------------------------------------------------------------- 1 | Integrate a geodesic in PyGRO 2 | ============================= 3 | 4 | Integrating the geodesic equations related to a specific metric tensor is the main goal of PyGRO. The main tool for doing so is the :py:class:`.GeodesicEngine` class. It can be thought of as a *worker* which performs the integration for us, by combining all the information stored in an initialized :py:class:`.Metric` object and using them to integrate the orbit encoded in a :py:class:`.Geodesic` object with a given kind (either *time-like* or *null*) and initial conditions. 5 | 6 | .. image:: _static/PygroGeodesicEngine.png 7 | :width: 100% 8 | :alt: Geodesic Engine 9 | 10 | 11 | Given a generic spacetime described by the metric tensor :math:`g_{\mu\nu}`, the geodesic equations related to this metric read: 12 | 13 | .. math:: 14 | 15 | \ddot{x}^\mu = -\Gamma^{\mu}_{\nu\rho}\dot{x}^\nu\dot{x}^{\rho}. 16 | 17 | Here: a dot represents a derivation with respect to an affine parameter :math:`\lambda` by which the geodesic curve is parameterized (for the time-like geodesic case we assume that this affine parameter coincides with the proper time measured by the massive particle); we assume summation over repeated indices; the quantities :math:`\Gamma^{\mu}_{\nu\rho}` represent the Christoffel symbols related to the metric, defined by 18 | 19 | .. math:: 20 | 21 | \Gamma^{\mu}_{\nu\rho} = \frac{1}{2}g^{\mu\sigma}\left(\partial_\nu g_{\sigma \rho} + \partial_\mu g_{\nu \sigma}- \partial_\sigma g_{\nu\rho}\right). 22 | 23 | These equations are integrated numerically in PyGRO after initial conditions on both the space-time coordinates (``Geodesic.initial_x``), and initial tangent vector (``Geodesic.initial_u``) have been assigned. Morevoer, before integration, the user has to define whether the desired geodesic is time-like or null. In the following sections we illustrate how to perform these operations in PyGRO. 24 | 25 | The first thing to do is initialize of a geodesic engine. This is done by defining a :py:class:`.GeodesicEngine` object and passing to its constructor a initialzed :py:class:`.Metric` object. Additionally, one can pass other arguments to the ``GeodesicEngine`` constructor, namely: 26 | 27 | * A ``bool`` to the ``verbose`` argument (which sets whether the steps of the initialization should be printed to the standard output or not); 28 | * The ``backend`` argument whose value can be either ``"autowrap"`` or ``"lambdify"``. In the first case (which is what is set by default), PyGRO converts the call to a specific symbolic expression, in this case, the geodeisc equatioons, into a C-precompiled binary executable. On the other hand, when ``"lambdify"`` is set, PyGRO relies on the native-Python ``sympy`` method ``lambdify`` to perform calls to symbolic expressions. The former **drastically** improves the integration performances, but might not work on particular devices which have no ``gcc`` compiler installed (as for example the Jupyter development environment in `Juno `_, running on iOS) or when one relies on non-symbolic auxiliary functions to define the metric (see :doc:`create_metric`). 29 | * The ``integrator`` argument (default value is ``"dp45"``, correspongind to a Dormand-Prince 4-th order ODE integrator with adaptive step size) by which one can select the numerical integration algorithm that wants to employ (see :doc:`integrators` to see other alternatives). 30 | 31 | Assuming default settings are fine for our purposes, we can initialize the :py:class:`.GeodesicEngine` with: 32 | 33 | .. code-block:: 34 | 35 | geo_engine = pygro.GeodesicEngine(metric) 36 | 37 | which links to it the :py:class:`.Metric` object and thus making it an integrator for the geodesic equations related to that specific space-time. 38 | 39 | Dealing with horizons 40 | ----------------------------------------- 41 | 42 | Suppose that the :py:class:`.Metric` object that has been passed to the :py:class:`.GeodesicEngine` constructor is the one that we have initialized in the tutorial :doc:`create_metric`, i.e. the Schwarzschild space-time. As we know, the Schwarzschild coordinates present a coordinate singularity at :math:`r = 2M` which identifies the location of the event horizon of a black hole for an outside observer. Suppose now that we wish to integrate a geodesic that plunges into the black hole (examples of these cases will be shown in the next sections). Since the integration method uses the proper time of the geodesic to parameterize the space-time coordinates, the integration eventually reaches a point of stall where the increment on the proper time between two following steps tends to 0, due to the fact that the proper time of a particle stops at the event horizon. Accordingly, our integration will never reach an end. This happens because in the :py:meth:`~pygro.GeodesicEngine.integrate` method the argument ``tauf`` sets the value of the proper time at which ending the integration. In order to avoid the unconvenient situation in which we have to manually stop the integration, a :py:class:`.StoppingCriterion` can be defined which allows the integration to be stopped automatically when a certain condition is not satisfied. In particular, for the case of the event horizon of a Schwarzschild black hole, we could set the criterion to be that the radial coordinate of the geodesic should always be greater than a value that is a small fraction above the horizon. When this is no longer true, the integration will be stopped and give us a chosen ``exit``. This can be easily done with: 43 | 44 | .. code-block:: 45 | 46 | geo_engine.set_stopping_criterion("r > 2.00001*M", "horizon") 47 | 48 | Here, we have used the :py:meth:`~pygro.GeodesicEngine.set_stopping_criterion` method which accepts as a first argument the symbolic expression of the condition that has to be continuously checked during the integration and as a second argument a string that represent the ``Geodesic.exit`` when the condition is not satisfied. 49 | 50 | .. note:: 51 | By default, a successful integration that is completed up to the end point specified in the ``GeodesicEngine.integrate()`` method gives a ``Geodesic.exit == "done"``. 52 | 53 | Integrating time-like and null geodesics 54 | ----------------------------------------- 55 | 56 | We can now proceed to the numerical integration of actual geodesics. 57 | 58 | .. toctree:: 59 | 60 | geodesics/null_geodesic 61 | geodesics/timelike_geodesic -------------------------------------------------------------------------------- /docs/source/examples/orbital_fitting/S2_MCMC.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import numpy as np 3 | import pygro 4 | import emcee 5 | from scipy.interpolate import interp1d 6 | from astropy import constants, units 7 | 8 | # CONSTANTS 9 | 10 | G_N = constants.G.to('au**3/(Msun*s**2)').value 11 | c = constants.c.to('au/s').value 12 | Msun = units.Msun.to('kg')*units.kg 13 | 14 | # OBSERVATIONAL DATA 15 | 16 | # We use pandas to import observational data from Gillessen et al. (2017) 17 | # https://arxiv.org/abs/1611.09144 18 | 19 | pos = pd.read_csv("data/tab_gillessen_pos.csv", names = ["t", "alpha", "alpha_err", "delta", "delta_err"]) 20 | rv = pd.read_csv("data/tab_gillessen_vr.csv", names = ["t", "v_los", "vlos_err"]) 21 | 22 | t_pos = pos.t.values 23 | 24 | alpha_obs = pos.alpha.values/1000 25 | delta_obs = pos.delta.values/1000 26 | 27 | alpha_err = pos.alpha_err.values/1000 28 | delta_err = pos.delta_err.values/1000 29 | 30 | t_rv = rv.t.values 31 | vlos_obs = rv.v_los.values 32 | vlos_err = rv.vlos_err.values 33 | 34 | # ORBITAL MODEL 35 | 36 | # Orbital parameters for S2 from Gillessen et al 2017 37 | # https://arxiv.org/abs/1611.09144 38 | 39 | M = 4.35 40 | D = 8.33 41 | t_p = 2002.33 42 | a = 0.1255 43 | e = 0.8839 44 | inc = np.deg2rad(134.18) 45 | Omega = np.deg2rad(226.94) 46 | omega = np.deg2rad(65.51) 47 | 48 | # Reference frame priors from Plewa et al. (2015) to take into account zero-point offset and drift of the astrometric reference frame 49 | 50 | x0 = 0 51 | y0 = 0 52 | vx0 = 0 53 | vy0 = 0 54 | vLSR = 0 55 | 56 | # Starting values of the parameters for the MCMC 57 | # the walkers than explore the parameter space thanks to the flat priors 58 | 59 | start_params = np.array([D, M, t_p, a, e, inc, Omega, omega, x0, y0, vx0, vy0, vLSR]) 60 | 61 | # Sigmas for defining flat priors (and for the samll ball in the parameter space in which we initialize the walkers) 62 | 63 | delta_params = np.array([0.2, 0.02, 0.03, 0.0009, 0.0019, np.deg2rad(0.40), np.deg2rad(0.60), np.deg2rad(0.57), 0.0002, 0.0002, 0.0001, 0.0001, 5]) 64 | 65 | start_flat = start_params-6*delta_params 66 | end_flat = start_params+6*delta_params 67 | 68 | # Defining the PyGRO Metric as shown in: 69 | # https://rdellamonica.github.io/pygro/create_metric.html 70 | 71 | name = "Schwarzschild spacetime" 72 | coordinates = ["t", "r", "theta", "phi"] 73 | 74 | transform_functions = [ 75 | "t", 76 | "r*sin(theta)*cos(phi)", 77 | "r*sin(theta)*sin(phi)", 78 | "r*cos(theta)" 79 | ] 80 | 81 | line_element = "-(1-2*M/r)*dt**2+1/(1-2*M/r)*dr**2+(r**2)*(dtheta**2+sin(theta)**2*dphi**2)" 82 | 83 | metric = pygro.Metric( 84 | name = name, 85 | coordinates = coordinates, 86 | line_element = line_element, 87 | transform = transform_functions, 88 | M = 1, 89 | ) 90 | 91 | # Defining the PyGRO Geodesic Engine and setting a stopping criterion as shown in: 92 | # https://rdellamonica.github.io/pygro/integrate_geodesic.html 93 | 94 | geo_engine = pygro.GeodesicEngine(integrator="dp45") 95 | geo_engine.set_stopping_criterion("r > 2.001*M", "horizon") 96 | 97 | def get_observables(params): 98 | # Incoming set of parameters from the Markov Chain 99 | 100 | D, M, t_p, a, e, inc, Omega, omega, xS0, yS0, vxS0, vyS0, v_LSR = params 101 | 102 | # Transforming the distance of SgrA* and the semi-major axis in AU 103 | r_G = (constants.G*M*1e+6*Msun/constants.c**2).to('au').value 104 | 105 | D_s = D*units.kpc.to('au') 106 | a_sma = a*units.arcsec.to('rad')*D_s/r_G 107 | 108 | # Defining keplerian orbital period to have a temporal scale to carry on the integration 109 | T = np.sqrt(4*np.pi**2*a_sma**3) 110 | 111 | # Definition of the Orbit object as shown in: 112 | # https://rdellamonica.github.io/pygro/integrating_orbits.html 113 | # We use two different geodesics, one for the forward integration form the 2002 pericenter, 114 | # the other for the backward integration 115 | 116 | orbit_bw = pygro.Orbit(geo_engine=geo_engine ,verbose=False) 117 | orbit_bw.set_orbital_parameters(t_P=0, a=a_sma, e=e, i=inc, omega=omega, Omega=Omega) 118 | orbit_bw.integrate(2*T, 1, accuracy_goal = 15, precision_goal = 15, direction="bw") 119 | 120 | orbit_fw = pygro.Orbit(geo_engine=geo_engine, verbose=False) 121 | orbit_fw.set_orbital_parameters(t_P=0, a=a_sma, e=e, i=inc, omega=omega, Omega=Omega) 122 | orbit_fw.integrate(2*T, 1, accuracy_goal = 15, precision_goal = 15, direction="fw") 123 | 124 | # Function to convert orbit to observable quantities 125 | def orbit_to_observables(orbit): 126 | t, x, y, z = metric.transform(orbit.geo.x.T) 127 | 128 | # Converting time to years 129 | t_em = t_p+t*r_G/constants.c.to('au/yr').value 130 | 131 | # Rømer delay 132 | t_obs = t_em+z*r_G/constants.c.to('au/yr').value 133 | 134 | # Astrometric observations 135 | alpha = y*r_G/D_s*units.rad.to('arcsec') 136 | delta = x*r_G/D_s*units.rad.to('arcsec') 137 | 138 | # Line of sight velocity 139 | v_z = orbit.geo.u[:,1]*np.cos(orbit.geo.x[:,2])-orbit.geo.x[:,1]*orbit.geo.u[:,2]*np.sin(orbit.geo.x[:,2]) 140 | longitudinal_redshift = v_z 141 | 142 | # Relativistic redshift 143 | einstein_redshift = orbit.geo.u[:,0]-1 144 | 145 | # Total line of sight velocity 146 | redshift = (longitudinal_redshift+1)*(einstein_redshift+1)-1 147 | v_los = redshift*constants.c.to('km/s') 148 | 149 | return t_obs, alpha, delta, v_los 150 | 151 | # Merging of the forward and backward geodesics 152 | t_obs_bw, alpha_bw, delta_bw, v_los_bw = orbit_to_observables(orbit_bw) 153 | t_obs_fw, alpha_fw, delta_fw, v_los_fw = orbit_to_observables(orbit_fw) 154 | 155 | t_obs = np.hstack([np.flip(t_obs_bw), t_obs_fw]) 156 | alpha = np.hstack([np.flip(alpha_bw), alpha_fw]) 157 | delta = np.hstack([np.flip(delta_bw), delta_fw]) 158 | v_los = np.hstack([np.flip(v_los_bw), v_los_fw]) 159 | 160 | # Interpolation of the integrated orbit. Using cubic spiline which is a good interpolator for DormandPrince5(4) integrators 161 | alpha_int = interp1d(t_obs, alpha) 162 | delta_int = interp1d(t_obs, delta) 163 | v_los_int = interp1d(t_obs, v_los) 164 | 165 | # Final determination of the orbit at the observation epochs + zero-point offset and drift of the reference frame 166 | alpha = alpha_int(t_pos)+xS0+vxS0*(t_pos-2009.2) 167 | delta = delta_int(t_pos)+yS0+vyS0*(t_pos-2009.2) 168 | v_los = v_los_int(t_rv)-v_LSR 169 | 170 | return alpha, delta, v_los 171 | 172 | # FITTING 173 | 174 | # Gaussian priors log-likelihood 175 | def logprob_prior_gauss(param, start, delta): 176 | return -(param-start)**2/(2*delta**2) 177 | 178 | # Flat priors log-likelihood 179 | def logprob_prior_flat(param, start, end): 180 | if start < param < end: 181 | return 0.0 182 | return -np.inf 183 | 184 | # Conmplete set of priors 185 | def log_prior(params): 186 | prior = 0 187 | 188 | for i, param in enumerate(params): 189 | if i in range(8,13): 190 | # Gaussian priors on reference frame parameters 191 | prior += logprob_prior_gauss(param, start_params[i], delta_params[i]) 192 | else: 193 | # Flat priors on all other parameters 194 | prior += logprob_prior_flat(param, start_flat[i], end_flat[i]) 195 | 196 | return prior 197 | 198 | # Computing the likelihood of a given set of parameter 199 | def log_likelihood(params): 200 | # Orbital model 201 | alpha, delta, v_los = get_observables(params) 202 | 203 | # Cmparison with data 204 | likelihood = np.linalg.norm((alpha-alpha_obs)/alpha_err)**2/2 205 | likelihood += np.linalg.norm((delta-delta_obs)/delta_err)**2/2 206 | likelihood += np.linalg.norm((v_los-vlos_obs)/vlos_err)**2/2 207 | 208 | return -likelihood 209 | 210 | # Total log-likelihood 211 | def log_posterior(params): 212 | 213 | prior = log_prior(params) 214 | 215 | if not np.isfinite(prior): 216 | return -np.inf 217 | 218 | likelihood = log_likelihood(params) 219 | 220 | return prior+likelihood 221 | 222 | # RUNNING MCMC 223 | 224 | # Setting MCMC parameters 225 | nwalkers = 32 226 | ndim = len(start_flat) 227 | max_n = 300000 228 | 229 | # Generating random initial positions for walkers based on the priors 230 | start = np.zeros((nwalkers, ndim)) 231 | 232 | for i in range(ndim): 233 | start[:, i] = np.random.uniform(start_flat[i], end_flat[i], nwalkers) 234 | 235 | # Running the MCMC using emcee 236 | # Here one can set the properties of the sampler, implement convergence criteria and save the results of the emcee sampling. 237 | # See the official documentation: https://emcee.readthedocs.io/en/stable/ 238 | 239 | if __name__ == "__main__": 240 | sampler = emcee.EnsembleSampler(nwalkers, ndim, log_posterior) 241 | 242 | sampler.run_mcmc(start, max_n) -------------------------------------------------------------------------------- /pygro/observer.py: -------------------------------------------------------------------------------- 1 | import sympy as sp 2 | import numpy as np 3 | 4 | from typing import Sequence, Union, Optional 5 | from pygro.metric_engine import Metric 6 | from pygro.geodesic import _GEODESIC_TYPE, _VALID_GEODESIC_TYPE 7 | 8 | import logging 9 | 10 | class Observer: 11 | ''' 12 | The :py:class:`Observer` class represent PyGRO representation of physical observers in space-time. It is built by specifying either a frame or a co-frame uniquely identifying a tetrad in space-time. This can than be used to fire geodesics from the observer's position giving a physical meaning to the intial values of the 4-velocity components for the integrated geodesic. 13 | ''' 14 | def __init__(self, metric: Optional[Metric], x: Sequence[Union[int, float]], frame: Optional[list[str]] = None, coframe: Optional[list[str]] = None): 15 | r''' 16 | Initializes the :py:class:`Observer` class. Accepts the following arguments: 17 | 18 | :param metric: The :py:class:`.Metric` object to link to the :py:class:`GeodesicEngine` and from which the geodesic equations and all the related symbolic quantities are retrieved. If not provided, the :py:class:`GeodesicEngine` will be linked to the last initialized :py:class:`.Metric`. 19 | :type metric: Metric 20 | :param x: The space-time position of the observer (must be a 4-dimensional array of numbers). 21 | :type x: Sequence[Union[int, float]] 22 | :param frame/coframe: The symbolic definitions of either the observer's frame or coframe in the tetrad formalism (accepts a list of four strings, one for each tetrad). See the tutorial :doc:`define_observer` for more details. 23 | :type frame/coframe: list[str] 24 | 25 | ''' 26 | if not isinstance(x, (list, tuple, np.ndarray)): 27 | raise TypeError("'x''must be a sequence (list, tuple or numpy.ndarray).") 28 | if len(x) != 4: 29 | raise ValueError("'x' must contain exactly four elements.") 30 | 31 | if metric == None: 32 | if len(Metric.instances) > 0: 33 | if isinstance(Metric.instances[-1], Metric): 34 | metric = Metric.instances[-1] 35 | logging.warning(f"No Metric object passed to the Geodesic Engine constructor. Using last initialized Metric ({metric.name}) instead.") 36 | else: 37 | raise ValueError("No Metric found, initialize one and pass it as argument to the GeodesicEngine constructor.") 38 | 39 | self.metric = metric 40 | self.x = np.array(x) 41 | 42 | check = sum(f is not None for f in [frame, coframe]) 43 | 44 | if not check == 1: 45 | raise ValueError('You must specify one (and only one) between frame and coframe for the observer.') 46 | 47 | if frame is not None: 48 | self._get_frame(frame) 49 | if coframe is not None: 50 | self._get_coframe(coframe) 51 | 52 | def _get_coframe(self, coframe: list[str]): 53 | 54 | if len(coframe) != 4: 55 | raise ValueError('coframe should be a 4-dimensional list of strings') 56 | 57 | coframe_symb = [] 58 | self.coframe_matrix = sp.zeros(4, 4) 59 | 60 | try: 61 | for coframe_i in coframe: 62 | coframe_symb.append(sp.expand(sp.parse_expr(coframe_i))) 63 | except: 64 | raise ValueError("Please insert a valid expression for the coframes components.") 65 | else: 66 | for i, coframe_i in enumerate(coframe_symb): 67 | for j, dx in enumerate(self.metric.dx): 68 | self.coframe_matrix[i, j] = coframe_i.coeff(dx,1) 69 | 70 | self.frame_matrix = self.coframe_matrix.inv().T 71 | 72 | def _get_frame(self, frame : list[str]): 73 | if len(frame) != 4: 74 | raise ValueError('frame should be a 4-dimensional list of strings') 75 | 76 | frame_symb = [] 77 | self.frame_matrix = sp.zeros(4, 4) 78 | 79 | try: 80 | for frame_i in frame: 81 | frame_symb.append(sp.expand(sp.parse_expr(frame_i))) 82 | except: 83 | raise ValueError("Please insert a valid expression for the frames components.") 84 | else: 85 | for i, frame_i in enumerate(frame_symb): 86 | for j, x in enumerate(self.metric.x): 87 | self.frame_matrix[i, j] = frame_i.coeff(sp.symbols("e"+str(x)),1) 88 | 89 | self.coframe_matrix = self.frame_matrix.inv().T 90 | 91 | def convert_3vector(self, vector: Sequence[Union[int, float]], type: _GEODESIC_TYPE): 92 | r''' 93 | Converts a 3-vector in the reference frame of the :py:class:`Observer` into a 4-vector in space-time. The user must specify the components of the 3-vector ``v`` and the desired 4-vector ``type`` that can either be ``time-like`` or ``geodesic``. 94 | 95 | :param vector: A 3-dimensional sequence of numbers corresponding to the components of the 3-vector in the Observer's reference frame. 96 | :type vector: Sequence[Union[int, float]] 97 | :param type: The vector normalization. It accepts strings with either ``"time-like"`` (:math:`g_{\mu\nu}u^\mu u^\nu = -1`) or ``"null"`` (:math:`g_{\mu\nu}u^\mu u^\nu = 0`). 98 | :type type: Literal['time-like', 'null'] 99 | 100 | ''' 101 | if not isinstance(vector, (list, tuple, np.ndarray)): 102 | raise TypeError("'x''must be a sequence (list, tuple or numpy.ndarray).") 103 | if len(vector) != 3: 104 | raise ValueError("'vector' must be a 3-vector, containing exactly three elements.") 105 | 106 | if type not in _VALID_GEODESIC_TYPE: 107 | raise TypeError("Vector type must either be 'null' or 'time-like'.") 108 | 109 | u_123 = sp.lambdify([*self.metric.x, *self.metric.get_parameters_symb()], self.metric.subs_functions(self.frame_matrix[1:, 1:]))(*self.x, *self.metric.get_parameters_val())@np.array(vector) 110 | 111 | if type == "time-like": 112 | u0 = abs(sp.lambdify([*self.metric.x, self.metric.u[1], self.metric.u[2], self.metric.u[3], *self.metric.get_parameters_symb()], self.metric.subs_functions(self.metric.u0_s_timelike))(*self.x, *u_123, *self.metric.get_parameters_val())) 113 | elif type == "null": 114 | u0 = abs(sp.lambdify([*self.metric.x, self.metric.u[1], self.metric.u[2], self.metric.u[3], *self.metric.get_parameters_symb()], self.metric.subs_functions(self.metric.u0_s_null))(*self.x, *u_123, *self.metric.get_parameters_val())) 115 | 116 | return np.append(u0, u_123) 117 | 118 | def _from_frame_vector(self, theta_obs: float, phi_obs: float, type = _GEODESIC_TYPE, v: Optional[float] = None) -> Sequence[float]: 119 | 120 | if not isinstance(theta_obs, (float, int)) or not isinstance(phi_obs, (float, int)): 121 | raise TypeError("Angles theta and phi must be numbers.") 122 | 123 | if type == "time-like": 124 | if v is None: 125 | raise ValueError("You must set the modulus 'v' of the 3-velocity in the Observer reference frame for a time-like vector") 126 | if v < 0: 127 | raise ValueError("3-velocity must be positive") 128 | else: 129 | v = 1 130 | 131 | x_obs: float = v*np.cos(theta_obs)*np.cos(phi_obs) 132 | y_obs: float = v*np.cos(theta_obs)*np.sin(phi_obs) 133 | z_obs: float = v*np.sin(theta_obs) 134 | 135 | return x_obs, y_obs, z_obs 136 | 137 | def from_f1(self, theta_obs: float, phi_obs: float, type = _GEODESIC_TYPE, v: Optional[float] = None): 138 | r''' 139 | Returns a 4-vector corresponding to the initial 4-velocity for a time-like or null geodesic (depending on the ``type`` argument) fired with angles ``theta_obs`` and ``phi_obs`` from the :math:`f_1` vector. See :doc:`define_observer` for an illustrative example of this functionality. 140 | 141 | :param theta_obs: The angle :math:`\theta_{\rm obs}` in the local observer's frame. 142 | :type theta_obs: float 143 | :param phi_obs: The angle :math:`\phi_{\rm obs}` in the local observer's frame. 144 | :type phi_obs: float 145 | :param type: The vector normalization. It accepts strings with either ``"time-like"`` (:math:`g_{\mu\nu}u^\mu u^\nu = -1`) or ``"null"`` (:math:`g_{\mu\nu}u^\mu u^\nu = 0`). 146 | :type type: Literal['time-like', 'null'] 147 | :param v: The modulus of the spatial velocity of the geodesic. To be specified in the ``time-like case.`` 148 | :type v: float 149 | ''' 150 | x, y, z = self._from_frame_vector(theta_obs, phi_obs, type, v) 151 | return self.convert_3vector([x, y, z], type) 152 | 153 | def from_f2(self, theta_obs: float, phi_obs: float, type = _GEODESIC_TYPE, v: Optional[float] = None): 154 | r''' 155 | As :py:meth:`~.pygro.observer.Observer.from_f1` method, but for the vector :math:`f_2`. 156 | ''' 157 | x, y, z = self._from_frame_vector(theta_obs, phi_obs, type, v) 158 | return self.convert_3vector([z, x, y], type) 159 | 160 | def from_f3(self, theta_obs: float, phi_obs: float, type = _GEODESIC_TYPE, v: Optional[float] = None): 161 | r''' 162 | As :py:meth:`~.pygro.observer.Observer.from_f1` method, but for the vector :math:`f_3`. 163 | ''' 164 | x, y, z = self._from_frame_vector(theta_obs, phi_obs, type, v) 165 | return self.convert_3vector([z, y, x], type) -------------------------------------------------------------------------------- /pygro/geodesic.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, Optional, Tuple, get_args, Sequence, Union 2 | import numpy as np 3 | from pygro import GeodesicEngine 4 | 5 | import logging 6 | 7 | _GEODESIC_TYPE = Literal['time-like', 'null'] 8 | _VALID_GEODESIC_TYPE: Tuple[_GEODESIC_TYPE, ...] = get_args(_GEODESIC_TYPE) 9 | 10 | class Geodesic(): 11 | r""" 12 | The ``pygro`` representation of a geodesic. It is used to define the geodesic type (either time-like or null), initial data and stores the results of the numerical integration, perfromed by the :py:class:`.GeodesicEngine`. 13 | 14 | See :doc:`integrate_geodesic` for more details on the actual integration procedure. 15 | 16 | :ivar initial_x: The starting position of the geodesic. It is a 4-dimensional `numpy.ndarray` that can be set using the helper function :py:func:`set_starting_position`. 17 | :vartype initial_x: np.ndarray 18 | :ivar initial_u: The starting 4-velocity of the geodesic. It is a 4-dimensional `numpy.ndarray` that can be set using the helper function :py:func:`set_starting_4velocity` which enforces the appropriate normalization condition depending on the geodesic type. 19 | :vartype initial_u: np.ndarray 20 | """ 21 | def __init__(self, type: _GEODESIC_TYPE, engine: Optional[GeodesicEngine] = None, verbose: bool = True): 22 | r""" 23 | The :py:class:`Geodesic` constructor accepts the following arguments: 24 | 25 | :param type: The geodesic normalization. It accepts strings with either ``"time-like"`` (:math:`g_{\mu\nu}\dot{x}^\mu\dot{x}^\nu = -1`) or ``"null"`` (:math:`g_{\mu\nu}\dot{x}^\mu\dot{x}^\nu = 0`). 26 | :type type: Literal['time-like', 'null'] 27 | :param engine: The :py:class:`.GeodesicEngine` object to link to the :py:class:`Geodesic`. If not provided, the :py:class:`Geodesic` will be linked to the last initialized :py:class:`.GeodesicEngine`. 28 | :type engine: Optional[GeodesicEngine] 29 | :param verbose: Specifies whether to log information on the geodesic status to the standard output. 30 | :type verbose: bool 31 | """ 32 | if type not in _VALID_GEODESIC_TYPE: 33 | raise TypeError("Geodesic must either be 'null' or 'time-like'.") 34 | 35 | if not engine: 36 | engine = GeodesicEngine.instances[-1] 37 | logging.warning(f"No GeodesicEngine object passed to the Geodesic constructor. Using last initialized GeodesicEngine instead") 38 | 39 | if not isinstance(engine, GeodesicEngine): 40 | raise TypeError("No GeodesicEngine found, initialize one and pass it as argument to the Geodesic constructor") 41 | 42 | self.engine = engine 43 | self.metric = engine.metric 44 | 45 | self._type = type 46 | self._verbose = verbose 47 | 48 | self._initial_x = None 49 | self._initial_u = None 50 | 51 | self._tau = np.empty(0) 52 | self._x = np.empty((0,4)) 53 | self._u = np.empty((0,4)) 54 | 55 | @property 56 | def type(self) -> _GEODESIC_TYPE: 57 | return self._type 58 | 59 | @property 60 | def initial_x(self) -> np.ndarray: 61 | return self._initial_x 62 | 63 | @property 64 | def initial_u(self) -> np.ndarray: 65 | return self._initial_u 66 | 67 | @property 68 | def x(self) -> np.ndarray: 69 | return self._x 70 | 71 | def x_int(self, tau: float): 72 | return self.interpolator(tau)[:,:4] 73 | 74 | @property 75 | def u(self) -> np.ndarray: 76 | return self._u 77 | 78 | def u_int(self, tau: float): 79 | return self.interpolator(tau)[:,4:] 80 | 81 | @property 82 | def tau(self) -> np.ndarray: 83 | return self._tau 84 | 85 | @initial_x.setter 86 | def initial_x(self, initial_x: Sequence[Union[int, float]]): 87 | if not isinstance(initial_x, (list, tuple, np.ndarray)): 88 | raise TypeError("initial_x must be a sequence (list or tuple).") 89 | if len(initial_x) != 4: 90 | raise ValueError("initial_x must contain exactly four elements.") 91 | if not all(isinstance(num, (int, float)) for num in initial_x): 92 | raise TypeError("All elements in initial_x must be numbers (int or float).") 93 | 94 | self._initial_x = np.array(initial_x) 95 | self._tau = np.empty(0) 96 | self._x = np.empty((0,4)) 97 | self._u = np.empty((0,4)) 98 | 99 | @initial_u.setter 100 | def initial_u(self, initial_u: Sequence[Union[int, float]]): 101 | if not isinstance(initial_u, (list, tuple, np.ndarray)): 102 | raise TypeError("initial_u must be a sequence (list or tuple).") 103 | if len(initial_u) != 4: 104 | raise ValueError("initial_u must contain exactly four elements.") 105 | if not all(isinstance(num, (int, float)) for num in initial_u): 106 | raise TypeError("All elements in initial_u must be numbers (int or float).") 107 | 108 | if self.initial_x is None: 109 | raise ValueError("You must use set_starting_point before initializing the 4-velocity.") 110 | 111 | self._tau = np.empty(0) 112 | self._x = np.empty((0,4)) 113 | self._u = np.empty((0,4)) 114 | self._initial_u = np.array(initial_u) 115 | 116 | @x.setter 117 | def x(self, value: np.ndarray) -> None: 118 | if not isinstance(value, np.ndarray): 119 | raise TypeError("x must be a numpy array.") 120 | if value.ndim != 2 or value.shape[1] != 4: 121 | raise ValueError("x array must have shape (N, 4), where N can be any number.") 122 | self._x = value 123 | 124 | @u.setter 125 | def u(self, value: np.ndarray) -> None: 126 | if not isinstance(value, np.ndarray): 127 | raise TypeError("u must be a numpy array.") 128 | if value.ndim != 2 or value.shape[1] != 4: 129 | raise ValueError("u array must have shape (N, 4), where N can be any number.") 130 | self._u = value 131 | 132 | @tau.setter 133 | def tau(self, value: np.ndarray) -> None: 134 | if not isinstance(value, np.ndarray): 135 | raise TypeError("u must be a numpy array.") 136 | if value.ndim != 1: 137 | raise ValueError("u array must be a one-dimensional array") 138 | self._tau = value 139 | 140 | def set_starting_point(self, x0: Union[float, int], x1: Union[float, int], x2: Union[float, int], x3: Union[float, int]): 141 | r""" 142 | Sets the initial values of the space-time coordinates from which to start the integration. 143 | """ 144 | if self._verbose: 145 | logging.info("Setting starting point") 146 | self.initial_x = np.array([x0, x1, x2, x3]) 147 | 148 | def get_initial_u0(self, u1, u2, u3): 149 | if self._type == "null": 150 | u_sol = self.engine.u0_f_null(self.initial_x, u1, u2, u3) 151 | elif self._type == "time-like": 152 | u_sol = self.engine.u0_f_timelike(self.initial_x, u1, u2, u3) 153 | 154 | if np.isnan(u_sol): 155 | raise ValueError("The 4-velocity cannot be normalized, returned NaN") 156 | 157 | return u_sol 158 | 159 | def get_initial_u1(self, u0, u2, u3): 160 | if self._type == "null": 161 | u_sol = self.engine.u1_f_null(self.initial_x, u0, u2, u3) 162 | elif self._type == "time-like": 163 | u_sol = self.engine.u1_f_timelike(self.initial_x, u0, u2, u3) 164 | 165 | if np.isnan(u_sol): 166 | raise ValueError("The 4-velocity cannot be normalized, returned NaN") 167 | 168 | return u_sol 169 | 170 | def get_initial_u2(self, u0, u1, u3): 171 | if self._type == "null": 172 | u_sol = self.engine.u2_f_null(self.initial_x, u0, u1, u3) 173 | elif self._type == "time-like": 174 | u_sol = self.engine.u2_f_timelike(self.initial_x, u0, u1, u3) 175 | 176 | if np.isnan(u_sol): 177 | raise ValueError("The 4-velocity cannot be normalized, returned NaN") 178 | 179 | return u_sol 180 | 181 | def get_initial_u3(self, u0, u1, u2): 182 | if self._type == "null": 183 | u_sol = self.engine.u3_f_null(self.initial_x, u0, u1, u2) 184 | elif self._type == "time-like": 185 | u_sol = self.engine.u3_f_timelike(self.initial_x, u0, u1, u2) 186 | 187 | if np.isnan(u_sol): 188 | raise ValueError("The 4-velocity cannot be normalized, returned NaN") 189 | 190 | return u_sol 191 | 192 | def set_starting_4velocity(self, u0: Optional[float] = None, u1: Optional[float] = None, u2: Optional[float] = None, u3: Optional[float] = None): 193 | r""" 194 | Sets the initial values of the components of the 4-velocity, enforcing the normalization conditions. For this reason, only three out of the four components ``u[i]`` of the 4-velocity must be specified, and the remaining one will be automatically computed to satistfy the normalization condition. 195 | 196 | To override this behaviour, you can directly set the ``.initial_u`` property of the :py:class:`Geodesic`. 197 | """ 198 | check = sum(u is not None for u in [u0, u1, u2, u3]) 199 | 200 | if not check == 3: 201 | raise ValueError("Specify three components of the initial 4-velocity.") 202 | 203 | if u0 is None: 204 | u0 = self.get_initial_u0(u1, u2, u3) 205 | if u1 is None: 206 | u1 = self.get_initial_u1(u0, u2, u3) 207 | if u2 is None: 208 | u2 = self.get_initial_u2(u0, u1, u3) 209 | if u3 is None: 210 | u3 = self.get_initial_u3(u0, u1, u2) 211 | if self._verbose: 212 | logging.info("Setting initial 4-velocity.") 213 | self.initial_u = np.array([u0, u1, u2, u3]) -------------------------------------------------------------------------------- /pygro/default_metrics/Kerr-BL.metric: -------------------------------------------------------------------------------- 1 | {"name": "Kerr - Boyer-Lindquist", "g": [["-(1-2*m*r/(r**2+a**2*cos(theta)**2))", "0", "0", "-(2*m*r*a*sin(theta)**2)/(r**2+a**2*cos(theta)**2)"], ["0", "(r**2+a**2*cos(theta)**2)/(r**2-2*m*r+a**2)", "0", "0"], ["0", "0", "(r**2+a**2*cos(theta)**2)", "0"], ["-(2*m*r*a*sin(theta)**2)/(r**2+a**2*cos(theta)**2)", "0", "0", "(r**2+a**2+(2*m*r*a**2*sin(theta)**2)/(r**2+a**2*cos(theta)**2))*sin(theta)**2"]], "x": ["t", "r", "theta", "phi"], "g_inv": [["(-a**4*cos(theta)**2 - 2*a**2*m*r*sin(theta)**2 - a**2*r**2*cos(theta)**2 - a**2*r**2 - r**4)/(a**4*cos(theta)**2 + 2*a**2*m*r*sin(theta)**2 - 2*a**2*m*r + a**2*r**2*cos(theta)**2 + a**2*r**2 - 2*m*r**3 + r**4)", "0", "0", "-2*a*m*r/(a**4*cos(theta)**2 + 2*a**2*m*r*sin(theta)**2 - 2*a**2*m*r + a**2*r**2*cos(theta)**2 + a**2*r**2 - 2*m*r**3 + r**4)"], ["0", "(a**2 - 2*m*r + r**2)/(a**2*cos(theta)**2 + r**2)", "0", "0"], ["0", "0", "1/(a**2*cos(theta)**2 + r**2)", "0"], ["-2*a*m*r/(a**4*cos(theta)**2 + 2*a**2*m*r*sin(theta)**2 - 2*a**2*m*r + a**2*r**2*cos(theta)**2 + a**2*r**2 - 2*m*r**3 + r**4)", "0", "0", "(a**2*cos(theta)**2 - 2*m*r + r**2)/(a**4*sin(theta)**2*cos(theta)**2 + 2*a**2*m*r*sin(theta)**4 - 2*a**2*m*r*sin(theta)**2 + a**2*r**2*sin(theta)**2*cos(theta)**2 + a**2*r**2*sin(theta)**2 - 2*m*r**3*sin(theta)**2 + r**4*sin(theta)**2)"]], "eq_x": ["ut", "ur", "utheta", "uphi"], "eq_u": ["2*uphi*ur*(a*m*r*(-4*a**2*m*r**2*sin(theta)**2/(a**2*cos(theta)**2 + r**2)**2 + 2*a**2*m*sin(theta)**2/(a**2*cos(theta)**2 + r**2) + 2*r)*sin(theta)**2/(a**4*cos(theta)**2 + 2*a**2*m*r*sin(theta)**2 - 2*a**2*m*r + a**2*r**2*cos(theta)**2 + a**2*r**2 - 2*m*r**3 + r**4) - (4*a*m*r**2*sin(theta)**2/(a**2*cos(theta)**2 + r**2)**2 - 2*a*m*sin(theta)**2/(a**2*cos(theta)**2 + r**2))*(-a**4*cos(theta)**2 - 2*a**2*m*r*sin(theta)**2 - a**2*r**2*cos(theta)**2 - a**2*r**2 - r**4)/(2*(a**4*cos(theta)**2 + 2*a**2*m*r*sin(theta)**2 - 2*a**2*m*r + a**2*r**2*cos(theta)**2 + a**2*r**2 - 2*m*r**3 + r**4))) + 2*uphi*utheta*(a*m*r*((4*a**4*m*r*sin(theta)**3*cos(theta)/(a**2*cos(theta)**2 + r**2)**2 + 4*a**2*m*r*sin(theta)*cos(theta)/(a**2*cos(theta)**2 + r**2))*sin(theta)**2 + 2*(2*a**2*m*r*sin(theta)**2/(a**2*cos(theta)**2 + r**2) + a**2 + r**2)*sin(theta)*cos(theta))/(a**4*cos(theta)**2 + 2*a**2*m*r*sin(theta)**2 - 2*a**2*m*r + a**2*r**2*cos(theta)**2 + a**2*r**2 - 2*m*r**3 + r**4) - (-4*a**3*m*r*sin(theta)**3*cos(theta)/(a**2*cos(theta)**2 + r**2)**2 - 4*a*m*r*sin(theta)*cos(theta)/(a**2*cos(theta)**2 + r**2))*(-a**4*cos(theta)**2 - 2*a**2*m*r*sin(theta)**2 - a**2*r**2*cos(theta)**2 - a**2*r**2 - r**4)/(2*(a**4*cos(theta)**2 + 2*a**2*m*r*sin(theta)**2 - 2*a**2*m*r + a**2*r**2*cos(theta)**2 + a**2*r**2 - 2*m*r**3 + r**4))) + 2*ur*ut*(a*m*r*(4*a*m*r**2*sin(theta)**2/(a**2*cos(theta)**2 + r**2)**2 - 2*a*m*sin(theta)**2/(a**2*cos(theta)**2 + r**2))/(a**4*cos(theta)**2 + 2*a**2*m*r*sin(theta)**2 - 2*a**2*m*r + a**2*r**2*cos(theta)**2 + a**2*r**2 - 2*m*r**3 + r**4) - (-4*m*r**2/(a**2*cos(theta)**2 + r**2)**2 + 2*m/(a**2*cos(theta)**2 + r**2))*(-a**4*cos(theta)**2 - 2*a**2*m*r*sin(theta)**2 - a**2*r**2*cos(theta)**2 - a**2*r**2 - r**4)/(2*(a**4*cos(theta)**2 + 2*a**2*m*r*sin(theta)**2 - 2*a**2*m*r + a**2*r**2*cos(theta)**2 + a**2*r**2 - 2*m*r**3 + r**4))) + 2*ut*utheta*(-2*a**2*m*r*(-a**4*cos(theta)**2 - 2*a**2*m*r*sin(theta)**2 - a**2*r**2*cos(theta)**2 - a**2*r**2 - r**4)*sin(theta)*cos(theta)/((a**2*cos(theta)**2 + r**2)**2*(a**4*cos(theta)**2 + 2*a**2*m*r*sin(theta)**2 - 2*a**2*m*r + a**2*r**2*cos(theta)**2 + a**2*r**2 - 2*m*r**3 + r**4)) + a*m*r*(-4*a**3*m*r*sin(theta)**3*cos(theta)/(a**2*cos(theta)**2 + r**2)**2 - 4*a*m*r*sin(theta)*cos(theta)/(a**2*cos(theta)**2 + r**2))/(a**4*cos(theta)**2 + 2*a**2*m*r*sin(theta)**2 - 2*a**2*m*r + a**2*r**2*cos(theta)**2 + a**2*r**2 - 2*m*r**3 + r**4))", "2*a**2*ur*utheta*sin(theta)*cos(theta)/(a**2*cos(theta)**2 + r**2) + r*utheta**2*(a**2 - 2*m*r + r**2)/(a**2*cos(theta)**2 + r**2) + uphi**2*(a**2 - 2*m*r + r**2)*(-4*a**2*m*r**2*sin(theta)**2/(a**2*cos(theta)**2 + r**2)**2 + 2*a**2*m*sin(theta)**2/(a**2*cos(theta)**2 + r**2) + 2*r)*sin(theta)**2/(2*(a**2*cos(theta)**2 + r**2)) - uphi*ut*(-4*a*m*r**2*sin(theta)**2/(a**2*cos(theta)**2 + r**2)**2 + 2*a*m*sin(theta)**2/(a**2*cos(theta)**2 + r**2))*(a**2 - 2*m*r + r**2)/(a**2*cos(theta)**2 + r**2) - ur**2*(2*r/(a**2 - 2*m*r + r**2) + (2*m - 2*r)*(a**2*cos(theta)**2 + r**2)/(a**2 - 2*m*r + r**2)**2)*(a**2 - 2*m*r + r**2)/(2*(a**2*cos(theta)**2 + r**2)) - ut**2*(4*m*r**2/(a**2*cos(theta)**2 + r**2)**2 - 2*m/(a**2*cos(theta)**2 + r**2))*(a**2 - 2*m*r + r**2)/(2*(a**2*cos(theta)**2 + r**2))", "2*a**2*m*r*ut**2*sin(theta)*cos(theta)/(a**2*cos(theta)**2 + r**2)**3 - a**2*ur**2*sin(theta)*cos(theta)/((a**2*cos(theta)**2 + r**2)*(a**2 - 2*m*r + r**2)) + a**2*utheta**2*sin(theta)*cos(theta)/(a**2*cos(theta)**2 + r**2) - 2*r*ur*utheta/(a**2*cos(theta)**2 + r**2) - uphi**2*(-(4*a**4*m*r*sin(theta)**3*cos(theta)/(a**2*cos(theta)**2 + r**2)**2 + 4*a**2*m*r*sin(theta)*cos(theta)/(a**2*cos(theta)**2 + r**2))*sin(theta)**2 - 2*(2*a**2*m*r*sin(theta)**2/(a**2*cos(theta)**2 + r**2) + a**2 + r**2)*sin(theta)*cos(theta))/(2*(a**2*cos(theta)**2 + r**2)) - uphi*ut*(4*a**3*m*r*sin(theta)**3*cos(theta)/(a**2*cos(theta)**2 + r**2)**2 + 4*a*m*r*sin(theta)*cos(theta)/(a**2*cos(theta)**2 + r**2))/(a**2*cos(theta)**2 + r**2)", "2*uphi*ur*(a*m*r*(4*a*m*r**2*sin(theta)**2/(a**2*cos(theta)**2 + r**2)**2 - 2*a*m*sin(theta)**2/(a**2*cos(theta)**2 + r**2))/(a**4*cos(theta)**2 + 2*a**2*m*r*sin(theta)**2 - 2*a**2*m*r + a**2*r**2*cos(theta)**2 + a**2*r**2 - 2*m*r**3 + r**4) - (a**2*cos(theta)**2 - 2*m*r + r**2)*(-4*a**2*m*r**2*sin(theta)**2/(a**2*cos(theta)**2 + r**2)**2 + 2*a**2*m*sin(theta)**2/(a**2*cos(theta)**2 + r**2) + 2*r)*sin(theta)**2/(2*(a**4*sin(theta)**2*cos(theta)**2 + 2*a**2*m*r*sin(theta)**4 - 2*a**2*m*r*sin(theta)**2 + a**2*r**2*sin(theta)**2*cos(theta)**2 + a**2*r**2*sin(theta)**2 - 2*m*r**3*sin(theta)**2 + r**4*sin(theta)**2))) + 2*uphi*utheta*(a*m*r*(-4*a**3*m*r*sin(theta)**3*cos(theta)/(a**2*cos(theta)**2 + r**2)**2 - 4*a*m*r*sin(theta)*cos(theta)/(a**2*cos(theta)**2 + r**2))/(a**4*cos(theta)**2 + 2*a**2*m*r*sin(theta)**2 - 2*a**2*m*r + a**2*r**2*cos(theta)**2 + a**2*r**2 - 2*m*r**3 + r**4) - ((4*a**4*m*r*sin(theta)**3*cos(theta)/(a**2*cos(theta)**2 + r**2)**2 + 4*a**2*m*r*sin(theta)*cos(theta)/(a**2*cos(theta)**2 + r**2))*sin(theta)**2 + 2*(2*a**2*m*r*sin(theta)**2/(a**2*cos(theta)**2 + r**2) + a**2 + r**2)*sin(theta)*cos(theta))*(a**2*cos(theta)**2 - 2*m*r + r**2)/(2*(a**4*sin(theta)**2*cos(theta)**2 + 2*a**2*m*r*sin(theta)**4 - 2*a**2*m*r*sin(theta)**2 + a**2*r**2*sin(theta)**2*cos(theta)**2 + a**2*r**2*sin(theta)**2 - 2*m*r**3*sin(theta)**2 + r**4*sin(theta)**2))) + 2*ur*ut*(a*m*r*(-4*m*r**2/(a**2*cos(theta)**2 + r**2)**2 + 2*m/(a**2*cos(theta)**2 + r**2))/(a**4*cos(theta)**2 + 2*a**2*m*r*sin(theta)**2 - 2*a**2*m*r + a**2*r**2*cos(theta)**2 + a**2*r**2 - 2*m*r**3 + r**4) - (4*a*m*r**2*sin(theta)**2/(a**2*cos(theta)**2 + r**2)**2 - 2*a*m*sin(theta)**2/(a**2*cos(theta)**2 + r**2))*(a**2*cos(theta)**2 - 2*m*r + r**2)/(2*(a**4*sin(theta)**2*cos(theta)**2 + 2*a**2*m*r*sin(theta)**4 - 2*a**2*m*r*sin(theta)**2 + a**2*r**2*sin(theta)**2*cos(theta)**2 + a**2*r**2*sin(theta)**2 - 2*m*r**3*sin(theta)**2 + r**4*sin(theta)**2))) + 2*ut*utheta*(4*a**3*m**2*r**2*sin(theta)*cos(theta)/((a**2*cos(theta)**2 + r**2)**2*(a**4*cos(theta)**2 + 2*a**2*m*r*sin(theta)**2 - 2*a**2*m*r + a**2*r**2*cos(theta)**2 + a**2*r**2 - 2*m*r**3 + r**4)) - (-4*a**3*m*r*sin(theta)**3*cos(theta)/(a**2*cos(theta)**2 + r**2)**2 - 4*a*m*r*sin(theta)*cos(theta)/(a**2*cos(theta)**2 + r**2))*(a**2*cos(theta)**2 - 2*m*r + r**2)/(2*(a**4*sin(theta)**2*cos(theta)**2 + 2*a**2*m*r*sin(theta)**4 - 2*a**2*m*r*sin(theta)**2 + a**2*r**2*sin(theta)**2*cos(theta)**2 + a**2*r**2*sin(theta)**2 - 2*m*r**3*sin(theta)**2 + r**4*sin(theta)**2)))"], "u0_timelike": "-2*a*m*r*uphi*sin(theta)**2/(a**2*cos(theta)**2 - 2*m*r + r**2) - sqrt((a**2*cos(theta)**2 + r**2)*(a**2 - 2*m*r + r**2)*(a**6*uphi**2*sin(theta)**2*cos(theta)**2 + a**6*utheta**2*cos(theta)**4 + 2*a**4*m*r*uphi**2*sin(theta)**4 - 2*a**4*m*r*uphi**2*sin(theta)**2*cos(theta)**2 - 2*a**4*m*r*uphi**2*sin(theta)**2 - 2*a**4*m*r*utheta**2*cos(theta)**4 - 2*a**4*m*r*utheta**2*cos(theta)**2 + 2*a**4*r**2*uphi**2*sin(theta)**2*cos(theta)**2 + a**4*r**2*uphi**2*sin(theta)**2 + a**4*r**2*utheta**2*cos(theta)**4 + 2*a**4*r**2*utheta**2*cos(theta)**2 + a**4*ur**2*cos(theta)**4 + a**4*cos(theta)**2 - 4*a**2*m**2*r**2*uphi**2*sin(theta)**4 + 4*a**2*m**2*r**2*uphi**2*sin(theta)**2 + 4*a**2*m**2*r**2*utheta**2*cos(theta)**2 + 2*a**2*m*r**3*uphi**2*sin(theta)**4 - 2*a**2*m*r**3*uphi**2*sin(theta)**2*cos(theta)**2 - 6*a**2*m*r**3*uphi**2*sin(theta)**2 - 6*a**2*m*r**3*utheta**2*cos(theta)**2 - 2*a**2*m*r**3*utheta**2 - 2*a**2*m*r*ur**2*cos(theta)**2 - 2*a**2*m*r*cos(theta)**2 - 2*a**2*m*r + a**2*r**4*uphi**2*sin(theta)**2*cos(theta)**2 + 2*a**2*r**4*uphi**2*sin(theta)**2 + 2*a**2*r**4*utheta**2*cos(theta)**2 + a**2*r**4*utheta**2 + 2*a**2*r**2*ur**2*cos(theta)**2 + a**2*r**2*cos(theta)**2 + a**2*r**2 + 4*m**2*r**4*uphi**2*sin(theta)**2 + 4*m**2*r**4*utheta**2 + 4*m**2*r**2 - 4*m*r**5*uphi**2*sin(theta)**2 - 4*m*r**5*utheta**2 - 2*m*r**3*ur**2 - 4*m*r**3 + r**6*uphi**2*sin(theta)**2 + r**6*utheta**2 + r**4*ur**2 + r**4))/(-a**4*cos(theta)**2 + 2*a**2*m*r*cos(theta)**2 + 2*a**2*m*r - a**2*r**2*cos(theta)**2 - a**2*r**2 - 4*m**2*r**2 + 4*m*r**3 - r**4)", "u0_null": "-2*a*m*r*uphi*sin(theta)**2/(a**2*cos(theta)**2 - 2*m*r + r**2) - sqrt((a**2*cos(theta)**2 + r**2)*(a**2 - 2*m*r + r**2)*(a**6*uphi**2*sin(theta)**2*cos(theta)**2 + a**6*utheta**2*cos(theta)**4 + 2*a**4*m*r*uphi**2*sin(theta)**4 - 2*a**4*m*r*uphi**2*sin(theta)**2*cos(theta)**2 - 2*a**4*m*r*uphi**2*sin(theta)**2 - 2*a**4*m*r*utheta**2*cos(theta)**4 - 2*a**4*m*r*utheta**2*cos(theta)**2 + 2*a**4*r**2*uphi**2*sin(theta)**2*cos(theta)**2 + a**4*r**2*uphi**2*sin(theta)**2 + a**4*r**2*utheta**2*cos(theta)**4 + 2*a**4*r**2*utheta**2*cos(theta)**2 + a**4*ur**2*cos(theta)**4 - 4*a**2*m**2*r**2*uphi**2*sin(theta)**4 + 4*a**2*m**2*r**2*uphi**2*sin(theta)**2 + 4*a**2*m**2*r**2*utheta**2*cos(theta)**2 + 2*a**2*m*r**3*uphi**2*sin(theta)**4 - 2*a**2*m*r**3*uphi**2*sin(theta)**2*cos(theta)**2 - 6*a**2*m*r**3*uphi**2*sin(theta)**2 - 6*a**2*m*r**3*utheta**2*cos(theta)**2 - 2*a**2*m*r**3*utheta**2 - 2*a**2*m*r*ur**2*cos(theta)**2 + a**2*r**4*uphi**2*sin(theta)**2*cos(theta)**2 + 2*a**2*r**4*uphi**2*sin(theta)**2 + 2*a**2*r**4*utheta**2*cos(theta)**2 + a**2*r**4*utheta**2 + 2*a**2*r**2*ur**2*cos(theta)**2 + 4*m**2*r**4*uphi**2*sin(theta)**2 + 4*m**2*r**4*utheta**2 - 4*m*r**5*uphi**2*sin(theta)**2 - 4*m*r**5*utheta**2 - 2*m*r**3*ur**2 + r**6*uphi**2*sin(theta)**2 + r**6*utheta**2 + r**4*ur**2))/(-a**4*cos(theta)**2 + 2*a**2*m*r*cos(theta)**2 + 2*a**2*m*r - a**2*r**2*cos(theta)**2 - a**2*r**2 - 4*m**2*r**2 + 4*m*r**3 - r**4)"} -------------------------------------------------------------------------------- /docs/source/define_observer.rst: -------------------------------------------------------------------------------- 1 | Define a space-time Observer 2 | ================================================================ 3 | 4 | Theoretical background 5 | -------------------------- 6 | 7 | The approach to General Relativity that employs observer frames is known as the *tetrad formalism* or Cartan formalism. In this framework, it is possible to construct frames (also called vierbein) at each point on the curved manifold, *i.e.* constructing a frame bundle over the manifold. These frames consist of four orthonormal vectors that we denote as :math:`\{f^\alpha_a\}` (greek indices represent the components of the frame vector in the chart coordinates fixed on the manifold while roman indices select one of the frame vector): one time-like vector (:math:`f^\alpha_0`) and three space-like vectors (:math:`f^\alpha_i`), representing respectively the time axis of a given observer and a series of orthonormal spatial axes, forming a spatial tetrad, at a given point. The observer uses thse axes to define tangent vectors to the manifold and project them in a convenient way in its own frame. In this sense, we can say that the frame :math:`\{f^\alpha_a\}` provides a local basis for tangent vectors at the observer's location. 8 | 9 | .. image:: _static/observer-illustration.png 10 | :width: 100% 11 | :alt: Reference frame of a given observer 12 | :class: only-light 13 | 14 | .. image:: _static/observer-illustration-dark.png 15 | :width: 100% 16 | :alt: Reference frame of a given observer 17 | :class: only-dark 18 | 19 | Similarly, one can define a co-frame :math:`\{f_\beta^b\}` which forms the basis for tangent 1-forms at the observer's location. 20 | 21 | This can be generalized and one can use the defined frame and co-frame basis to define any tensorial quantity the in the tangent or co-tangent bundle (or any product of these). For example, the metric tensor can be expressed in the coframe as 22 | 23 | .. math:: 24 | g = -f^0\otimes f^0 + \sum_i f^i\otimes f^i, 25 | :label: metric-tetrad 26 | 27 | corresponding to the relations between the local frame metric and the space-time metric 28 | 29 | .. math:: 30 | 31 | f_a^\alpha f_\beta^b \eta_{ab} &= g_{\alpha\beta},\\ 32 | f^a_\alpha f^\beta_b g_{ab} &= \eta_{\alpha\beta}. 33 | 34 | 35 | In other terms, in the local observer's frame the metric is ordinary Minkowski metric, which is a statement of the local flatness in General Relativity. 36 | 37 | An example: stationary observers in the Schwarzschild space-time 38 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 39 | 40 | Let's consider the Schwarzschild space-time already used in this series of tutorials: 41 | 42 | .. math:: 43 | 44 | ds^2 = -\left(1-\frac{2M}{r}\right)dt^2+\left(1-\frac{2M}{r}\right)^{-1}dr^2+r^2(d\theta^2+\sin^2\theta d\phi^2). 45 | 46 | We want to fix an observer at a certain point so that the :math:`f_1` frame vector points in the radial direction, towards the black hole. The other frame vectors, :math:`f_2` and :math:`f_3` will point along the *angular* directions at the observer's positon, opposite to the coordinate basis vecotor (to ensure the left-handedness of the frame). 47 | 48 | .. image:: _static/observer-illustration-schwarzschild.png 49 | :width: 100% 50 | :alt: Geodesic Engine 51 | :class: only-light 52 | 53 | .. image:: _static/observer-illustration-schwarzschild-dark.png 54 | :width: 100% 55 | :alt: Geodesic Engine 56 | :class: only-dark 57 | 58 | When these ingredients are put together, and equation :eq:`metric-tetrad` is taken into account, one can define a co-frame basis for the Schwarzschild metric (expressed in Schwarzschild coordinates and hance defined in terms of the coordinate basis of the co-tangent space :math:`\{dt,\,dr,\,d\theta,\,d\phi\}`) as 59 | 60 | .. math:: 61 | 62 | f^0 &= \sqrt{1-\frac{2M}{r}}dt\\ 63 | f^1 &= -\frac{dr}{\sqrt{1-\frac{2M}{r}}}\\ 64 | f^2 &= -r\sin\theta d\phi\\ 65 | f^3 &= -r\,d\theta 66 | 67 | From these expressions one can obtain the frame dual :math:`\{f_\alpha^a\}` as the inverse of the coframe matrix :math:`\{f^\beta_b\}` which will be expressed in the coordinate basis of the tangent space :math:`\{\partial t,\,\partial r,\,\partial\theta,\,\partial\phi\}` 68 | 69 | .. math:: 70 | 71 | f_0 &= \frac{1}{\sqrt{1-\frac{2M}{r}}}\partial_t\\ 72 | f_1 &= -\sqrt{1-\frac{2M}{r}}\partial_r\\ 73 | f_2 &= -\frac{1}{r\sin\theta} \partial_\phi\\ 74 | f_3 &= -\frac{1}{r}\partial_\theta 75 | 76 | As can be seen, the world line of the :math:`f_0` vector is a spatially-fixed line, so that the frame identified represents that of a static observers who use rocket engines to "hover" over the massive object. 77 | 78 | Observers in PyGRO 79 | ---------------------- 80 | 81 | Let's now implement the above example in PyGRO, where space-time observers are represented by the abstract class :py:class:`.Observer`. 82 | 83 | In this case, creating a new stationary observer at a distance :math:`r=50M` on the equatorial plane of the black hole is as easy as defining: 84 | 85 | .. code-block:: 86 | 87 | observer = pygro.Observer( 88 | metric = metric, 89 | x = [0, 50, np.pi/2, 0], 90 | coframe = ["sqrt(1-2*M/r)*dt","-dr/sqrt(1-2*M/r)", "-r*sin(theta)*dphi", "-r*dtheta"] 91 | ) 92 | 93 | where we have used the expressions of the coframe vectors of the observer's frame defined above. Read :py:class:`.Observer` for a detailed documentation of the API functionality. 94 | 95 | PyGRO expects to recieve a list of strings for the symbolic definitions of the frame co-vector expressed in the coordinate co-basis, *i.e.* a linear combination of the differentials ``d[coordinate]`` that matches the coordinate names used to define the :py:class:`.Metric` object (see :doc:`create_metric`). 96 | 97 | Alternatively, one can decide to define the observer starting from the frame vectors (instead of co-vectors) expressed in the coordinate basis of the tangent space. In this case PyGRO expects the ``frame`` keyword of the :py:class:`.Observer` be populated by a list of expressions containing the expressions of the frame vectors as a linear combination of the the coordinate basis :math:`\partial_{\{x\}}` that in the code must be represented by ``e[coordinate]``, again matching the coordinate names used to define the :py:class:`.Metric` object. 98 | 99 | For example, the same observer defined above can be defined as: 100 | 101 | .. code-block:: 102 | 103 | observer = pygro.Observer( 104 | metric = metric, 105 | x = [0, 50, np.pi/2, 0], 106 | frame = ["1/sqrt(1-2*M/r)*et","-sqrt(1-2*M/r)*er", "-1/(r*sin(theta))*ephi", "-1/r*etheta"] 107 | ) 108 | 109 | 110 | .. note:: 111 | 112 | The symbolic expressions of both the frame and co-frame vectors can make use of auxiliary expressions and auxiliary functions (see :doc:`create_metric`) as long as these are correctly introduced at the moment of the definition of the :py:class:`.Metric` object. 113 | 114 | We can now use the newly defined :py:class:`.Observer` object to set initial conditions for photons or test-particles in a more physical way than done in the previous tutorials (:doc:`integrate_geodesic`). 115 | 116 | To do so, we can use the methods :py:meth:`~pygro.obsever.Observer.from_f1`, :py:meth:`~pygro.obsever.Observer.from_f2` and :py:meth:`~pygro.obsever.Observer.from_f3` of the :py:class:`.Observer` class to fire a geodesic that forms specific angles with respect to the :math:`f_1`, :math:`f_2` and :math:`f_3` frame vectors respectively. To do so, we define the angles :math:`\theta_{\rm obs}` and :math:`\phi_{\rm obs}` that correspond to the usual longitude and latitude in the observer's reference frame taking as origin the given frame vector. 117 | 118 | For example, for the :math:`f_1` case, these angle are illustrated here: 119 | 120 | .. image:: _static/observer-illustration-angles.png 121 | :width: 100% 122 | :alt: Reference frame of a given observer 123 | :class: only-light 124 | 125 | .. image:: _static/observer-illustration-angles-dark.png 126 | :width: 100% 127 | :alt: Reference frame of a given observer 128 | :class: only-dark 129 | 130 | Hence, suppose we want to fire a time-like geodesic starting at the observer's position with a spatial velocity that, in the oberver's reference frame, has an initial direction of :math:`\theta_{\rm obs} = 15^{\circ}` and :math:`\phi_{\rm obs} = 5^{\circ}` from the radial inward-pointing direction (:math:`f_1` direction) and that the module of the velocity is 30% of the speed of light (``v = 0.3`` given the gometrized units that we are using). 131 | 132 | This can be done by: 133 | 134 | .. code-block:: 135 | 136 | geo = pygro.Geodesic("time-like", geo_engine, verbose = False) 137 | 138 | geo.initial_x = observer.x 139 | geo.initial_u = observer.from_f1(np.deg2rad(15), np.deg2rad(5), type = geo.type, v = 0.3) 140 | 141 | The geodesic is correctly initialized and the 4-velocity is already normalized to be a time-like vector. We can proceed to the integration. 142 | 143 | .. code-block:: 144 | 145 | geo_engine.integrate(geo, 1000, 1, verbose = True) 146 | 147 | Which gives the following results: 148 | 149 | .. image:: _static/observer_example_1.png 150 | :width: 100% 151 | :alt: Example of initial conditions assigned from an observer 152 | 153 | Looking at the :math:`x-y` and :math:`x-z` planes we can see that the geodesic effectively starts with the given direction in space-time form the observer's location and plunges into the horizon. 154 | 155 | Similarly we can deal with light rays (null geodesics) originating at the observer position and integrate them in a *ray-tracing* fashion. For example the code 156 | 157 | .. code-block:: 158 | 159 | observer = pygro.Observer( 160 | metric, 161 | [0, 25*np.sqrt(2), np.pi/2, np.pi/4], 162 | coframe = ["sqrt(A(r))*dt", "-dr/sqrt(A(r))", "-r*sin(theta)*dphi", "-r*dtheta"] 163 | ) 164 | 165 | phi_arr = np.linspace(-np.pi/4, np.pi/4, 101) 166 | geo_arr = [] 167 | 168 | for phi in phi_arr: 169 | 170 | geo = pygro.Geodesic("null", geo_engine, verbose = False) 171 | 172 | geo.initial_x = observer.x 173 | geo.initial_u = observer.from_f1(0, phi, type = geo.type) 174 | 175 | geo_engine.integrate(geo, 1000, 1, verbose = False) 176 | 177 | geo_arr.append(geo) 178 | 179 | 180 | Will fire 101 geodesics from the corner of a :math:`25M\times 25M` square on the eqatorial plane of the Schwarzschild black hole, with angles spanning the :math:`[-\pi/4,\pi/4]` (from right to left of the :math:`f_1` vector) towards the black hole and integrate them. 181 | 182 | .. image:: _static/observer_example_2.png 183 | :width: 100% 184 | :alt: Null geodesics originating from the observer 185 | 186 | Interestingly, we can use the ``Geodesic.exit`` attribute to identify the geodesics ending up in the horizon and mark them with a different color (see :doc:`visualize`). -------------------------------------------------------------------------------- /docs/source/integrating_orbits.rst: -------------------------------------------------------------------------------- 1 | Integrating physical orbits 2 | =========================== 3 | 4 | The trajectories of massive test particles in PyGRO can be integrated using the low-level :py:class:`.Geodesic` class, by assigning the initial data directly from the values of the space-time coordinates and of the components of the 4-velocity at a given proper time (see :doc:`geodesics/timelike_geodesic`) 5 | 6 | Alternatively, one can set the initial conditions by tying the orbit to a physical observer at a given location in space-time and assign initial velocity and direction relative to this observer(see :doc:`define_observer`). 7 | 8 | In this tutorial we introduce another possibility. The :py:class:`.Orbit` class, which is a wrapper for the :py:class:`.Geodesic` class in the ``"time-like"`` case, allows to set initial conditions for a massive test particle and to integrate its trajectory, by paramterizing the initial position and velocity in a set of Keplerian elements that are familiar to those of classical celestial mechanics. 9 | 10 | .. caution:: 11 | The functionalities of the :py:class:`.Orbit` class and the procedure for assigning initial conditions are guaranteed to work exclusively for spherically symmetric space-times. 12 | 13 | Theoretical background 14 | -------------------------- 15 | 16 | The geodesic equations, describing relativistic trajectories of test particles, are a system of four second-order differential equations for four unknown functions that describe the spacetime trajectory of a test particle as a function of an affine parameter. For massive particle, describing time-like trajectories, one can choose affine parameter the proper time of the particle. 17 | 18 | A solution to the geodesic equations is uniquely determined once second-order initial conditions are assigned. Namely, one has to specify at a any given proper time :math:`s_0` the initial spacetime position of the test particle (*e.g.* for the Schwarzschild space-time :math:`t(s_0)`, :math:`r(s_0)`, :math:`\theta(s_0)`, :math:`\phi(s_0)`) and spacetime 4-velocity (*e.g.* :math:`\dot{t}(s_0)`, :math:`\dot{r}(s_0)`, :math:`\dot{\theta}(s_0)`, :math:`\dot{\phi}(s_0)`). In PyGRO, this is what one should do using the standard :py:class:`.Geodesic` API. 19 | 20 | In spherically symmetric space-times (we are considering that the space-time coordinates are :math:`r` for the radial coordinate and :math:`(\theta,\,\phi)` for the latitudinal and azimuthal angles), one can exploit this symmetry to reduce the complexity of the problem. As a matter of fact, one can always perform appropriate rotations to make the trajectory lie on the equatorial plane for the whole duration of the motion. This reduces the number of free parameters, since :math:`\theta = \pi/ 2` and :math:`\dot{\theta} = 0` are assumed implicitly. Moreover, since the metric components (and thus the geodesic equations) do not explicitly depend on the time coordinate :math:`t`, the value :math:`t(s_0)` can be chosen arbitrarily, with no impact on the resulting trajectory. Finally, the normalization condition of the 4-velocity for massive test-particles, :math:`g_{\mu\nu}\dot{x}^\mu\dot{x}^\nu=-1`, represents a constraint on the initial data, thus allowing to derive one of the components of the 4-velocity as a function of the others. 21 | 22 | A complete set of initial data that uniquely identifies a trajectory on the equatorial plane of a spherically symmetric spacetime can thus be (:math:`r(s_0)`, :math:`\phi(s_0)`, :math:`\dot{t}(s_0)`, :math:`\dot{\phi}(s_0)`), with :math:`\dot{r}(s_0)` fixed by the choice of :math:`\dot{t}(s_0)` and :math:`\dot{\phi}(s_0)` through the normalization condition. 23 | 24 | Instead of fixing the initial conditions directly in terms of these components of the 4-velocity, we introduce a different parameterization in terms of constants of motion. In particular, given the relativistic test-particle Lagrangian, that can be built from the metric tensor 25 | 26 | .. math:: 27 | \mathcal{L} = \frac{1}{2}g_{\mu\nu}\dot{x}^\mu\dot{x}^\nu, 28 | 29 | one can obtain in spherically symmetric space-times two constants of motion: 30 | 31 | .. math:: 32 | E &= -\frac{\partial {\mathcal{L}}}{\partial \dot{t}}\\ 33 | L &= \frac{\partial \mathcal{\mathcal{L}}}{\partial \dot{\phi}} 34 | :label: EL-lagrangian 35 | 36 | And always rewrite the equation of motion on the radial direction as a function 37 | 38 | .. math:: 39 | \dot{r}^2 = E - V_\textrm{eff}(r; L) 40 | :label: dotr2 41 | 42 | Where we have introduced the effective potential :math:`V_\textrm{eff}` which is parametrized by the orbital angular momentum and depends functionally on the radial coordinate :math:`r`. A choice of :math:`E` and :math:`L` uniquely fixes the evolution of the radial coordiante and specifically defines the radial turning points, *i.e.* point where :math:`\dot{r} = 0`. In the case of quasi-elliptic orbits (which is the case we are interested in, even though the :py:class:`.Orbit` class can in general be used for any orbital configuration) we call the two radial turning point the pericenter (identified by radial coordinate :math:`r_p`) and the apocenter (identified by :math:`r_a`), 43 | 44 | 45 | We can invert this mapping and introduce two orbital parameters, namely the eccentricity :math:`e`` and the semi-major axis :math:`a` that have a unique map to the radial turning points 46 | 47 | .. math:: 48 | r_p &= a(1-e)\\ 49 | r_a &= a(1+e) 50 | 51 | and look for the values of :math:`E` and :math:`L` that make :math:`\dot{r}=0` at both :math:`r=r_p` and :math:`r=r_a`, by numerically solving :eq:`dotr2`. 52 | 53 | .. image:: _static/effective-potential.png 54 | :width: 100% 55 | :alt: Effective potential 56 | :class: only-light 57 | 58 | .. image:: _static/effective-potential-dark.png 59 | :width: 100% 60 | :alt: Effective potential 61 | :class: only-dark 62 | 63 | In the flat space-time limit the orbital parameters that we have introduced perfectly match the concept of semi-major axis and eccentricity classically defined in Newtonian celestial mechanics for Keplerian elliptical orbits. In the General Relativistic case, these parameters depend on the particular choice of coordinates used (for example, in the harmonic gauge of post-Newtonian mechanics, these parameter have a slightly different definition and physical interpretation) and their physical meaning is not uniquely defined in the strong-field regime. They don't thus have a direct physical meaning, but only serve as a familiar and useful parametrization of initial data. 64 | 65 | Once the values of :math:`E` and :math:`L` are known, one can invert equations :eq:`EL-lagrangian` to obtain :math:`\dot{t}` and :math:`\dot{\phi}`. We can now choose wheter to start the integration at apocenter or pericenter and fix the rest of initial conditions from there. For example, if we call :math:`t_p` the time of pericenter passage, we have that inital data are 66 | 67 | .. math:: 68 | t(s_0) &= t_p\\ 69 | r(s_0) &= a(1-e)\\ 70 | \phi(s_0) &= 0\\ 71 | \dot{r}(s_0) &= 0 72 | 73 | and :math:`\dot{t}(s_0)` and :math:`\dot{\phi}(s_0)` are derived with the procedure that we have just explained. This completely fixes the initial conditions on the equatorial plane and allows to integrate the geodesic equations numerically. 74 | 75 | To go outside the equatorial plane, we can introduce the standard angular orbital parameters: 76 | 77 | - **Inclination** (:math:`i`) measures how much the orbital plane is inclined with respect to the euqatorial plane of the spherically-symmetric space-time considered. The two planes intersect on a line that is known as \emph{line of nodes}. The orbiting object crosses this line two times over one period. The point where it cuts it from below the plane is known as the \emph{ascending node}; the point where it cuts it from above is known as \emph{descending node}. 78 | - **Longitude of the ascending** (:math:`\Omega`), which is the angle between the :math:`x` axis (*i.e.* the one identified by :math:`\theta=\pi/2` and :math:`\phi=0`) and the ascending node. 79 | - **Argument of the pericenter** (:math:`\omega`) between the line of nodes and the semi-major axis of the orbit, specifically its pericenter, ovver the orbital plane. 80 | 81 | .. image:: _static/orbital-parameters.png 82 | :width: 100% 83 | :alt: Illustration of the orbital parameters 84 | :class: only-light 85 | 86 | .. image:: _static/orbital-parameters-dark.png 87 | :width: 100% 88 | :alt: Illustration of the orbital parameters 89 | :class: only-dark 90 | 91 | These angles correspond to three subsequent rotations that bring the orbit into the desired reference frame: 92 | 93 | - a rotation :math:`R_1` around the :math:`z` axis by an angle :math:`-\omega` (the minus sign is due to the fact that by definition :math:`\omega` points *towards* :math:`x` and not *from* it); 94 | - a rotation :math:`R_2` by an angle :math:`-i` around the new :math:`x` axis, corresponding to the line of nodes; 95 | - a rotation :math:`R_3` around the :math:`z` axis by an angle :math:`-\Omega`. 96 | 97 | 98 | The three rotations are applied to both the initial position and velocity before integrating the geodesic. 99 | 100 | Orbits in PyGRO 101 | --------------- 102 | 103 | In PyGRO the :py:class:`.Orbit` class incorporates all the previously explained theoretical background and, being based on the combination of symbolic comutaions of the :py:class:`.Metric` object and numerical root-finding routines, generalizes it for an arbitrary spherically-symmetric spacetime. 104 | 105 | After having defined a :py:class:`.Metric` (:doc:`create_metric`) and having linked it to a :py:class:`GeodesicEngine` (:doc:`integrate_geodesic`), defininf an :py:class:`.Orbit` is as simple as 106 | 107 | .. code-block:: 108 | 109 | orbit = pygro.Orbit() 110 | 111 | One can then assign the orbital parameters (or pass them to the :py:class:`.Orbit` constructur as a dictionary ``orbital_parameters``, see the detailed API documentation) by specifying 112 | 113 | .. code-block:: 114 | 115 | orbit.set_orbital_parameters(t_P = 0, a = 200, e = 0.8) 116 | 117 | In this case, we have assigned a periceneter passage coordinate time of ``t_P=0``, a semi-major axis ``a = 200`` and an eccentricity ``e=0.8``. This is regarded as a minimal set of orbital parameters that uniquely identifiy an orbit. Alternatively, one could have specified ``t_A``, the time of apocenter passage, which would have fixed initial conditions at apocenter, instead. Additionally, one can fix values for the angular orbital parameters ``(..., i = ..., omega = ..., Omega = ...)``, which have to be specified in radians. These are not mandatory, and one can leave them blank. PyGRO will automatically fix them to zero (implying that orbit lies on the equatorial plane, :math:`\theta=\pi/2`, with its initial major-axis oriented along the pseudo-cartesian x-axis, corresponding to an angle :math:`\phi=0`). 118 | 119 | At this point one can integrate the orbit, using the :py:meth:`~pygro.orbit.Orbit.integrate` method which requires as arguments the lenght of proper time interval on which to integrate and the inital step. Additionally, one can pass all the arguments of the specific geodesic integrator chosen in the :py:class:`.GeodesicEngine` initialization and a precision tolerance for the root-finding algorithm of the initial conditions (see the :py:class:`.Orbit` documentation) 120 | 121 | .. code-block:: 122 | 123 | orbit.integrate(2e5, 0.1, accuracy_goal = 12, precision_goal = 12) 124 | 125 | Behind the curtains, this is where, before carrying out the numerical integration of the particle's trajectory, PyGRO performs the calculations to convert the orbital parameters into initial conditions for the geodesic. In case this procedure fails (for whatever reason, be it theoretical impossibility to map between the given set of orbital parameters and initial geodesic conditons or failure of the root-finding algorithm) the procedure falls back to assignign intial conditions based on Kepler's laws (which of course don't have general validity in a relativistic scenario). 126 | 127 | An example orbit that can be obtained in this case is shown here: 128 | 129 | .. image:: _static/orbit-example-1.png 130 | :width: 100% 131 | :alt: Example orbit 132 | 133 | but we refer to the example notebooks :doc:`examples/Schwarzschild-ISCO` and :doc:`examples/Schwarzschild-Precession` for in-depth examples of the functionality of the :py:class:`.Orbit` API. -------------------------------------------------------------------------------- /docs/source/create_metric.rst: -------------------------------------------------------------------------------- 1 | Define your own space-time 2 | ================================================================ 3 | 4 | In this tutorial we will go through all the different methods in PyGRO for the generation of a :meth:`~pygro.metric_engine.Metric` object that describes a particular space-time metric. PyGRO uses the signature convention :math:`(-,\,+,\,+,\,+)`. 5 | 6 | For the sake of simplicity, we will devote this tutorial to the Schwarzschild solution, describing the spherically symmetric spacetime around a point-like particle with mass :math:`M`. 7 | When expressed in Schwarzschild coordinates :math:`(t, r, \theta, \phi)` the line element describing the geometry of this spacetime is described by 8 | 9 | .. math:: 10 | ds^2 = -\left(1-\frac{2M}{r}\right)dt^2+\left(1-\frac{2M}{r}\right)^{-1}dr^2+r^2(d\theta^2+\sin^2\theta d\phi^2), 11 | 12 | where we have assumed that :math:`G=c=1` and, hence, the radial coordinate and spatial distances are expressed in units of gravitational radii :math:`r_g = GM/c^2`. 13 | 14 | Purely symbolic approach 15 | ------------------------ 16 | .. _symb: 17 | 18 | In this approach, the :meth:`~pygro.metric_engine.Metric` object will be generated starting from a line element (``str``) which is a function that depends **explicitly** only on the space-time coordinates 19 | and on a given number of ``constant`` parameters. This means that no *auxiliary* function of the space-time coordinates is introduced, as will be done in the :ref:`Auxiliary expressions approach ` 20 | or :ref:`Auxiliary functions approach `. 21 | In order to initialize the :meth:`~pygro.metric_engine.Metric`, we define a ``list`` of ``str`` for the spacetime coordinates, and we express the ``line_element`` as a function of such coordinates :math:`\{x^\mu\}` and of their infinitesimal increment :math:`\{dx^\mu\}`, indicated with a ``d`` as prefix 22 | (e.g. for coordinate ``theta`` the increment is ``dtheta``). Additionally, the ``transform_functions`` list is defined, which contains the symbolic expressions for transformation functions 23 | from the spacetime coordinates in which the ``line_element`` is expressed to pseudo-cartesian coordinates :math:`(t, x, y, z)` that are useful to :doc:`visualize`. 24 | 25 | .. tip:: 26 | Since the ``line_element`` is converted into a ``sympy`` expression, a good way to check whether it has been correctly typed, 27 | is to apply the ``pygro.parse_expr`` function on the ``line_element`` and check that the mathematical expression is properly interpreted. 28 | 29 | .. code-block:: 30 | 31 | import pygro 32 | 33 | name = "Schwarzschild spacetime" 34 | coordinates = ["t", "r", "theta", "phi"] 35 | 36 | transform_functions = [ 37 | "t", 38 | "r*sin(theta)*cos(phi)", 39 | "r*sin(theta)*sin(phi)", 40 | "r*cos(theta)" 41 | ] 42 | 43 | line_element = "-(1-2*M/r)*dt**2+1/(1-2*M/r)*dr**2+r**2*(dtheta**2+sin(theta)**2*dphi**2)" 44 | 45 | metric = pygro.Metric( 46 | name = name, 47 | coordinates = coordinates, 48 | line_element = line_element, 49 | transform = transform_functions, 50 | M = 1 51 | ) 52 | 53 | Note that we have passed an additional argument to the Metric constructor ``(..., M = 1)`` by which we have set to unity the value of the parameter :math:`M` in the metric. 54 | In PyGRO, constant parameters should always be assigned a numerical value. If no argument ``M`` is passed to the constructor, the user will be prompted to insert one as input: 55 | 56 | .. code-block:: none 57 | 58 | >>> Insert value for M: 59 | 60 | During initialization the code will inform the user about the current state of initialization through the standard output: 61 | 62 | .. code-block:: none 63 | 64 | (PyGRO) INFO: Initializing Schwarzschild spacetime. 65 | (PyGRO) INFO: Calculating inverse metric. 66 | (PyGRO) INFO: Calculating symbolic equations of motion. 67 | (PyGRO) INFO: Computing helper function to normalize 4-velocity. 68 | (PyGRO) INFO: The Metric (Schwarzschild spacetime) has been initialized. 69 | 70 | The :meth:`~pygro.metric_engine.Metric` performs tensorial operations on the newly generated metric tensor :math:`g_{\mu\nu}` (accessible via :attr:`Metric.g`) for computing: 71 | 72 | * The inverse metric, accessible via :attr:`Metric.g_inv`; 73 | * The geodesic equations, representing the right-hand side in equation 74 | .. math:: 75 | 76 | \ddot{x}^\mu = - \Gamma^{\mu}_{\nu\rho}\dot{x}^\nu\dot{x}^\rho 77 | 78 | where, :math:`\Gamma^{\mu}_{\nu\rho}` are the Christoffel symbols accessible via :meth:`~pygro.metric_engine.Metric.Christoffel`. 79 | These four equations are stored into a list accessible via :attr:`Metric.eq_u`. 80 | * Two symbolic algebraic expressions for the :math:`\dot{x}^0` component of the four velocity derived from the normalization conditions: 81 | .. math:: 82 | 83 | g_{\mu\nu}\dot{x}^\mu\dot{x}^\nu = \left\{\begin{array}{ll} 84 | &-1&\qquad\textrm{time-like curve}\\ 85 | &0&\qquad\textrm{null curve}\\ 86 | \end{array}\right. 87 | 88 | These are particularly useful when one needs to retrieve the time-like component of the four-velocity of a massive particle (or, equivalently, the time-like component of a photon wave-vector) 89 | knowing the spatial components of the velocity (which is usually the case). See :doc:`integrate_geodesic` for a working example. 90 | 91 | Changing the value of the metric parameters 92 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 93 | 94 | The values of the parameters appearing in the :py:class:`.Metric` object must be set at moment of the declaration of the metric. However, through the :py:meth:`~pygro.metric_engine.Metric.set_constant` method one can change at any time the value of the parameters stored in the metric. This is useful when one wants to see how the geodesics in a given space-time change depending on the parameters appearing in the metric tensor. The arguments of :py:meth:`~pygro.metric_engine.Metric.set_constant` must specify the name of the parameter that one wants to change along with the new value of the parameters. For example, in the previously defined :py:class:`.Metric` we can change the value of the mass from 1 to 2 by running 95 | 96 | .. code-block:: 97 | 98 | metric.set_constant(M = 2) 99 | 100 | 101 | Auxiliary expressions approach 102 | ------------------------------- 103 | .. _aux-expr: 104 | 105 | In this section we review a different symbolic approach to generate a :meth:`~pygro.metric_engine.Metric` object which, differently than 106 | before, relies on an auxiliary function which has a closed analytical form. Suppose, for the sake of simplicity, that one desires to generate 107 | the same Schwarzschild metric that has been computed in the :ref:`Purely symbolic approach `, but instead of defining it purely symbolically, 108 | one wants to write it using the following expression 109 | 110 | .. math:: 111 | ds^2 = -A(r)dt^2+\frac{1}{A(r)}dr^2+r^2(d\theta^2+\sin^2\theta d\phi^2), 112 | 113 | where: 114 | 115 | .. math:: 116 | A(r) = \left(1-\frac{2M}{r}\right). 117 | 118 | Clearly the new expression is formally equivalent to that in the previous section and one might think that this reformulation is not useful. However, for much more complicated metrics, having the possibility to inject into the metric auxiliary functions whose actual analytic expression is indicated elsewhere can be really useful and allow for neater formulation of the problem. For this reason, in PyGRO a functionality to accept auxiliary functions as part of the metric expression has been introduced. It can be easily accessed by specifying the auxiliary function and its dependency from the spacetime coordinates (e.g. ``A(r)`` in our case) in the ``line_element`` and later passing as additional keyword argument, whose name is the functional part of the function, to the :meth:`~pygro.metric_engine.Metric` constructor a ``str`` containing the symbolic expression of the function (e.g. ``(..., A = "1-2*M/r")``). Again, any constant parameter that is used in the auxiliary expression must be specified as additional keyword argument (e.g. ``(..., M = 1)``). 119 | 120 | Here is what a :meth:`~pygro.metric_engine.Metric` initialization would look like in this case: 121 | 122 | .. code-block:: 123 | 124 | name = "Schwarzschild spacetime" 125 | coordinates = ["t", "r", "theta", "phi"] 126 | 127 | transform_functions = [ 128 | "t", 129 | "r*sin(theta)*cos(phi)", 130 | "r*sin(theta)*sin(phi)", 131 | "r*cos(theta)" 132 | ] 133 | 134 | line_element = "-A(r)*dt**2+1/A(r)*dr**2+r**2*(dtheta**2+sin(theta)**2*dphi**2)" 135 | 136 | A = "1-2*M/r" 137 | 138 | metric = pygro.Metric( 139 | name = name, 140 | coordinates = coordinates, 141 | line_element = line_element, 142 | transform = transform_functions, 143 | A = A, 144 | M = 1 145 | ) 146 | 147 | .. note:: 148 | 149 | Auxiliary expression can also rely on *other* auxiliary expressions, as long as on metric initialization they are all properly passed to the :meth:`~pygro.metric_engine.Metric` constructor. For example, the previous metric could also be defined as ``line_element = "-A(r)*dt**2+B(r)*dr**2+r**2*(dtheta**2+sin(theta)**2*dphi**2)"``, provided that the initialization is done with ``metric = pygro.Metric(..., line_element = line_element, transform = transform_functions, A = "1-2*M/r", B = "1/A(r)", M = 1)``. 150 | 151 | Auxiliary functions approach 152 | ------------------------------- 153 | .. _aux-func: 154 | 155 | Finally, we have a last approach for the metric initialization, which relies on auxiliary ``pyfunc`` methods as parts of the line element. This approach is particularly useful when we wish to introduce in the metric functions of the coordinates that do not have an analytic expression and rely on, for example, the solution of an integral or on an interpolated/tabulated function which is not available within the ``sympy`` module. This approach allows to use any function defined in the ``__main__`` body of your script as auxiliary function. 156 | 157 | .. caution:: 158 | We suggest using the *Auxiliary functions approach* only when strictly dictated by the problem you want to solve, i.e. only if it is necessary to rely on an external function that cannot be expressed symbolically with an analytic expression. This is because PyGRO reaches its best performances when integrating geodesic equations expressed in a completely symbolic way. More specifically, upon linking of a :meth:`~pygro.metric_engine.Metric` element to a :meth:`~pygro.geodesic_engine.GeodesicEngine`, PyGRO makes use of the built-in ``sympy`` method ``autowrap``, which converts the call to a specific symbolic expression into a C-precompiled binary executable, whereas when presented with a non symbolic expression, it relies on the native-Python ``sympy`` method ``lambdify``. The former **drastically** improves the integration performances. 159 | 160 | In order to correctly initialize a metric using the *Auxiliary functions approach* the user must take into account the fact that Christoffel symbols and, hence, geodesic equations are computed from the derivatives of the metric coefficients. This means that, while in the purely symbolic approaches the :meth:`~pygro.metric_engine.Metric` deals autonomously with the computation of such derivatives, in the auxiliary functions approach the user should not only pass to the :meth:`~pygro.metric_engine.Metric` constructor the ``pyfunc`` corresponding to the auxiliary functions reported in the line element, but also its derivatives with respect to all the coordinates on which it explicitly depends. These must be passed as keyword arguments to the metric constructor corresponding to the following syntax: 161 | 162 | > ``"A(r)" -> Metric(..., A = [...], dAdr = [...])`` 163 | 164 | It is important to notice that the ``pyfunc`` to pass to the metric must be defined as a method depending on four arguments, one for each coordinate, that has to be ordered exactly as the coordinates of the metric. 165 | 166 | Here, for example, we initialize the same Schwarzschild metric of the previous examples but using the auxiliary functions approach: 167 | 168 | .. code-block:: 169 | 170 | name = "Schwarzschild spacetime" 171 | coordinates = ["t", "r", "theta", "phi"] 172 | line_element = "-A(r)*dt**2+1/A(r)*dr**2+r**2*(dtheta**2+dphi**2)" 173 | transform = [ 174 | "t", 175 | "r*sin(theta)*cos(phi)", 176 | "r*sin(theta)*sin(phi)", 177 | "r*cos(theta)" 178 | ] 179 | 180 | def A(t, r, theta, phi): 181 | M = metric.get_constant("M") 182 | return 1-2*M/r 183 | 184 | def dAdr(t, r, theta, phi): 185 | M = metric.get_constant("M") 186 | return 2*M/r**2 187 | 188 | metric = pygro.Metric( 189 | name = name, 190 | line_element = line_element, 191 | coordinates = coordinates, 192 | A = A, 193 | dAdr = dAdr, 194 | transform = transform 195 | ) 196 | 197 | metric.add_parameter("M", 1) 198 | 199 | .. note:: 200 | Notice how we have made use of the :meth:`~pygro.metric_engine.Metric.get_constant` method of the ``Metric`` class to access the `M` parameter inside the metric. In particular, since now the symbolic expression of the line element does not contain any :math:`M`, we had to manually add this parameter to the metric by using the :meth:`~pygro.metric_engine.Metric.add_parameter` method. Using this approach, now we can link symbolic parameters of the metric to ones that we need to access from the auxiliary functions. -------------------------------------------------------------------------------- /pygro/orbit.py: -------------------------------------------------------------------------------- 1 | from pygro import Geodesic, GeodesicEngine, Metric 2 | from scipy.optimize import fsolve, root 3 | import sympy as sp 4 | import numpy as np 5 | import sympy as sp 6 | import logging 7 | from .utils.rotations import * 8 | 9 | from typing import Optional, Literal 10 | 11 | class Orbit: 12 | """ 13 | Base class for integrating physical orbits in spherically symmetric spacetimes. It is a wrapper around the :py:class:`.Geodesic` object for the ``time-like`` case with additional methods to assign initial conditions based on the standard Keplerian parameters in spherically symmetric space-times. 14 | """ 15 | def __init__(self, geo_engine: Optional[GeodesicEngine] = None, verbose: bool = False, orbital_parameters : Optional[dict] = None, radial_coordinate : Optional[sp.Basic] = None, latitude : Optional[sp.Basic] = None, longitude : Optional[sp.Basic] = None): 16 | r""" 17 | The :py:class:`.Orbit` constructor accepts the following arguments: 18 | 19 | :param geo_engine: The :py:class:`.GeodesicEngine` object to link to the :py:class:`.Orbit`. If not provided, the :py:class:`Geodesic` will be linked to the last initialized :py:class:`.GeodesicEngine`. 20 | :type geo_engine: Optional[GeodesicEngine] 21 | :param verbose: Specifies whether to log information on the geodesic status to the standard output. 22 | :type verbose: bool 23 | :param orbital_parameters: An optional dictionary to be passed to the :py:meth:`~pygro.orbit.Orbit.set_orbital_parameters` method. See the documentation of this method for the exact details of what to include in the dictionary. 24 | :type orbital_parameters: Optional[dict] 25 | :param radial_coordinate: When initializing the :py:class:`.Orbit`, it will assume by the default that the radial coordinate in the metric will be the first component of the coordinate array (*i.e.* the content of ``Metric.x[1]``). This can be overridden by specifically passing the symbol corresponding to the radial coordinate. 26 | :type radial_coordinate: Optional[sp.Basic] 27 | :param latitude: When initializing the :py:class:`.Orbit`, it will assume by the default that the latitude angular coordinate (:math:`\theta` in the usual Schwarzschild coordinates, see :doc:`create_metric`) in the metric will be the second component of the coordinate array (*i.e.* the content of ``Metric.x[2]``). This can be overridden by specifically passing the symbol corresponding to the latitude. 28 | :type latitude: Optional[sp.Basic] 29 | :param longitude: When initializing the :py:class:`.Orbit`, it will assume by the default that the longitude angular coordinate (:math:`\phi` in the usual Schwarzschild coordinates, see :doc:`create_metric`) in the metric will be the third component of the coordinate array (*i.e.* the content of ``Metric.x[3]``). This can be overridden by specifically passing the symbol corresponding to the longitude. 30 | :type longitude: Optional[sp.Basic] 31 | 32 | After initialization, the :py:class:`.Orbit` instance has the following attributes: 33 | 34 | :ivar params: The dictionary containing the orbital parameters 35 | :vartype params: dict 36 | :ivar V_eff: A callable which returns the effective potential of the orbit as a function of the orbital energy, angular momentum and of the radial coordinate :math:`V_\textrm{eff} = V_\textrm{eff}(E, L, r)`. 37 | :vartype V_eff: Callable 38 | :ivar E: The value of the orbital energy of the orbit that corresponds to the given orbital parameters. 39 | :vartype E: float 40 | :ivar L: The value of the orbital angular momentum of the orbit that corresponds to the given orbital parameters. 41 | :vartype L: float 42 | """ 43 | if not geo_engine: 44 | geo_engine = GeodesicEngine.instances[-1] 45 | logging.warning(f"No GeodesicEngine object passed to the Geodesic constructor. Using last initialized GeodesicEngine instead") 46 | 47 | if not isinstance(geo_engine, GeodesicEngine): 48 | raise TypeError("No GeodesicEngine found, initialize one and pass it as argument to the Orbit constructor") 49 | 50 | self.geo_engine : GeodesicEngine = geo_engine 51 | self.metric : Metric = geo_engine.metric 52 | 53 | self._t, self._r, self._theta, self._phi = self.metric.x 54 | self._u_t, self._u_r, self._u_theta, self._u_phi = self.metric.u 55 | 56 | if self.metric.Lagrangian().diff(self._t) != 0: 57 | raise ValueError("Space-time is not stationary.") 58 | 59 | if self.metric.Lagrangian().diff(self._phi) != 0: 60 | raise ValueError("Space-time is not azimuthally symmetric.") 61 | 62 | if (radial_coordinate is not None): 63 | if (radial_coordinate in self.metric.x): 64 | self._r = radial_coordinate 65 | self._u_r = self.metric.u[self.metric.x.index(radial_coordinate)] 66 | else: 67 | raise ValueError(f"Cannot set {str(radial_coordinate)} as radial coordinate") 68 | 69 | if (latitude is not None): 70 | if (latitude in self.metric.x): 71 | self._theta = latitude 72 | self._u_theta = self.metric.u[self.metric.x.index(latitude)] 73 | else: 74 | raise ValueError(f"Cannot set {str(latitude)} as latitude") 75 | 76 | if (longitude is not None): 77 | if (longitude in self.metric.x): 78 | self._phi = longitude 79 | self._u_phi = self.metric.u[self.metric.x.index(longitude)] 80 | else: 81 | raise ValueError(f"Cannot set {str(longitude)} as longitude") 82 | 83 | if orbital_parameters: 84 | self.set_orbital_parameters(**orbital_parameters) 85 | 86 | self.geo = Geodesic('time-like', self.geo_engine, verbose) 87 | 88 | @property 89 | def params(self) -> dict: 90 | return self._params 91 | 92 | @params.setter 93 | def params(self, params): 94 | self._params : dict = params 95 | for k, v in params.items(): 96 | setattr(self, k, v) 97 | 98 | def set_orbital_parameters(self, t_P: Optional[float] = None, a: Optional[float] = None, e: Optional[float] = None, i: Optional[float] = None, omega: Optional[float] = None, Omega: Optional[float] = None, t_A: Optional[float] = None): 99 | r""" 100 | Sets the orbital parameters that uniquely identify the orbit on the equatorial plane. 101 | 102 | For a detailed explaination on how a choice of orbital parameters is translated into intial conditions for the geodesic, see the :doc:`integrating_orbits` tutorial and the example notebooks :doc:`examples/Schwarzschild-ISCO` and :doc:`examples/Schwarzschild-Precession`. 103 | 104 | The parametrization that we choose is the usual one in celestial mechanics: 105 | 106 | * :math:`t_p` (``t_P``): coordinate time of pericenter passage. Alternatively, one can specify the coordinate time of apocenter passge :math:`t_a` (``t_A``). Assigning one (and only one) of these parameters is **mandatory** and will raise an error if not assigned. Depending on the choice between ``t_P`` and ``t_A`` the integration will be started at pericenter or apocenter. 107 | * :math:`a` (``a``): The semi-major axis of the orbit in the same units in which the radial coordiante in the metrix is expressed. This parameter is **mandatory** and will raise an error if not assigned. 108 | * :math:`e` (``e``): The orbital eccentricity, which fixes the radial turning points :math:`r_p=a(1-e)` and :math:`r_a=a(1+e)` (see :doc:`integrating_orbits`). This parameter is **mandatory** and will raise an error if not assigned. 109 | * :math:`(i,\,\omega,\,\Omega)` (``i, omega, Omega``): the three angular orbital parameters (in radians) which will rotate the initial conditions out of the equatorial plane. See :doc:`integrating_orbits` for a proper definition of these parameters. These parameters are optional. If not specified they will be fixed to zero, leaving the orbit on the equatorial plane (:math:`\theta = \pi/2` or the corresponding latitude coordinate). 110 | 111 | """ 112 | 113 | if a is None: 114 | raise ValueError("Semi-major not specified.") 115 | elif a <= 0: 116 | raise ValueError("Semi-major axis must be a > 0") 117 | 118 | if sum(t is not None for t in [t_P, t_A]) != 1: 119 | raise ValueError("Must specify one (and only one) between time of pericenter passage (t_P) and time of apocenter passage (t_A).") 120 | 121 | if e is None: 122 | raise ValueError("Eccentricity not specified.") 123 | elif e < 0: 124 | raise ValueError("Eccentricity must be > 0") 125 | elif e >= 1: 126 | raise ValueError("Eccentricity must be <= 1") 127 | 128 | if Omega is None: 129 | Omega = 0 130 | logging.warning("Longitude of ascending node not specified, set to 0.") 131 | 132 | if omega is None: 133 | omega = 0 134 | logging.warning("Argument of the pericenter not specified, set to 0.") 135 | 136 | if i is None: 137 | i = 0 138 | logging.warning("Inclination not specified, set to 0.") 139 | 140 | if t_P is not None: 141 | self._at = "peri" 142 | self.params = dict( 143 | t_P = t_P, a = a, e = e, i = i, omega = omega, Omega = Omega 144 | ) 145 | elif t_A is not None: 146 | self._at = "apo" 147 | self.params = dict( 148 | t_A = t_A, a = a, e = e, i = i, omega = omega, Omega = Omega 149 | ) 150 | 151 | def _compute_initial_conditions(self, initial_conditions_tol: float = 1e-12): 152 | 153 | r_P = self.a*(1-self.e) 154 | r_A = self.a*(1+self.e) 155 | 156 | Lagrangian = self.metric.subs_functions(self.metric.Lagrangian()).subs([(self._theta, sp.pi/2), (self._u_theta, 0)]) 157 | 158 | E = -Lagrangian.diff(self._u_t) 159 | L = Lagrangian.diff(self._u_phi) 160 | 161 | E_s, L_s = sp.symbols("E L") 162 | 163 | u_t_E = sp.solve(E-E_s, self._u_t)[0] 164 | u_phi_L = sp.solve(L-L_s, self._u_phi)[0] 165 | 166 | u_r2_lagr = sp.solve(Lagrangian.subs([(self._u_t, u_t_E), (self._u_phi, u_phi_L)]) + 1/2, self._u_r**2, simplify=False)[0] 167 | 168 | u_r2_func = sp.lambdify([E_s, L_s, self._r, *self.metric.get_parameters_symb()], u_r2_lagr) 169 | u_r2_prime_func = sp.lambdify([E_s, L_s, self._r, *self.metric.get_parameters_symb()], u_r2_lagr.diff(self._r)) 170 | 171 | u_t_func = sp.lambdify([E_s, L_s, self._r, *self.metric.get_parameters_symb()], u_t_E) 172 | u_phi_func = sp.lambdify([E_s, L_s, self._r, *self.metric.get_parameters_symb()], u_phi_L) 173 | 174 | self.V_eff = sp.lambdify([E_s, L_s, self._r], self.metric.evaluate_parameters(self.metric.subs_functions(u_r2_lagr))) 175 | 176 | def u_r2(EL): 177 | E, L = EL 178 | if self.e > 0: 179 | return u_r2_func(E, L, r_P, *self.metric.get_parameters_val()), u_r2_func(E, L, r_A, *self.metric.get_parameters_val()) 180 | elif self.e == 0: 181 | return u_r2_prime_func(E, L, r_P, *self.metric.get_parameters_val()), u_r2_func(E, L, r_P, *self.metric.get_parameters_val()) 182 | 183 | # Computing Energy and Angular momentum from the keplerian orbit as initial guessess for the root search 184 | 185 | GM = float(sp.limit(self.metric.evaluate_parameters((self.metric.subs_functions(self.metric.g[0,0])+1)/(2/self._r)), self._r, sp.oo)) 186 | 187 | keplerian_T = np.sqrt(4*np.pi**2*self.a**3/GM) 188 | 189 | keplerian_u_phi_0 = 2*np.pi/keplerian_T*self.a**2/r_P**2*np.sqrt(1-self.e**2) 190 | keplerian_u_t_0 = self.geo_engine.u0_f_timelike([0, r_P, np.pi/2, 0], 0, 0, keplerian_u_phi_0) 191 | 192 | E_0_proposal = self.metric.get_evaluable_function(E)(r_P, keplerian_u_t_0) 193 | L_0_proposal = self.metric.get_evaluable_function(L)(r_P, keplerian_u_phi_0) 194 | 195 | sol = root(u_r2, [E_0_proposal, L_0_proposal], tol = initial_conditions_tol, method = 'lm') 196 | 197 | self._sol = sol 198 | 199 | if (not all(np.array(u_r2(sol.x)) <= initial_conditions_tol)) or self.e*u_r2_prime_func(*sol.x, r_P, *self.metric.get_parameters_val())*u_r2_prime_func(*sol.x, r_A, *self.metric.get_parameters_val()) > 0: 200 | logging.warning("Could not find stable initial conditions with current orbital parameters. Falling back to approximate Keplerian initial conditions.") 201 | E_0, L_0 = E_0_proposal, L_0_proposal 202 | else: 203 | E_0, L_0 = sol.x 204 | 205 | self.E, self.L = E_0, L_0 206 | 207 | r_0 = r_P if self._at == "peri" else -r_A 208 | 209 | u_t_0 = abs(u_t_func(E_0, L_0, abs(r_0), *self.metric.get_parameters_val())) 210 | u_phi_0 = abs(u_phi_func(E_0, L_0, abs(r_0), *self.metric.get_parameters_val())) 211 | 212 | x0 = r_0 213 | y0 = 0 214 | z0 = 0 215 | 216 | vx0 = 0 217 | vy0 = r_0*u_phi_0/u_t_0 218 | vz0 = 0 219 | 220 | r0 = np.array([x0, y0, z0]) 221 | v0 = np.array([vx0, vy0, vz0]) 222 | 223 | R1 = Rotate_z(self.omega) 224 | R2 = Rotate_x(self.i) 225 | R3 = Rotate_z(self.Omega) 226 | 227 | self._r0 = R3@R2@R1@r0 228 | self._v0 = R3@R2@R1@v0 229 | self._ut0 = u_t_0 230 | self._t0 = self.t_P if self._at == "peri" else self.t_A 231 | 232 | def integrate(self, tauf : float | int, initial_step : float | int, verbose : bool = False, direction : Literal["bw", "fw"] = "fw", initial_conditions_tol : float = 1e-15, **integration_kwargs): 233 | """ 234 | A wrapper around the :py:meth:`~pygro.geodesic_engine.GeodesicEngine.integrate` method. 235 | 236 | It accepts the same arguments plus: 237 | 238 | :param initial_conditions_tol: sets the numerical tolerance for the root finding algorithm of the initial conditions. 239 | :type initial_conditions_tol: float 240 | 241 | This method sets the initial conditions of the integration starting from the orbital parameters. In case the procedure is not successfull it falls back to the classical Keplerian initial conditions (alerting the user that this has been the case). 242 | 243 | """ 244 | if hasattr(self, 'params'): 245 | self._compute_initial_conditions(initial_conditions_tol = initial_conditions_tol) 246 | else: 247 | raise Exception("Orbital parameters have not been set.") 248 | 249 | r0, theta0, phi0 = cartesian_to_spherical_point(*self._r0) 250 | vr0, vtheta0, vphi0 = cartesian_to_spherical_vector(*self._r0, *self._v0) 251 | 252 | self.geo.set_starting_point(self._t0, r0, theta0, phi0) 253 | self.geo.initial_u = [self._ut0, vr0*self._ut0, vtheta0*self._ut0, vphi0*self._ut0] 254 | 255 | self.geo_engine.integrate(self.geo, tauf, initial_step, verbose, direction, **integration_kwargs) -------------------------------------------------------------------------------- /pygro/geodesic_engine.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Literal, Callable, TYPE_CHECKING, get_args 2 | import numpy as np 3 | import numpy.typing as npt 4 | import time 5 | import sympy as sp 6 | import pygro.integrators as integrators 7 | from pygro.integrators import AVAILABLE_INTEGRATORS 8 | from pygro.metric_engine import Metric 9 | if TYPE_CHECKING: 10 | from pygro.geodesic import Geodesic 11 | from sympy.utilities.autowrap import autowrap 12 | from sympy.utilities.lambdify import lambdify 13 | import logging 14 | 15 | _BACKEND_TYPES = Literal["autowrap", "lambdify"] 16 | 17 | class GeodesicEngine(): 18 | r""" 19 | The main numerical class in PyGRO. It deals with performing the numerical operations to integrate a :py:class:`.Geodesic` object. 20 | 21 | After linking a :py:class:`.Metric` to the :py:class:`GeodesicEngine`, the latter will have the following attributes: 22 | 23 | :ivar motion_eq: A callable of the coordinates and their derivatives which returns the right-hand-side of the geodesic equations (casted to a first-order system of ODEs). For example, given the coordinates ``["t", "x", "y", "z"]``, the ``motion_eq`` will be of type ``(t, x, y, z, u_t, u_x, u_y, u_z) -> Iterable[float]`` of dimension 8. 24 | :vartype motion_eq: Iterable[Callable] 25 | :ivar u[i]_f_timelike: a helper function that returns the numerical value of the *i*-th component of the 4-velocity of a massive test particle as a function of the others, which normalizes the 4-velocity to :math:`g_{\mu\nu}\dot{x}^\mu\dot{x}^\nu = -1` 26 | :vartype u[i]_f_timelike: Callable[Iterable[float], float] 27 | :ivar u[i]_f_null: a helper function that returns the numerical value of the *i*-th component of the 4-velocity of a null test particle as a function of the others, which normalizes the 4-velocity to :math:`g_{\mu\nu}\dot{x}^\mu\dot{x}^\nu = 0` 28 | :vartype u[i]_f_null: Callable[Iterable[float], float] 29 | :ivar stopping_criterion: a :py:class:`.StoppingCriterionList` representing the list of conditions to check at each integration step to determine whether to stop the integration or not. 30 | :vartype stopping_criterion: StoppingCriterionList 31 | """ 32 | instances = [] 33 | 34 | def __init__(self, metric: Optional[Metric] = None, backend: _BACKEND_TYPES = "autowrap", integrator : AVAILABLE_INTEGRATORS = "rkf45"): 35 | r""" 36 | The :py:class:`GeodesicEngine` constructor accepts the following arguments: 37 | 38 | :param metric: The :py:class:`.Metric` object to link to the :py:class:`GeodesicEngine` and from which the geodesic equations and all the related symbolic quantities are retrieved. If not provided, the :py:class:`GeodesicEngine` will be linked to the last initialized :py:class:`.Metric`. 39 | :type metric: Metric 40 | :param backend: The symbolic backend to use. If ``autowrap`` (defaul option) the symbolic expression will be converted, upon linking the :py:class:`.Metric` to the :py:class:`GeodesicEngine`, in pre-compiled C low-level callable with a consitent gain in performance of single function-calls. If ``lambdify`` the geodesic equations will not be compiled as C callables and will be converted to Python callables, with a loss in performances. The :py:class:`GeodesicEngine` will fallback to a ``lambdify`` whenever auxiliary py-functions are used to define the :py:class:`.Metric` object (see :doc:`create_metric` for an example). 41 | :type backend: Literal["autowrap", "lambdify"] 42 | :param integrator: Specifies the numerical integration schemes used to carry out the geodesic integration. See :doc:`integrators`. Default is ``rkf45`` correspoding to the :py:class:`.RungeKuttaFehlberg45` integrator. 43 | :type integrator: Literal['rkf45', 'dp45', 'ck45', 'rkf78'] 44 | """ 45 | if metric == None: 46 | if len(Metric.instances) > 0: 47 | if isinstance(Metric.instances[-1], Metric): 48 | metric = Metric.instances[-1] 49 | logging.warning(f"No Metric object passed to the Geodesic Engine constructor. Using last initialized Metric ({metric.name}) instead.") 50 | else: 51 | raise ValueError("No Metric found, initialize one and pass it as argument to the GeodesicEngine constructor.") 52 | 53 | if not integrator in (valid_integrators := get_args(AVAILABLE_INTEGRATORS)): 54 | raise ValueError(f"'integrator' must be one of {valid_integrators}") 55 | 56 | self.link_metric(metric, backend) 57 | self._integrator = integrator 58 | GeodesicEngine.instances.append(self) 59 | 60 | def link_metric(self, g: Metric, backend: _BACKEND_TYPES): 61 | 62 | if not backend in (valid_backends := get_args(_BACKEND_TYPES)): 63 | raise ValueError(f"'backend' must be one of {valid_backends}") 64 | 65 | logging.info(f"Linking {g.name} to the Geodesic Engine") 66 | 67 | self.metric = g 68 | self.eq_x = g.eq_x 69 | self.eq_u = g.eq_u 70 | 71 | self.metric._geodesic_engine_linked = True 72 | self.metric.geodesic_engine = self 73 | 74 | if (backend == "lambdify") or len(self.metric.get_parameters_functions()) > 0: 75 | self._wrapper = "lambdify" 76 | else: 77 | self._wrapper = "autowrap" 78 | 79 | self._motion_eq_f = [] 80 | 81 | for eq in [*self.eq_x, *self.eq_u]: 82 | if self._wrapper == "autowrap": 83 | try: 84 | self._motion_eq_f.append(autowrap(self.metric.subs_functions(eq), backend='cython', args = [*self.metric.x, *self.metric.u, *self.metric.get_parameters_symb()])) 85 | except OSError: 86 | logging.warning("Falling back to 'lambdify' backend because the current platform is not compatible with 'autowrap'. This may affect performances.") 87 | self._wrapper = "lambdify" 88 | self._motion_eq_f.append(lambdify([*self.metric.x, *self.metric.u, *self.metric.get_parameters_symb()], self.metric.subs_functions(eq))) 89 | elif self._wrapper == "lambdify": 90 | self._motion_eq_f.append(lambdify([*self.metric.x, *self.metric.u, *self.metric.get_parameters_symb()], self.metric.subs_functions(eq))) 91 | 92 | def _f_eq(tau, xu): 93 | return np.array([self._motion_eq_f[i](*xu, *self.metric.get_parameters_val()) for i in range(8)]) 94 | 95 | self.motion_eq = _f_eq 96 | 97 | u0_f_timelike = lambdify([*self.metric.x, self.metric.u[1], self.metric.u[2], self.metric.u[3], *self.metric.get_parameters_symb()], self.metric.subs_functions(g.u0_s_timelike)) 98 | self.u0_f_timelike = lambda initial_x, u1, u2, u3: abs(u0_f_timelike(*initial_x, u1, u2, u3, *self.metric.get_parameters_val())) 99 | u1_f_timelike = lambdify([*self.metric.x, self.metric.u[0], self.metric.u[2], self.metric.u[3], *self.metric.get_parameters_symb()], self.metric.subs_functions(g.u1_s_timelike)) 100 | self.u1_f_timelike = lambda initial_x, u0, u2, u3: abs(u1_f_timelike(*initial_x, u0, u2, u3, *self.metric.get_parameters_val())) 101 | u2_f_timelike = lambdify([*self.metric.x, self.metric.u[0], self.metric.u[1], self.metric.u[3], *self.metric.get_parameters_symb()], self.metric.subs_functions(g.u2_s_timelike)) 102 | self.u2_f_timelike = lambda initial_x, u0, u1, u3: abs(u2_f_timelike(*initial_x, u0, u1, u3, *self.metric.get_parameters_val())) 103 | u3_f_timelike = lambdify([*self.metric.x, self.metric.u[0], self.metric.u[1], self.metric.u[2], *self.metric.get_parameters_symb()], self.metric.subs_functions(g.u3_s_timelike)) 104 | self.u3_f_timelike = lambda initial_x, u0, u1, u2: abs(u3_f_timelike(*initial_x, u0, u1, u2, *self.metric.get_parameters_val())) 105 | 106 | u0_f_null = lambdify([*self.metric.x, self.metric.u[1], self.metric.u[2], self.metric.u[3], *self.metric.get_parameters_symb()], self.metric.subs_functions(g.u0_s_null)) 107 | self.u0_f_null = lambda initial_x, u1, u2, u3: abs(u0_f_null(*initial_x, u1, u2, u3, *self.metric.get_parameters_val())) 108 | u1_f_null = lambdify([*self.metric.x, self.metric.u[0], self.metric.u[2], self.metric.u[3], *self.metric.get_parameters_symb()], self.metric.subs_functions(g.u1_s_null)) 109 | self.u1_f_null = lambda initial_x, u0, u2, u3: abs(u1_f_null(*initial_x, u0, u2, u3, *self.metric.get_parameters_val())) 110 | u2_f_null = lambdify([*self.metric.x, self.metric.u[0], self.metric.u[1], self.metric.u[3], *self.metric.get_parameters_symb()], self.metric.subs_functions(g.u2_s_null)) 111 | self.u2_f_null = lambda initial_x, u0, u1, u3: abs(u2_f_null(*initial_x, u0, u1, u3, *self.metric.get_parameters_val())) 112 | u3_f_null = lambdify([*self.metric.x, self.metric.u[0], self.metric.u[1], self.metric.u[2], *self.metric.get_parameters_symb()], self.metric.subs_functions(g.u3_s_null)) 113 | self.u3_f_null = lambda initial_x, u0, u1, u2: abs(u3_f_null(*initial_x, u0, u1, u2, *self.metric.get_parameters_val())) 114 | 115 | self.stopping_criterion = StoppingCriterionList([]) 116 | 117 | logging.info("Metric linking complete.") 118 | 119 | def set_stopping_criterion(self, expr: str, exit_str: str = "none"): 120 | r""" 121 | Creates a :py:class:`.StoppingCriterion` given the symbolic expression contained in the ``expr`` argument and sets is as the sole stopping criterion for the :py:class:`GeodesicEngine`. 122 | 123 | :param expr: a string to be sympy-parsed containing the expression that will be evaluated at each integration time-step. 124 | :type expr: str 125 | :param exit_str: the exit message that will be attached to the :py:class:`.Geodesic` when the stopping condition is met (*i.e.* when the condition in ``expr`` return ``False``). 126 | :type exit_str: str 127 | """ 128 | self.stopping_criterion = StoppingCriterionList([]) 129 | self.add_stopping_criterion(expr, exit_str) 130 | 131 | def add_stopping_criterion(self, expr: str, exit_str: str = "none"): 132 | r""" 133 | Adds a :py:class:`.StoppingCriterion` to the :py:class:`.StoppingCriterionList` of the :py:class:`.GeodesicEngine`. Has the same parameters as :py:func:`set_stopping_criterion` 134 | """ 135 | expr_s = sp.parse_expr(expr) 136 | free_symbols = list(expr_s.free_symbols-set(self.metric.x)) 137 | check = True 138 | for symbol in free_symbols: 139 | if symbol in self.metric.get_parameters_symb(): 140 | check &= True 141 | if check == True: 142 | stopping_criterion = sp.lambdify([*self.metric.x, *self.metric.u, *self.metric.get_parameters_symb()], expr_s) 143 | def f(*xu): 144 | return stopping_criterion(*xu, *self.metric.get_parameters_val()) 145 | 146 | self.stopping_criterion.append(StoppingCriterion(f, expr, exit_str)) 147 | else: 148 | raise TypeError(f"Unkwnown symbol {str(symbol)}") 149 | 150 | def integrate(self, geo: 'Geodesic', tauf: float, initial_step: float, verbose: bool = False, direction: Literal["fw", "bw"] = "fw", **integrator_kwargs) -> None: 151 | """ 152 | The main function for the numerical integration. Accepts an initialized :py:class:`.Geodesic` (*i.e.* with appropriately set initial space-time position and 4-velocity), performs the numerical integration of the geodesic equations up to the provided final value of the affine parameter ``tauf`` or until a stopping condition is met, and returns to the input :py:class:`.Geodesic` object the integrated numerical array. 153 | 154 | See :doc:`integrate_geodesic` for a complete tutorials. 155 | 156 | :param geo: the :py:class:`.Geodesic` to be integrated. 157 | :type geo: Geodesic 158 | :param tauf: the value of the final proper time at which to stop the numerical integration. 159 | :type tauf: float 160 | :param initial_step: the value of the initial proper time step. 161 | :type initial_step: float 162 | :param verbose: whether to log the integration progress to the standard output or not. 163 | :type verbose: bool 164 | :param direction: if "fw" the integration is carried on forward in proper time; if "bw" the integration is carried on backward in proper time. The correct signs for ``tauf`` and ``initial_step`` will be assigned automatically. 165 | :type direction: Literal["fw", "bw"] 166 | :param integrator_kwargs: the kwargs to be passed to the integrator. 167 | """ 168 | if not direction in ["fw", "bw"]: 169 | raise TypeError("direction must be either 'fw' or 'bw'.") 170 | if verbose: 171 | logging.info("Starting integration.") 172 | 173 | integrator = integrators.get_integrator(self._integrator, self.motion_eq, stopping_criterion = self.stopping_criterion, **integrator_kwargs) 174 | 175 | if direction == "bw": 176 | h = -abs(initial_step) 177 | tauf = -abs(tauf) 178 | else: 179 | h = abs(initial_step) 180 | tauf = abs(tauf) 181 | 182 | if verbose: 183 | time_start = time.perf_counter() 184 | 185 | tau, xu, exit, interpolator = integrator.integrate(0, tauf, np.array([*geo.initial_x, *geo.initial_u]), h) 186 | 187 | if verbose: 188 | time_elapsed = (time.perf_counter() - time_start) 189 | logging.info(f"Integration completed in {time_elapsed:.5} s with result '{exit}'.") 190 | 191 | geo.tau = tau 192 | geo.x = np.stack(xu[:,:4]) 193 | geo.u = np.stack(xu[:,4:]) 194 | geo.exit = exit 195 | geo.interpolator = interpolator 196 | 197 | class StoppingCriterion: 198 | r""" 199 | An helper class to deal with stopping criterion. Requires as input the lambdified callable (``stopping_criterion``) built from a sympy symboic expression (``expr``) and can be called on a given geodesic step to return either ``True`` if the condition in the expression is verified or ``False`` when it is not, stopping the integration. It also requires an exit message (``exit_str``), useful to flag a geodesic that fires the stopping criterion (*i.e.* a geodesic that ends in an horizon). 200 | """ 201 | def __init__(self, stopping_criterion: Callable[[npt.ArrayLike], bool], expr: str, exit_str: str): 202 | self.stopping_criterion = stopping_criterion 203 | self.exit = exit_str 204 | self.expr = expr 205 | 206 | def __repr__(self): 207 | return f"" 208 | 209 | def __call__(self, *geo: npt.ArrayLike) -> bool: 210 | return self.stopping_criterion(*geo) 211 | 212 | class StoppingCriterionList: 213 | r""" 214 | An aggregator of multiple :py:class:`StoppingCriterion` objects. When called on a geodesic it tests multiple stopping criterions on the last step and returns ``False`` if at least one of the ``stopping_criterions`` is falsy. In that case stores the ``exit`` of the stopping criterion that fired the condition. 215 | """ 216 | def __init__(self, stopping_criterions: list[StoppingCriterion]): 217 | self.stopping_criterions = stopping_criterions 218 | self.exit = None 219 | 220 | def append(self, stopping_criterion: StoppingCriterion): 221 | self.stopping_criterions.append(stopping_criterion) 222 | 223 | def __repr__(self): 224 | return f"" 225 | 226 | def __call__(self, *geo: npt.ArrayLike) -> bool: 227 | check = True 228 | for stopping_criterion in self.stopping_criterions: 229 | check &= stopping_criterion(*geo) 230 | if not check: 231 | self.exit = stopping_criterion.exit 232 | break 233 | 234 | return check -------------------------------------------------------------------------------- /pygro/integrators.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from typing import Literal, Optional, Tuple, Callable, Optional 3 | from pygro.interpolators import Interpolator, LinearInterpolator, DP54DenseOutput, HermiteCubicInterpolator, DP853DenseOutput 4 | 5 | AVAILABLE_INTEGRATORS = Literal['rkf45', 'dp45', 'ck45', 'rkf78', 'dp853'] 6 | 7 | class IntegrationError(Exception): 8 | def __init__(self, message): 9 | self.message = message 10 | 11 | class Integrator: 12 | """ 13 | Base class for ODE integrator. 14 | """ 15 | interpolator_class = LinearInterpolator 16 | 17 | def __init__(self, function: Callable, stopping_criterion: Optional[Callable] = lambda x: True): 18 | 19 | if not callable(function): 20 | raise TypeError("'function' must be a callable object.") 21 | if not callable(stopping_criterion): 22 | raise TypeError("'stopping_criterion' must be a callable object.") 23 | 24 | self.f = function 25 | self.stopping_criterion = stopping_criterion 26 | 27 | def next_step(self, x: float, y: np.ndarray, h: float) -> Tuple[float, np.ndarray, float]: 28 | raise NotImplementedError 29 | 30 | def integrate(self, x_start: float, x_end: float, y_start: np.array, initial_step: float) -> Tuple[np.ndarray, np.ndarray, str, Interpolator]: 31 | x = [x_start] 32 | y = [y_start] 33 | k = [] 34 | h = initial_step 35 | 36 | exit = "failed" 37 | 38 | while abs(x[-1]) < abs(x_end) and self.stopping_criterion(*y[-1]): 39 | next = self.next_step(x[-1], y[-1], h, x_end) 40 | x.append(next[0]) 41 | y.append(next[1]) 42 | h = next[2] 43 | k.append(next[3]) 44 | 45 | if not self.stopping_criterion(*y[-1]): 46 | exit = self.stopping_criterion.exit 47 | else: 48 | exit = "done" 49 | 50 | next = self.next_step(x[-1], y[-1], h, x_end) 51 | k.append([self.f(next[0], next[1])]) 52 | 53 | x = np.array(x) 54 | y = np.array(y) 55 | 56 | return x, y, exit, self.interpolator_class(x, y, k) 57 | 58 | class ExplicitAdaptiveRungeKuttaIntegrator(Integrator): 59 | r""" 60 | Base class for explicit adaptive step-size integrators of the Runge-Kutta family. 61 | 62 | The local error is estimated as the difference between the two approximations at higher and lower order: 63 | 64 | .. math:: 65 | 66 | E = \| y_{\text{high}} - y_{\text{low}} \| 67 | 68 | where :math:`\| \cdot \|` typically represents a norm, such as the maximum norm or the Euclidean norm (the one we use). 69 | 70 | The step size is adapted based on a defined error tolerance, which ensures the solution's accuracy, computed as: 71 | 72 | .. math:: 73 | \text{tol} = \text{tol}_\text{abs} + \text{tol}_\text{rel} \cdot \| y \| 74 | :label: integration-tolerance 75 | 76 | The tolerance acts as a threshold for acceptable error in the solution. If :math:`E \leq \text{tol}`, the step is accepted; otherwise, it is rejected, and a smaller step size is used. 77 | 78 | The constructor for the :py:class:`.ExplicitAdaptiveRungeKuttaIntegrator` object accepts the following arguments that can be directly passed to the :py:meth:`~pygro.geodesic_engine.GeodesicEngine.integrate` method of the :py:class:`.GeodesicEngine` object upon integration: 79 | 80 | :param accuracy_goal: the exponent that defines the integration absolute tolerance ``atol = 10**(-accuracy_goal)``, :math:`\text{tol}_\text{abs}`, in formula :eq:`integration-tolerance`. 81 | :type accuracy_goal: int 82 | :param precision_goal: the exponent that defines the integration relative tolerance ``rtol = 10**(-precision_goal)``, :math:`\text{tol}_\text{rel}`, in formula :eq:`integration-tolerance`. 83 | :type precision_goal: int 84 | :param hmax: maximum acceptable step size (default is ``hmax = 1e+16``). 85 | :type hmax: float 86 | :param hmin: minimum acceptable step size (default is ``hmin = 1e-16``). 87 | :type hmin: float 88 | :param max_iter: maxium namber of step-size adaptation attempts before rising an error. If this error is reached, try to use smaller ``accuracy_goal`` and/or ``precision_goal`` or to reduce the initial step size of the integration (``initial_step`` in the :py:meth:`~pygro.geodesic_engine.GeodesicEngine.integrate` method). 89 | :type max_iter: int 90 | """ 91 | interpolator_class = HermiteCubicInterpolator 92 | 93 | def __init__(self, function: Callable, stopping_criterion: Callable, order: int, stages: int, accuracy_goal: Optional[int] = 10, precision_goal: Optional[int] = 10, safety_factor: Optional[float] = 0.9, hmax: Optional[float] = 1e+16, hmin: Optional[float] = 1e-16, max_iter: int = 1000): 94 | 95 | super().__init__(function, stopping_criterion) 96 | 97 | if not isinstance(order, int): 98 | raise TypeError("'order' must be integer.") 99 | if not isinstance(stages, int): 100 | raise TypeError("'stages' must be integer.") 101 | 102 | if not isinstance(accuracy_goal, int): 103 | raise TypeError("'accuracy_goal' must be integer.") 104 | if not isinstance(precision_goal, int): 105 | raise TypeError("'precision_goal' must be integer.") 106 | 107 | if not isinstance(safety_factor, (float, int)): 108 | raise TypeError("'safety_factor' must be a number.") 109 | if not isinstance(hmax, (float, int)): 110 | raise TypeError("'hmax' must be a number.") 111 | 112 | self.order = order 113 | self.stages = stages 114 | 115 | self.atol = 10**(-accuracy_goal) 116 | self.rtol = 10**(-precision_goal) 117 | self.sf = safety_factor 118 | self.hmax = hmax 119 | self.hmin = hmin 120 | self.min_factor = 0.2 121 | self.max_factor = 10 122 | self.max_iter = max_iter 123 | 124 | def next_step(self, x: float, y: np.ndarray, h: float, x_end: float) -> Tuple[float, np.ndarray, float]: 125 | k = np.zeros(self.stages, dtype = object) 126 | h1 = h 127 | 128 | j = 1 129 | 130 | while True: 131 | 132 | x1 = x + h1 133 | 134 | if np.sign(h1)*(x1 - x_end) > 0: 135 | x1 = x_end 136 | 137 | h1 = x1 - x 138 | 139 | for i in range(self.stages): 140 | k[i] = self.f(x+self.c[i]*h1, y + h1*np.dot(k, self.a[i])) 141 | 142 | y_propagation = y + h1*np.dot(self.b[1], k) 143 | y_error = y + h1*np.dot(self.b[0], k) 144 | 145 | v = np.linalg.norm(y_propagation-y_error) 146 | w = self.atol+self.rtol*np.linalg.norm(np.maximum(y_propagation, y_error)) 147 | 148 | err = v/w 149 | 150 | if not err == 0: K = min(max(self.sf*err**(-1/self.order), self.min_factor), self.max_factor) 151 | 152 | if err > 1: 153 | # Reject step and try smaller step-size 154 | if j >= self.max_iter: 155 | raise IntegrationError("Reached maximum number of step iterations. Check initial step size, integration tolerances or check for the presence of horizons.") 156 | 157 | if h1 > 0: 158 | h1 = max(min(abs(self.hmax), h1*K), abs(self.hmin)) 159 | else: 160 | h1 = -max(min(abs(self.hmax), abs(h1)*K), abs(self.hmin)) 161 | 162 | j += 1 163 | continue 164 | 165 | else: 166 | # Accept step 167 | if err == 0: 168 | if h1 > 0: 169 | h2 = min(abs(self.hmax), 2*h1) 170 | else: 171 | h2 = -min(abs(self.hmax), 2*abs(h1)) 172 | break 173 | if h1 > 0: 174 | h2 = max(min(abs(self.hmax), h1*K), abs(self.hmin)) 175 | else: 176 | h2 = -max(min(abs(self.hmax), abs(h1)*K), abs(self.hmin)) 177 | break 178 | 179 | return x1, y_propagation, h2, k 180 | 181 | class DormandPrince45(ExplicitAdaptiveRungeKuttaIntegrator): 182 | interpolator_class = DP54DenseOutput 183 | def __init__(self, function: Callable, stopping_criterion: Callable, accuracy_goal: Optional[int] = 10, precision_goal: Optional[int] = 10, safety_factor: Optional[float] = 0.9, hmax: Optional[float] = 1e+16): 184 | 185 | self.a = np.array([ 186 | [0, 0, 0, 0, 0, 0, 0], 187 | [1/5, 0, 0, 0, 0, 0, 0], 188 | [3/40, 9/40, 0, 0, 0, 0, 0], 189 | [44/45, -56/15, 32/9, 0, 0, 0, 0], 190 | [19372/6561, -25360/2187, 64448/6561, -212/729, 0, 0, 0], 191 | [9017/3168, -355/33, 46732/5247, 49/176, -5103/18656, 0, 0], 192 | [35/384, 0, 500/1113, 125/192, -2187/6784, 11/84, 0] 193 | ]) 194 | 195 | self.b = np.array([ 196 | [5179/57600, 0, 7571/16695, 393/640, -92097/339200, 187/2100, 1/40], 197 | [35/384, 0, 500/1113, 125/192, -2187/6784, 11/84, 0], 198 | ]) 199 | 200 | self.c = np.array([ 201 | 0, 1/5, 3/10, 4/5, 8/9, 1, 1 202 | ]) 203 | 204 | super().__init__(function, stopping_criterion, 5, len(self.b[0]), accuracy_goal, precision_goal, safety_factor, hmax) 205 | 206 | class DormandPrince853(ExplicitAdaptiveRungeKuttaIntegrator): 207 | interpolator_class = DP853DenseOutput 208 | def __init__(self, function: Callable, stopping_criterion: Callable, accuracy_goal: Optional[int] = 10, precision_goal: Optional[int] = 10, safety_factor: Optional[float] = 0.9, hmax: Optional[float] = 1e+16): 209 | 210 | self.a = np.array([ 211 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 212 | [1/18, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 213 | [1/48, 1/16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 214 | [1/32, 0, 3/32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 215 | [5/16, 0, -75/64, 75/64, 0, 0, 0, 0, 0, 0, 0, 0, 0], 216 | [3/80, 0, 0, 3/16, 3/20, 0, 0, 0, 0, 0, 0, 0, 0], 217 | [29443841/614563906, 0, 0, 77736538/692538347, -28693883/1125000000, 23124283/1800000000, 0, 0, 0, 0, 0, 0, 0], 218 | [16016141/946692911, 0, 0, 61564180/158732637, 22789713/633445777, 545815736/2771057229, -180193667/1043307555, 0, 0, 0, 0, 0, 0], 219 | [39632708/573591083, 0, 0, -433636366/683701615, -421739975/2616292301, 100302831/723423059, 790204164/839813087, 800635310/3783071287, 0, 0, 0, 0, 0], 220 | [246121993/1340847787, 0, 0, -37695042795/15268766246, -309121744/1061227803, -12992083/490766935, 6005943493/2108947869, 393006217/1396673457, 123872331/1001029789, 0, 0, 0, 0], 221 | [-1028468189/846180014, 0, 0, 8478235783/508512852, 1311729495/1432422823, -10304129995/1701304382, -48777925059/3047939560, 15336726248/1032824649, -45442868181/3398467696, 3065993473/597172653, 0, 0, 0], 222 | [185892177/718116043, 0, 0, -3185094517/667107341, -477755414/1098053517, -703635378/230739211, 5731566787/1027545527, 5232866602/850066563, -4093664535/808688257, 3962137247/1805957418, 65686358/487910083, 0, 0], 223 | [13451932/455176623, 0, 0, 0, 0, -808719846/976000145, 1757004468/5645159321, 656045339/265891186, -3867574721/1518517206, 465885868/322736535, 53011238/667516719, 2/45, 0] 224 | ]) 225 | 226 | self.b = np.array([ 227 | [14005451/335480064, 0, 0, 0, 0, -59238493/1068277825, 181606767/758867731, 561292985/797845732, -1041891430/1371343529, 760417239/1151165299, 118820643/751138087, -528747749/2220607170, 1/4], 228 | [13451932/455176623, 0, 0, 0, 0, -808719846/976000145, 1757004468/5645159321, 656045339/265891186, -3867574721/1518517206, 465885868/322736535, 53011238/667516719, 2/45, 0] 229 | ]) 230 | 231 | self.c = np.array([ 232 | 0, 1/18, 1/12, 1/8, 5/16, 3/8, 59/400, 93/200, 5490023248/9719169821, 13/20, 1201146811/1299019798, 1, 1 233 | ]) 234 | 235 | super().__init__(function, stopping_criterion, 8, len(self.b[0]), accuracy_goal, precision_goal, safety_factor, hmax) 236 | 237 | class RungeKuttaFehlberg45(ExplicitAdaptiveRungeKuttaIntegrator): 238 | def __init__(self, function: Callable, stopping_criterion: Callable, accuracy_goal: Optional[int] = 10, precision_goal: Optional[int] = 10, safety_factor: Optional[float] = 0.9, hmax: Optional[float] = 1e+16): 239 | 240 | self.a = np.array([ 241 | [0, 0, 0, 0, 0, 0], 242 | [1/4, 0, 0, 0, 0, 0], 243 | [3/32, 9/32, 0, 0, 0, 0], 244 | [1932/2197, -7200/2197, 7296/2197, 0, 0, 0], 245 | [439/216, -8, 3680/513, -845/4104, 0, 0], 246 | [-8/27, 2, -3544/2565, 1859/4104, -11/40, 0], 247 | ]) 248 | 249 | self.b = np.array([ 250 | [16/135, 0, 6656/12825, 28561/56430, -9/50, 2/55], 251 | [25/216, 0, 1408/2565, 2197/4104, -1/5, 0] 252 | ]) 253 | 254 | self.c = np.array([ 255 | 0, 1/4, 3/8, 12/13, 1, 1/2 256 | ]) 257 | 258 | super().__init__(function, stopping_criterion, 5, len(self.b[0]), accuracy_goal, precision_goal, safety_factor, hmax) 259 | 260 | 261 | class RungeKuttaFehlberg78(ExplicitAdaptiveRungeKuttaIntegrator): 262 | def __init__(self, function: Callable, stopping_criterion: Callable, accuracy_goal: Optional[int] = 10, precision_goal: Optional[int] = 10, safety_factor: Optional[float] = 0.9, hmax: Optional[float] = 1e+16): 263 | 264 | super().__init__(function, stopping_criterion, 7, 13, accuracy_goal, precision_goal, safety_factor, hmax) 265 | 266 | self.c = [0, 2/27, 1/9, 1/6, 5/12, 1/2, 5/6, 1/6, 2/3, 1/3, 1, 0, 1] 267 | 268 | self.a = np.array( 269 | [ 270 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 271 | [2/27, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 272 | [1/36, 1/12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 273 | [1/24, 0, 1/8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 274 | [5/12, 0, -25/16, 25/16, 0, 0, 0, 0, 0, 0, 0, 0, 0], 275 | [1/20, 0, 0, 1/4, 1/5, 0, 0, 0, 0, 0, 0, 0, 0], 276 | [-25/108, 0, 0, 125/108, -65/27, 125/54, 0, 0, 0, 0, 0, 0, 0], 277 | [31/300, 0, 0, 0, 61/225, -2/9, 13/900, 0, 0, 0, 0, 0, 0], 278 | [2, 0, 0, -53/6, 704/45, -107/9, 67/90, 3, 0, 0, 0, 0, 0], 279 | [-91/108, 0, 0, 23/108, -976/135, 311/54, -19/60, 17/6, -1/12, 0, 0, 0, 0], 280 | [2383/4100, 0, 0, -341/164, 4496/1025, -301/82, 2133/4100, 45/82, 45/164, 18/41, 0, 0, 0], 281 | [3/205, 0, 0, 0, 0, -6/41, -3/205, -3/41, 3/41, 6/41, 0, 0, 0], 282 | [-1777/4100, 0, 0, -341/164, 4496/1025, -289/82, 2193/4100, 51/82, 33/164, 12/41, 0, 1, 0] 283 | ]) 284 | 285 | self.b = np.array( 286 | [ 287 | [0, 0, 0, 0, 0, 34/105, 9/35, 9/35, 9/280, 9/280, 0, 41/840, 41/840], 288 | [41/840, 0, 0, 0, 0, 34/105, 9/35, 9/35, 9/280, 9/280, 41/840, 0, 0] 289 | ] 290 | ) 291 | 292 | class CashKarp45(Integrator): 293 | # TODO: improve CashKarp typing, notation and documentation 294 | def __init__(self, function: Callable, stopping_criterion: Callable, twiddle1 = 1.1, twiddle2 = 1.5, quit1 = 100, quit2 = 100, safety_factor = 0.9, accuracy_goal = 10, precision_goal = 10): 295 | 296 | super().__init__(function, stopping_criterion) 297 | 298 | self.twiddle1 = twiddle1 299 | self.twiddle2 = twiddle2 300 | self.quit1 = quit1 301 | self.quit2 = quit2 302 | self.SF = safety_factor 303 | self.tolerance = 1 304 | self.Atol = 10**(-accuracy_goal) 305 | self.Rtol = 10**(-precision_goal) 306 | 307 | self.a = np.array([ 308 | [0, 0, 0, 0, 0, 0], 309 | [1/5, 0, 0, 0, 0, 0], 310 | [3/40, 9/40, 0, 0, 0, 0], 311 | [3/10, -9/10, 6/5, 0, 0, 0], 312 | [-11/54, 5/2, -70/27, 35/27, 0, 0], 313 | [1631/55296, 175/512, 575/13824, 44275/110592, 253/4096, 0] 314 | ]) 315 | 316 | self.b = np.array([ 317 | [1, 0, 0, 0, 0, 0], 318 | [-3/2, 5/2, 0, 0, 0, 0], 319 | [19/54, 0, -10/27, 55/54, 0, 0], 320 | [2825/27648, 0, 18575/48384, 13525/55296, 277/14336, 1/4], 321 | [37/378, 0, 250/621, 125/594, 0, 512/1771] 322 | ]) 323 | 324 | self.c = np.array([ 325 | 0, 1/5, 3/10, 3/5, 1, 7/8 326 | ]) 327 | 328 | def integrate(self, x_start, x_end, y_start, initial_step): 329 | 330 | x = [x_start] 331 | y = [y_start] 332 | 333 | h = initial_step 334 | twiddle1 = self.twiddle1 335 | twiddle2 = self.twiddle2 336 | quit1 = self.quit1 337 | quit2 = self.quit2 338 | 339 | while abs(x[-1]) <= abs(x_end) and self.stopping_criterion(*y[-1]): 340 | if self.verbose: 341 | print("{:.4f}".format(x[-1]), end= "\r") 342 | next = self.next_step(x[-1], y[-1], h, twiddle1, twiddle2, quit1, quit2) 343 | x.append(next[0]) 344 | y.append(next[1]) 345 | h = next[2] 346 | twiddle1 = next[3] 347 | twiddle2 = next[4] 348 | quit1 = next[5] 349 | quit2 = next[6] 350 | 351 | if not self.stopping_criterion(*y[-1]): 352 | exit = self.stopping_criterion.exit 353 | else: 354 | exit = "done" 355 | 356 | return np.array(x), np.array(y), exit 357 | 358 | def next_step(self, x, y, h, TWIDDLE1, TWIDDLE2, QUIT1, QUIT2): 359 | k = np.zeros(6, dtype = object) 360 | h1 = h 361 | while True: 362 | k[0] = h1*self.f(x, y) 363 | k[1] = h1*self.f(x + self.c[1]*h1, y + np.dot(k,self.a[1])) 364 | 365 | y1 = y + np.dot(self.b[0], k) 366 | y2 = y+ np.dot(self.b[1], k) 367 | 368 | E1 = self.E(y1, y2, 1) 369 | 370 | if E1 > TWIDDLE1*QUIT1: 371 | ESTTOL = E1/QUIT1 372 | h1 = max(1/5, self.SF/ESTTOL)*h1 373 | continue 374 | 375 | k[2] = h1*self.f(x + self.c[2]*h1, y+np.dot(k,self.a[2])) 376 | k[3] = h1*self.f(x + self.c[3]*h1, y+np.dot(k,self.a[3])) 377 | 378 | y3 = y + np.dot(self.b[2], k) 379 | E2 = self.E(y2, y3, 2) 380 | 381 | if E2 > TWIDDLE2*QUIT2: 382 | if E1 < 1: 383 | if abs(1/10*max(k[0]+k[1])) < self.tolerance: 384 | x = x + h1*self.c[1] 385 | h1 = h1/5 386 | return x, y2, h1, TWIDDLE1, TWIDDLE2, QUIT1, QUIT2 387 | else: 388 | h1 = h1/5 389 | continue 390 | else: 391 | ESTTOL = E2/QUIT2 392 | h1 = max(1/5, self.SF/ESTTOL)*h1 393 | continue 394 | 395 | k[4] = h1*self.f(x + self.c[4]*h1, y + np.dot(k,self.a[4])) 396 | k[5] = h1*self.f(x + self.c[5]*h1, y + np.dot(k,self.a[5])) 397 | 398 | y4 = y + np.dot(self.b[3], k) 399 | y5 = y + np.dot(self.b[4], k) 400 | 401 | E4 = self.E(y5, y4, 4) 402 | 403 | if E4 > 1: 404 | if E1/QUIT1 < TWIDDLE1: 405 | TWIDDLE1 = max(1.1, E1/QUIT1) 406 | if E2/QUIT2 < TWIDDLE2: 407 | TWIDDLE2 = max(1.1, E2/QUIT2) 408 | if E2 < 1: 409 | if abs(1/10*max(k[0]-2*k[2]+k[3])) < self.tolerance: 410 | x = x + h1*self.c[3] 411 | h1 = h1*self.c[3] 412 | return x, y3, h1, TWIDDLE1, TWIDDLE2, QUIT1, QUIT2 413 | else: 414 | if E1 < 1: 415 | if abs(1/10*max(k[0]+k[1])) < self.tolerance: 416 | x = x + h1*self.c[1] 417 | h1 = h1*self.c[1] 418 | return x, y2, h1, TWIDDLE1, TWIDDLE2, QUIT1, QUIT2 419 | else: 420 | h1 = h1/5 421 | continue 422 | else: 423 | ESTTOL = E4 424 | h1 = max(1/5, self.SF/ESTTOL)*h1 425 | continue 426 | else: 427 | x = x + h1*self.c[4] 428 | h1 = min(5, self.SF/E4)*h1 429 | 430 | Q1 = E1/E4 431 | if Q1 > QUIT1: 432 | Q1 = min(Q1, 10*QUIT1) 433 | else: 434 | Q1 = max(Q1, 2/3*QUIT1) 435 | QUIT1 = max(1, min(10000, Q1)) 436 | 437 | Q2 = E2/E4 438 | if Q2 > QUIT2: 439 | Qw = min(Q2, 10*QUIT2) 440 | else: 441 | Q2 = max(Q2, 2/3*QUIT2) 442 | QUIT2 = max(1, min(10000, Q2)) 443 | 444 | return x, y5, h1, TWIDDLE1, TWIDDLE2, QUIT1, QUIT2 445 | 446 | def E(self, y1, y2, i): 447 | return self.ERR(y1, y2, i)/self.tolerance**(1/(1+i)) 448 | 449 | def ERR(self, y1, y2, i): 450 | if hasattr(y1, "__len__"): 451 | v = y1-y2 452 | w = v/(self.Atol+self.Rtol*y1) 453 | return np.linalg.norm(w)**(1/(1+i)) 454 | else: 455 | v = y1-y2 456 | return abs(v/(self.Atol+self.Rtol*y1))**(1/(1+i)) 457 | 458 | def get_integrator(integrator: AVAILABLE_INTEGRATORS, *args, **kwargs) -> Integrator: 459 | if integrator == "rkf45": 460 | return RungeKuttaFehlberg45(*args, **kwargs) 461 | if integrator == "dp45": 462 | return DormandPrince45(*args, **kwargs) 463 | if integrator == "dp853": 464 | return DormandPrince853(*args, **kwargs) 465 | if integrator == "ck45": 466 | return CashKarp45(*args, **kwargs) 467 | if integrator == "rkf78": 468 | return RungeKuttaFehlberg78(*args, **kwargs) 469 | --------------------------------------------------------------------------------