├── 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 | 
4 | 
5 |
6 | [
](https://github.com/rdellamonica/pygro)
7 | [
](https://rdellamonica.github.io/pygro/index.html)
8 | [
](https://pypi.org/project/PyGRO)
9 | [](https://arxiv.org/abs/2504.20152)
10 | [](https://zenodo.org/badge/latestdoi/334275937)
11 |
12 |  
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 |
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 |
--------------------------------------------------------------------------------