gravityp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gravityp/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """
2
+ GRAVITYp: Python package to study the optical appearance of spherically symmetric
3
+ compact objects in General Relativity.
4
+
5
+ Source: https://github.com/alvaroslzar/GRAVITYp
6
+ """
7
+
8
+ from .utils import *
9
+ from .geodesic_integration import *
10
+ from .ray_tracing import *
11
+ from .rings import *
12
+ from .transfer_functions import *
13
+ from .emission_models import *
14
+ from .shadows import *
15
+ from .plots import *
16
+
17
+ import email.utils
18
+ from importlib.metadata import metadata, PackageNotFoundError
19
+
20
+ __name__ = "gravityp"
21
+
22
+ try:
23
+ _meta = metadata(__name__)
24
+ __version__ = _meta.get("Version", "0.1.0")
25
+
26
+ # setuptools packs pyproject.toml authors into the 'Author-email' header
27
+ _author_email_header = _meta.get("Author-email")
28
+
29
+ if _author_email_header:
30
+ # Parse header into [("Name", "email@domain.com"), ...]
31
+ _parsed_contacts = email.utils.getaddresses([_author_email_header])
32
+
33
+ _names = [name for name, _ in _parsed_contacts if name]
34
+ _emails = [addr for _, addr in _parsed_contacts if addr]
35
+
36
+ # Format as strings so help(gravityp) displays them cleanly
37
+ __author__ = ", ".join(_names) if _names else _meta.get("Author")
38
+ __email__ = ", ".join(_emails) if _emails else None
39
+ else:
40
+ __author__ = _meta.get("Author")
41
+ __email__ = None
42
+
43
+ except PackageNotFoundError:
44
+ __version__ = "0.1.0"
45
+ __author__ = "Gonzalo J. Olmo, Diego Rubiera-Garcia, João Luís Rosa, Diego Sáez-Chillón Gómez, Álvaro Salazar-Cuadros"
46
+ __email__ = None
@@ -0,0 +1,45 @@
1
+ """This module contains some predefined emission profiles
2
+ """
3
+
4
+ import numpy as np
5
+ import scipy.optimize as optimize
6
+
7
+
8
+ __all__ = [
9
+ "intensity_at_ISCO",
10
+ "intensity_at_photon_ring",
11
+ "intensity_at_inner_edge",
12
+ "standard_Unbound",
13
+ "normalized_Standard_Unbound",
14
+ "exponential",
15
+ "uniform"
16
+ ]
17
+
18
+
19
+ def intensity_at_ISCO(r, r_ISCO):
20
+ func = lambda x: (1./(x-(r_ISCO-1))**2) / (1./(r_ISCO-(r_ISCO-1))**2)
21
+ return np.piecewise(r, [r>=r_ISCO,r<r_ISCO] , [func,0] )
22
+
23
+ def intensity_at_photon_ring(r, r_ph):
24
+ func = lambda x: (1./(x-(r_ph-1))**3) / (1./(r_ph-(r_ph-1))**3)
25
+ return np.piecewise(r, [r>=r_ph,r<r_ph] , [func,0] )
26
+
27
+ def intensity_at_inner_edge(r, inner_edge):
28
+ func = lambda x: ( np.pi/2-np.arctan(x-5) ) / ( np.pi/2-np.arctan(inner_edge-5) )
29
+ return np.piecewise(r, [r>=inner_edge,r<inner_edge] , [func,0] )
30
+
31
+ def standard_Unbound(r, mu: float=8., sigma: float=2., gamma: float=2.):
32
+ return ( np.exp(-0.5*(gamma+np.arcsinh((r-mu)/sigma))**2) /
33
+ np.sqrt((r-mu)**2+sigma**2) )
34
+
35
+ def normalized_Standard_Unbound(r, mu: float=8., sigma: float=2., gamma: float=2.):
36
+ # FIXME: for some values of params, it doesn't find the minimum
37
+ neg_fun = lambda r: -standard_Unbound(r, mu, sigma, gamma)
38
+ minimum = optimize.minimize(neg_fun, 0).fun
39
+ return neg_fun(r)/minimum
40
+
41
+ def exponential(r):
42
+ return np.exp(-r)
43
+
44
+ def uniform(r):
45
+ return np.ones(shape=r.shape)
@@ -0,0 +1,192 @@
1
+ """This module contains all the functions needed for the geodesic integration
2
+ routine
3
+ """
4
+
5
+ import numpy as np
6
+ import scipy.integrate as integrate
7
+ import scipy.optimize as optimize
8
+ from bisect import bisect_right
9
+
10
+ from .utils import to_tuple
11
+
12
+
13
+ __all__ = [
14
+ "potential",
15
+ "get_increasing_maxima",
16
+ "find_interval",
17
+ "find_rmin",
18
+ "geodesic_in",
19
+ "geodesic_out",
20
+ "compute_outgoing",
21
+ "compute_geodesic",
22
+ ]
23
+
24
+ DEFAULT_NPOINTS = 100000 # ODE integration resolution
25
+ DEFAULT_R0 = 1000.0 # Asymptotic boundary for initial conditions
26
+ MINIMUM_R = 1e-6 # Minimum value of r for integration
27
+
28
+
29
+ def potential(r, radial_fun, radial_params, areal, areal_params):
30
+ """
31
+ Effective potential of a spherically symmetric metric of the form:
32
+
33
+ ds^2 = - radial_fun(r; radial_params)dt^2 + 1/radial_fun(r; radial_params)dr^2 + areal(r; areal_params)dOmega^2
34
+
35
+ Parameters:
36
+ r (float): radial coordinate of the metric
37
+ radial_fun (callable): -gtt metric function. Its first argument must be r
38
+ radial_params (float | tuple): additional parameters for radial_fun
39
+ areal (callable): areal radius squared. Its first argument must be r
40
+ areal_params (float | tuple): additional parameters for areal
41
+ """
42
+ return radial_fun(r,*to_tuple(radial_params)) / areal(r,*to_tuple(areal_params))
43
+
44
+ def get_increasing_maxima(r_phs, potential):
45
+ """Returns increasing local maxima of the potential with their photon sphere values"""
46
+ if len(r_phs) >= 1: # There has to be at least 1 local maximum
47
+ r_phs = np.array(r_phs)
48
+ r_phs = np.sort(r_phs)[::-1] # descending order of
49
+ photon_sph = r_phs[0].reshape(1,)
50
+ local_maxima = potential(photon_sph)
51
+ for r_ph in r_phs:
52
+ maximum = potential(r_ph)
53
+ if maximum > local_maxima.max(): # We take increasing local maxima
54
+ local_maxima = np.append(local_maxima, maximum)
55
+ photon_sph = np.append(photon_sph, r_ph)
56
+ return photon_sph[::-1], local_maxima[::-1] # Swap to ascending order of photon_spheres
57
+ else:
58
+ return np.array([]), np.array([])
59
+
60
+ def find_interval(inverse_b2, photon_sph, local_maxima, lower_bound=1e-4, upper_bound=100):
61
+ """Returns the bounds (r1,r2) where the rmin is found for a given 1/b^2"""
62
+ # NOTE: photon_sph and local_maxima have to be ordered by get_increasing_maxima
63
+ if len(photon_sph)==0 and len(local_maxima)==0: # Case 1: there is no local maximum
64
+ return (lower_bound, upper_bound)
65
+ if inverse_b2 >= local_maxima[0]: # Case 2: 1/b^2 higher than all local maxima
66
+ return (lower_bound, photon_sph[0])
67
+ if inverse_b2 <= local_maxima[-1]: # Case 3: 1/b^2 lower than all local maxima
68
+ return (photon_sph[-1], upper_bound)
69
+
70
+ # Case 4: there are more than 1 local maximum and 1/b^2 lies between two of them
71
+ # We negate both the array elements and 1/b^2 so the logic mirrors an increasing array
72
+ idx = bisect_right(local_maxima, -inverse_b2, key=lambda x: -x)
73
+ return (photon_sph[idx - 1], photon_sph[idx])
74
+
75
+ def find_rmin(b, r_phs, inner_edge, radial_fun, radial_params, areal, areal_params):
76
+ """
77
+ Finds the minimum radius of the trajectory using Brent's method.
78
+
79
+ Parameters:
80
+ b (float): impact parameter of the photon trajectory
81
+ r_phs (array): array with the position of photon spheres (maxima) of the potential
82
+ inner_edge (float): value of the r coordinate of the inner edge (event horizon, throat, etc.)
83
+ potential (callable): effective potential
84
+
85
+ Returns:
86
+ i) If b > bmin, then rmin is found by solving V(r)-1/b^2 = 0
87
+ ii) If b < bmin, then rmin = inner_edge.
88
+ """
89
+ pot = lambda r: potential(r, radial_fun, radial_params, areal, areal_params) # V(r)
90
+ photon_sph, local_maxima = get_increasing_maxima(r_phs, pot)
91
+ bounds = find_interval(1/b**2, photon_sph, local_maxima) # Interval for Brent's method
92
+ fun = lambda r: pot(r)-1/b**2
93
+ if fun(bounds[0])*fun(bounds[1]) >= 0: # No root if there is no sign change
94
+ root = 0
95
+ else:
96
+ root, results = optimize.brentq(fun, *bounds, full_output=True)
97
+ return max( max(root, inner_edge) , MINIMUM_R )
98
+
99
+ def geodesic_in(phi, r, b, radial_fun, radial_params, areal, areal_params):
100
+ """Expression for dphi/dr in the incoming null geodesic equation"""
101
+ areal_params = to_tuple(areal_params)
102
+ radial_params = to_tuple(radial_params)
103
+ return -( b / np.sqrt(areal(r,*areal_params)) )/np.sqrt( areal(r,*areal_params) - b**2*radial_fun(r,*radial_params) )
104
+
105
+ def geodesic_out(phi, r, b, radial_fun, radial_params, areal, areal_params):
106
+ """Expression for dphi/dr in the outgoing null geodesic equation"""
107
+ areal_params = to_tuple(areal_params)
108
+ radial_params = to_tuple(radial_params)
109
+ return +( b / np.sqrt(areal(r,*areal_params)) )/np.sqrt( areal(r,*areal_params) - b**2*radial_fun(r,*radial_params) )
110
+
111
+ def compute_outgoing(r_phs, inner_edge, rmin) -> bool:
112
+ """Returns a boolean that tells whether to compute outgoing geodesic after reaching rmin"""
113
+ compute = False
114
+ if (len(r_phs)>0): # There is at least 1 photon sphere
115
+ innermost_photon_sphere = np.array(r_phs).min()
116
+ if (innermost_photon_sphere < MINIMUM_R):
117
+ if (rmin > MINIMUM_R):
118
+ compute = True
119
+ # if (rmin > inner_edge) and (innermost_photon_sphere > 0): # Probamos con inner_edge mejor
120
+ else:
121
+ # print('Hola')
122
+ if (rmin > inner_edge) and (rmin > MINIMUM_R): # Probamos con inner_edge mejor
123
+ compute = True
124
+ else: # There is no photon sphere
125
+ compute = True
126
+ return compute
127
+
128
+ def compute_geodesic(b, r_phs, inner_edge, radial_fun, radial_params, areal, areal_params,
129
+ Npoints=DEFAULT_NPOINTS, r0=DEFAULT_R0):
130
+ """
131
+ Computes the null geodesic trajectory in Schwarzschild spacetime.
132
+
133
+ Integrates the geodesic equations to trace light ray paths. For incoming rays,
134
+ integrates from r0 down to rmin. For rays escaping to infinity (rmin > 3),
135
+ also integrates the outgoing trajectory from rmin back to r0.
136
+
137
+ Parameters:
138
+ b (float): Impact parameter in units of M=1
139
+ Npoints (int): Number of radial points for integration. Default: DEFAULT_NPOINTS
140
+ r0 (float): Starting radius. Default: DEFAULT_R0
141
+
142
+ Returns:
143
+ tuple[np.ndarray, np.ndarray]: (rr, phi) where rr is the radial coordinate
144
+ array and phi is the azimuthal angle array along the geodesic
145
+ """
146
+ rmin = find_rmin(b, r_phs, inner_edge, radial_fun, radial_params, areal, areal_params)
147
+ rr = np.geomspace(r0, rmin, Npoints) # We must ensure rmin > 0 in find_rmin for np.geomspace
148
+
149
+ phi0 = b/rr[0] # good approximation for r0>>rSch and better than phi0=0
150
+ phi = integrate.odeint(
151
+ geodesic_in, phi0, rr, (b,radial_fun,radial_params,areal,areal_params)
152
+ ).reshape(-1,)
153
+ phi = phi[~np.isnan(phi)]
154
+ rr = rr[:len(phi)]
155
+
156
+ # NOTE: does not work properly with high r0 and low Npoints for outgoing geodesics
157
+ # FIXME: si len(r_phs)=0, solo integra incoming.
158
+ if compute_outgoing(r_phs, inner_edge, rmin):
159
+ rout = rr[::-1]
160
+ phi0 = 2*phi[-1]-phi[-2] # Linear approx. for next angle. Required for strict monotonicity!!
161
+ phi_out = integrate.odeint(
162
+ geodesic_out, phi0, rout, (b,radial_fun,radial_params,areal,areal_params)
163
+ ).reshape(-1,)
164
+ rr = np.concatenate([rr,rout])
165
+ phi = np.concatenate([phi,phi_out])
166
+
167
+ return (rr,phi)
168
+
169
+
170
+ if __name__=="__main__":
171
+
172
+ def g_rr(r: float, dummy) -> float:
173
+ """Radial function g^{rr} of the Schwarzschild metric in units of M=1"""
174
+ return 1 - 2./r
175
+
176
+ def g_thth(r: float, dummy) -> float:
177
+ """Areal radius squared g^{theta theta} of the Schwarzschild in units of M=1"""
178
+ return r**2
179
+
180
+ # Dictionary codifying Schwarzschild metric and required params
181
+ Schwarzschild_kwargs = {
182
+ 'r_phs': [3.,], # Photon sphere at r=3M
183
+ 'inner_edge': 2., # Inner edge of disk at horizon r=2M
184
+ 'radial_fun': g_rr, # Above defined radial function
185
+ 'radial_params': None, # g_rr has no additional params
186
+ 'areal': g_thth, # Above defined areal radius squared
187
+ 'areal_params': None, # g_thth has no additional params
188
+ }
189
+
190
+
191
+ rr, phi = compute_geodesic(3, **Schwarzschild_kwargs)
192
+ print(rr, phi)
gravityp/plots.py ADDED
@@ -0,0 +1,293 @@
1
+ """This module contains utilities and predefined functions to plot figures
2
+ abount the different steps of the code.
3
+ """
4
+
5
+ import numpy as np
6
+
7
+ import matplotlib.pyplot as plt
8
+ from matplotlib.patches import Circle
9
+ from matplotlib import colors
10
+ from mpl_toolkits.axes_grid1 import make_axes_locatable
11
+
12
+ from .utils import to_tuple, to_list
13
+ from .geodesic_integration import compute_geodesic
14
+ from .ray_tracing import compute_nturns, get_order
15
+ from .transfer_functions import transfer_function
16
+ from .shadows import total_intensity, compute_points
17
+
18
+
19
+ __all__ = [
20
+ "plot_metric_function",
21
+ "make_metric_function_plot",
22
+ "plot_half_graph",
23
+ "plot_nturns",
24
+ "plot_geodesic",
25
+ "plot_BH",
26
+ "make_geodesics_plot",
27
+ "plot_transfer_function",
28
+ "make_transfer_function_plot",
29
+ "add_extra_tick",
30
+ "plot_emission_model",
31
+ "plot_observed_intensity",
32
+ "plot_intensities",
33
+ "plot_colorbar",
34
+ "make_shadow_plot"
35
+ ]
36
+
37
+
38
+ def plot_metric_function(ax, r_range, fun, param, name):
39
+ ax.plot(r_range, fun(r_range,param), label=f'{name}={param:.2f}')
40
+
41
+ def make_metric_function_plot(fun, params, r_range, figsize=(9,6), savepath=None):
42
+ ax = plt.figure(figsize=figsize).add_subplot()
43
+ ax.set_title(f'{fun.__name__[2:].capitalize()} metric function')
44
+
45
+ for name, param_list in params.items():
46
+ for param in param_list:
47
+ plot_metric_function(ax, r_range, fun, param, name)
48
+
49
+ ax.set_xlim(r_range[0], r_range[-1])
50
+ ax.set_ylim(-1,1)
51
+ ax.set_xlabel(r'$r/M$')
52
+ ax.set_ylabel(r'$e^{2\nu}$')
53
+ xticks = np.arange(r_range[0],r_range[-1]+1,2,dtype=int)
54
+ yticks = np.arange(-1,1.5,0.5)
55
+ ax.set_xticks(xticks)
56
+ ax.set_yticks(yticks)
57
+ ax.set_xticklabels(xticks)
58
+ ax.set_yticklabels(yticks)
59
+ ax.hlines(0, r_range[0], r_range[-1],color='black',linewidth=1)
60
+ ax.legend()
61
+ if savepath is not None:
62
+ plt.savefig(savepath)
63
+ plt.show()
64
+
65
+ def plot_half_graph(ax, bs, plot_label=False, **kwargs):
66
+ """Auxiliary function for plot_nturns"""
67
+ nturns = compute_nturns(bs, **kwargs)
68
+ upper = 1.25
69
+ lower = 0.75
70
+
71
+ lines_data = {
72
+ 'Direct': (np.ma.masked_where(nturns >= lower, nturns), 'black'),
73
+ 'Lensed': (np.ma.masked_where((nturns < lower) | (nturns >= upper), nturns), 'orange'),
74
+ 'Photon ring': (np.ma.masked_where(nturns < upper, nturns), 'red')
75
+ }
76
+
77
+ for label, (y_data, color) in lines_data.items():
78
+ plot_kwargs = {'color': color}
79
+ if plot_label:
80
+ plot_kwargs['label'] = label
81
+ ax.plot(bs, y_data, **plot_kwargs)
82
+
83
+ def plot_nturns(bs, b_crits, figsize=(7,7), savepath=None, **kwargs):
84
+ ax = plt.figure(figsize=figsize).add_subplot()
85
+ xticks = [i for i in range(0,11,2)]
86
+ xtick_labels = [f'{tick}' for tick in xticks]
87
+
88
+ if len(b_crits)==0:
89
+ plot_half_graph(ax, bs, plot_label=True, **kwargs)
90
+ else:
91
+ # Interval from 0 to first b_crit, plotting labels
92
+ bs_interval = bs[ bs <= b_crits[0]]
93
+ plot_half_graph(ax, bs_interval, plot_label=True, **kwargs)
94
+ ax.vlines(b_crits[0],0,2,colors='gray',linestyles='dashed',zorder=0)
95
+ xticks += [b_crits[0]]
96
+ if len(b_crits)==1:
97
+ xtick_labels += [rf'$b_c$']
98
+ else:
99
+ xtick_labels += [rf'$b_c^1$']
100
+
101
+ # Intermediate intervals [b1,b2], [b2,b3], ...
102
+ if len(b_crits) > 1:
103
+ for i, b_crit in enumerate(b_crits[1:], start=1):
104
+ bs_interval = bs[ (bs >= b_crits[i-1]) & (bs < b_crit) ]
105
+ plot_half_graph(ax, bs_interval, plot_label=False, **kwargs)
106
+ ax.vlines(b_crit,0,2,colors='gray',linestyles='dashed',zorder=0)
107
+ xticks += [b_crit]
108
+ xtick_labels += [rf'$b_c^{i+1}$']
109
+
110
+ # Last interval from last b_crit to infinity
111
+ bs_interval = bs[ bs >= b_crits[-1] ]
112
+ plot_half_graph(ax, bs_interval, plot_label=False, **kwargs)
113
+
114
+ ax.set_xlim(0,10)
115
+ ax.set_ylim(0,2)
116
+ ax.set_xticks(xticks)
117
+ ax.set_xticklabels(xtick_labels)
118
+ ax.set_yticks(np.arange(0,2.1,0.25))
119
+ ax.set_xlabel(r'$b/M$')
120
+ ax.set_ylabel(r'$n=\phi /(2\pi)$')
121
+ ax.legend(loc='upper right')
122
+ if savepath is not None:
123
+ plt.savefig(savepath)
124
+ plt.show()
125
+
126
+ def plot_geodesic(axes, areal, areal_params, rr, phi, color=None):
127
+ # NOTE: the plotted coordinate refers to the areal radius
128
+ if color is None:
129
+ order = get_order(phi[-1])
130
+ color = ['dodgerblue','orange','red'][order]
131
+
132
+ areal_radius = np.sqrt( areal(rr,*to_tuple(areal_params)) )
133
+ trajectory = (areal_radius*np.cos(phi), areal_radius*np.sin(phi))
134
+ axes.plot(*trajectory, color=color, linewidth=0.5, alpha=1)
135
+
136
+ def plot_BH(axes, r_phs, inner_edge, areal, areal_params):
137
+ # NOTE: Within this function, the variable r refers to the areal radius
138
+ i_edge_kwargs = {'color':'black', 'fill':(inner_edge!=0)}
139
+ p_ring_kwargs = {'color':'black', 'fill':False}
140
+
141
+ r_i_edge = np.sqrt( areal(inner_edge, *to_tuple(areal_params)) )
142
+ patch_i_edge = Circle((0, 0), r_i_edge, **i_edge_kwargs, zorder=3)
143
+ axes.add_patch(patch_i_edge)
144
+ for r_ph in r_phs:
145
+ r_photon = np.sqrt( areal(r_ph, *to_tuple(areal_params)) )
146
+ photon_ring = Circle((0, 0), r_photon, linestyle=(0,(5,2)), linewidth=2/3, zorder=3, **p_ring_kwargs)
147
+ axes.add_patch(photon_ring)
148
+
149
+ def make_geodesics_plot(bs_dict, r_phs, inner_edge, radial_fun, radial_params, areal, areal_params,
150
+ figsize=(7,7), savepath=None):
151
+ ax = plt.figure(figsize=figsize).add_subplot()
152
+
153
+ if bs_dict.__class__ != dict:
154
+ bs_dict = {'label': (None, bs_dict)} # Cast to dict
155
+ for label, (color, bs) in bs_dict.items():
156
+ for b in np.atleast_1d(bs):
157
+ coords = compute_geodesic(
158
+ b, r_phs, inner_edge, radial_fun, radial_params, areal, areal_params
159
+ )
160
+ plot_geodesic(ax, areal, areal_params, *coords, color)
161
+
162
+ plot_BH(ax, r_phs, inner_edge, areal, areal_params)
163
+
164
+ ax.set_xlim(-10,10)
165
+ ax.set_ylim(-10,10)
166
+ ax.set_xticks(np.arange(-10,11,5))
167
+ ax.set_yticks(np.arange(-10,11,5))
168
+ ax2 = ax.twinx()
169
+ ax2.set_ylim(-10, 10) # Keep limits synced with ax
170
+ ax2.set_yticks(np.arange(0, 11, 2)) # Ticks: 0, 1, 2, ..., 10
171
+
172
+ if savepath is not None:
173
+ plt.savefig(savepath)
174
+ plt.show()
175
+
176
+ def plot_transfer_function(axes, bs, order, correction: float=0, **kwargs):
177
+ # NOTE: bs in bs_list can also be lists for lensed and photon ring, which will be plotted separately
178
+ colors = ['dodgerblue','orange','red']
179
+ image = ['Direct','Lensed','Photon ring']
180
+ points = transfer_function(bs, order, correction, **kwargs)
181
+ axes.plot(*points, color=colors[order], label=f'{image[order]}', linewidth=1)
182
+
183
+ def make_transfer_function_plot(b_crits, kwargs, bs_list,
184
+ correction=0, figsize=(7,7), savepath=None):
185
+ fig, ax = plt.subplots(figsize=figsize)
186
+
187
+ for order, bs in enumerate(bs_list):
188
+ if order==0:
189
+ plot_transfer_function(ax, bs, order, correction, **kwargs)
190
+ else:
191
+ for bs_ring in to_list(bs):
192
+ plot_transfer_function(ax, bs_ring, order, correction, **kwargs)
193
+
194
+
195
+ xticks = [i for i in range(0,11,2)]
196
+ xticklabels = [f'{t}' for t in xticks]
197
+ # xticks += [b_crit]
198
+ # xticklabels += [r'$b_c$']
199
+
200
+ ax.set_xlabel(r'$b/M$')
201
+ ax.set_ylabel(r'$r_m/M$')
202
+ ax.set_xlim(0,10)
203
+ ax.set_xticks(xticks)
204
+ ax.set_xticklabels(xticklabels)
205
+ ax.set_ylim(0,15)
206
+ ax.set_yticks(np.arange(0,16,5))
207
+
208
+ if savepath is not None:
209
+ plt.savefig(savepath)
210
+ plt.show()
211
+
212
+ ## Intensity and shadows
213
+ def add_extra_tick(xticks, xticklabels, extra_tick=None, extra_ticklabel=None):
214
+ if (extra_tick is not None) and (extra_ticklabel is not None):
215
+ threshold = 0.6 # Minimum allowed distance between labels
216
+ xticks = [t for t in xticks if abs(t - extra_tick) > threshold]
217
+ xticklabels = [f'{t}' for t in xticks]
218
+ xticks += [extra_tick]
219
+ xticklabels += [extra_ticklabel]
220
+ return xticks, xticklabels
221
+
222
+ def plot_emission_model(rs, emission_model, text_string=None,
223
+ tick=None, ticklabel=None, figsize=(7,7), savepath=None):
224
+ fig, ax = plt.subplots(figsize=figsize)
225
+ ax.plot(rs, emission_model(rs), label=r'$I_{\mathrm{em}}/I_0$')
226
+
227
+ rmin = round(rs.min())
228
+ rmax = round(rs.max())
229
+
230
+ # Add an extra xtick placed at specified position
231
+ xticks = [i for i in range(rmin,rmax,2)]
232
+ xticklabels = [f'{t}' for t in xticks]
233
+ xticks, xticklabels = add_extra_tick(xticks, xticklabels, tick, ticklabel)
234
+
235
+ ax.set_xlabel(r'$r/M$')
236
+ ax.set_xticks(xticks)
237
+ ax.set_xticklabels(xticklabels)
238
+ ax.set_xlim(rmin,rmax)
239
+ # ax.set_ylabel(r'$I_\mathrm{em}/I_0$')
240
+ ax.set_ylim(0,1)
241
+ if text_string is not None:
242
+ ax.text( 7.5, 0.8, s=text_string )
243
+ ax.legend()
244
+ if savepath is not None:
245
+ plt.savefig(savepath)
246
+ plt.show()
247
+
248
+ def plot_observed_intensity(bs, bs_transfer_list, emission_model, kwargs,
249
+ figsize=(7,7), savepath=None, y_range=None):
250
+ fig, ax = plt.subplots(figsize=figsize)
251
+ ax.plot(bs, total_intensity(bs, bs_transfer_list, emission_model, kwargs),
252
+ label=r'$I_{\mathrm{ob}}/I_0$')
253
+
254
+ bmin = round(bs.min())
255
+ bmax = round(bs.max())
256
+ ax.set_xlabel(r'$b/M$')
257
+ ax.set_xticks(range(bmin,bmax+2,2))
258
+ ax.set_xlim(bmin,bmax)
259
+ ax.set_ylim(y_range)
260
+ ax.legend()
261
+ if savepath is not None:
262
+ plt.savefig(savepath)
263
+ plt.show()
264
+
265
+ def plot_intensities(ax, z, vmin=None, vmax=None):
266
+ return ax.imshow(
267
+ z,
268
+ norm=colors.PowerNorm(1, vmin, vmax),
269
+ cmap='inferno',
270
+ extent=(-10, 10, -10, 10),
271
+ origin='lower',
272
+ interpolation='none',
273
+ )
274
+
275
+ def plot_colorbar(fig, ax, im):
276
+ divider = make_axes_locatable(ax)
277
+ cax = divider.append_axes("right", size="5%", pad=0.15)
278
+ cb = fig.colorbar(im, cax=cax)
279
+
280
+ def make_shadow_plot(bs_transfer_list, emission_model, kwargs,
281
+ figsize=(7,7), savepath=None, y_range=None, Npixels=1e6):
282
+
283
+ Z = compute_points(bs_transfer_list, emission_model, kwargs, Npixels)
284
+
285
+ fig, ax = plt.subplots(figsize=figsize)
286
+ im = plot_intensities(ax, Z, *to_tuple(y_range))
287
+ ax.set_xticks(np.arange(-10, 11, 5))
288
+ ax.set_yticks(np.arange(-10, 11, 5))
289
+ plot_colorbar(fig, ax, im)
290
+
291
+ if savepath is not None:
292
+ plt.savefig(savepath)
293
+ plt.show()
@@ -0,0 +1,53 @@
1
+ """This module contains functions for computing the number of intersections
2
+ (a.k.a. order) of a geodesic with the accretion disk.
3
+ """
4
+
5
+ import numpy as np
6
+
7
+ from .geodesic_integration import compute_geodesic
8
+
9
+
10
+ __all__ = ["compute_nturns_scalar", "compute_nturns", "get_order"]
11
+
12
+
13
+ def compute_nturns_scalar(b, **kwargs):
14
+ (_,phi) = compute_geodesic(b, **kwargs)
15
+ return np.abs(phi[-1])/(2*np.pi)
16
+
17
+ def compute_nturns(bs, **kwargs):
18
+ return np.array([compute_nturns_scalar(b, **kwargs) for b in np.atleast_1d(bs)])
19
+
20
+ def get_order(deviation: float, correction: float=0) -> int:
21
+ """
22
+ Computes the number of turns nturn = deviation/(2*pi) and maps:
23
+ - n < 3/4 -> 0 (direct image)
24
+ - 3/4 < n < 5/4 -> 1 (lensed image)
25
+ - n > 5/4 -> 2 (photon ring)
26
+ """
27
+ nturns = abs(deviation - correction)/(2*np.pi) # We take absolute value for rays with b<0
28
+ return int(nturns > 0.75) + int(nturns > 1.25)
29
+
30
+
31
+ if __name__=="__main__":
32
+
33
+ def g_rr(r: float, dummy) -> float:
34
+ """Radial function g^{rr} of the Schwarzschild metric in units of M=1"""
35
+ return 1 - 2./r
36
+
37
+ def g_thth(r: float, dummy) -> float:
38
+ """Areal radius squared g^{theta theta} of the Schwarzschild in units of M=1"""
39
+ return r**2
40
+
41
+ # Dictionary codifying Schwarzschild metric and required params
42
+ Schwarzschild_kwargs = {
43
+ 'r_phs': [3.,], # Photon sphere at r=3M
44
+ 'inner_edge': 2., # Inner edge of disk at horizon r=2M
45
+ 'radial_fun': g_rr, # Above defined radial function
46
+ 'radial_params': None, # g_rr has no additional params
47
+ 'areal': g_thth, # Above defined areal radius squared
48
+ 'areal_params': None, # g_thth has no additional params
49
+ }
50
+
51
+ nturns = compute_nturns_scalar(3, **Schwarzschild_kwargs)
52
+ order = get_order(nturns)
53
+ print(f"nturns = {nturns:.2f}. Order = {order}")
gravityp/rings.py ADDED
@@ -0,0 +1,217 @@
1
+ """This module contains very helpful builtin functions for finding relevant
2
+ impact parameter values.
3
+ """
4
+
5
+ import numpy as np
6
+ import scipy.optimize as optimize
7
+
8
+ from .ray_tracing import compute_nturns_scalar
9
+
10
+ __all__ = [
11
+ "find_ring_edge",
12
+ "find_ring_bs",
13
+ "is_sorted",
14
+ "get_minima",
15
+ "find_rings_list",
16
+ "compute_optimal_array_steps",
17
+ "compute_optimal_array_Npoints"
18
+ ]
19
+
20
+
21
+ def find_ring_edge(fun, lower_bound, upper_bound, to_upper=True) -> float:
22
+ """Returns the impact parameter value for which the number of turns is 0.75 or 1.25 (depending on fun)"""
23
+ b = (upper_bound+lower_bound)/2.
24
+ if not to_upper: # Bounds are reversed
25
+ upper_bound, lower_bound = lower_bound, upper_bound
26
+ a = lower_bound
27
+
28
+ maxiter = 500
29
+ success, iter = False, 0
30
+ for iter in range(maxiter):
31
+ success = fun(a)*fun(b) < 0
32
+ if success:
33
+ break
34
+ a = b
35
+ b = (upper_bound+b)/2.
36
+ if success:
37
+ return optimize.brentq(fun, a, b, full_output=False) # type: ignore[index]
38
+ else:
39
+ raise ValueError(f'Invalid interval for ring edges. Iterations: {iter}')
40
+
41
+ def find_ring_bs(b_crit, ring_interval, epsilon, scalar_fun_nturns):
42
+ """Returns the interval of impact parameters where lensed and photon rings are found around a single b_crit"""
43
+ # TODO: considerar el caso en el que los lensed/photon rings se solapan
44
+ fun_lensed = lambda b: scalar_fun_nturns(b)-0.75
45
+ fun_p_ring = lambda b: scalar_fun_nturns(b)-1.25
46
+
47
+ # retro
48
+ bounds = ( ring_interval[0] , b_crit-epsilon )
49
+ retro_lensed = find_ring_edge(fun_lensed, *bounds, to_upper=True)
50
+ retro_p_ring = find_ring_edge(fun_p_ring, *bounds, to_upper=True)
51
+
52
+ # non-retro
53
+ bounds = ( b_crit+epsilon , ring_interval[1] )
54
+ lensed = find_ring_edge(fun_lensed, *bounds, to_upper=False)
55
+ p_ring = find_ring_edge(fun_p_ring, *bounds, to_upper=False)
56
+
57
+ return {
58
+ 'retro_lensed': (retro_lensed,retro_p_ring),
59
+ 'retro_p_ring': (retro_p_ring,b_crit),
60
+ 'p_ring': (b_crit,p_ring),
61
+ 'lensed': (p_ring,lensed)
62
+ }
63
+
64
+ def is_sorted(l):
65
+ # Source - https://stackoverflow.com/a/3755251
66
+ l = list(l)
67
+ return all(l[i] <= l[i+1] for i in range(len(l) - 1))
68
+
69
+ def get_minima(b_crits, epsilon, scalar_fun_nturns):
70
+ """Returns the local minima of the nturns function between a list of critical impact parameters"""
71
+ if not is_sorted(b_crits):
72
+ raise ValueError(f'List of b_crits is not sorted: {b_crits}')
73
+ minima = []
74
+ for i, b_crit in enumerate(b_crits[:-1]):
75
+ bounds = ( b_crits[i]+epsilon , b_crits[i+1]-epsilon )
76
+ result = optimize.minimize_scalar(scalar_fun_nturns, bounds=bounds)
77
+ if result.success: # type: ignore[index]
78
+ minima.append(result.x) # type: ignore[index]
79
+
80
+ return minima
81
+
82
+ def find_rings_list(b_crits, kwargs):
83
+ """Returns a list with all impact parameter intervals for each ring and for each b_crit"""
84
+ epsilon = 1e-11 # Maybe toooo small
85
+ max_value = max(b_crits) + 5
86
+ scalar_fun_nturns = lambda b: compute_nturns_scalar(b, **kwargs)
87
+
88
+ b_crits.sort() # We ensure that they are in increasing order
89
+ minima = get_minima(b_crits, epsilon, scalar_fun_nturns)
90
+ rings = []
91
+ for i, b_crit in enumerate(b_crits):
92
+ a = epsilon if i==0 else minima[i-1]
93
+ b = max_value if i==len(b_crits)-1 else minima[i]
94
+ rings.append( find_ring_bs(b_crit, (a,b), epsilon, scalar_fun_nturns) )
95
+ return rings
96
+
97
+ def compute_optimal_array_steps(bmin, bmax, rings,
98
+ steps=(0.1,0.01,0.001), joint=True, fill=False):
99
+ """
100
+ Sample points with specified steps.
101
+ If joint=True (default), direct, lensed and photon ring contributions are
102
+ returned in an ordered single array.
103
+ If joint=False, these three contributions are returned as a triplet:
104
+ ( bs_direct, bs_lensed, bs_p_ring )
105
+ """
106
+ bs_direct = []
107
+ bs_lensed = []
108
+ bs_p_ring = []
109
+
110
+ if not fill:
111
+ for i, ring in enumerate(rings):
112
+ # array of impact parameters for the direct image
113
+ start = bmin if i==0 else rings[i-1]["lensed"][1]
114
+ stop = ring["retro_lensed"][0]
115
+ bs_direct.append( np.arange(start, stop, steps[0]) )
116
+
117
+ # array of impact parameters for the lensed image
118
+ bs_lensed.append(
119
+ np.concatenate([
120
+ np.arange(ring["retro_lensed"][0],ring["retro_lensed"][1],steps[1]) ,
121
+ np.arange(ring["lensed"][0],ring["lensed"][1],steps[1])
122
+ ]).reshape(-1,)
123
+ )
124
+
125
+ # array of impact parameters for the ring image
126
+ bs_p_ring.append(
127
+ np.concatenate([
128
+ np.arange(ring["retro_p_ring"][0],ring["retro_p_ring"][1],steps[2]) ,
129
+ np.arange(ring["p_ring"][0],ring["p_ring"][1],steps[2])
130
+ ]).reshape(-1,)
131
+ )
132
+
133
+ # right tail of array for the direct image
134
+ bs_direct.append( np.arange(rings[-1]["lensed"][1], bmax, steps[0]) )
135
+ else:
136
+ bs_direct.append( np.arange(bmin, bmax, steps[0]) )
137
+ for i, ring in enumerate(rings):
138
+ # array of impact parameters for the lensed image
139
+ bs_lensed.append(
140
+ np.arange(ring["retro_lensed"][0],ring["lensed"][1],steps[1])
141
+ )
142
+
143
+ # array of impact parameters for the ring image
144
+ bs_p_ring.append(
145
+ np.arange(ring["retro_p_ring"][0],ring["p_ring"][1],steps[2])
146
+ )
147
+
148
+ bs_direct = np.concatenate(bs_direct)
149
+ bs_lensed = np.concatenate(bs_lensed)
150
+ bs_p_ring = np.concatenate(bs_p_ring)
151
+
152
+ if joint:
153
+ total = np.sort( np.concatenate([bs_direct, bs_lensed, bs_p_ring]) )
154
+ return total[(total>=bmin) & (total<=bmax)]
155
+ else:
156
+ return [bs_direct, bs_lensed, bs_p_ring]
157
+
158
+ def compute_optimal_array_Npoints(bmin, bmax, rings,
159
+ Npoints=100, joint=True, fill=False):
160
+ """
161
+ Sample points with specified number of points.
162
+ If joint=True (default), direct, lensed and photon ring contributions are
163
+ returned in an ordered single array.
164
+ If joint=False, these three contributions are returned as a triplet:
165
+ ( bs_direct, bs_lensed, bs_p_ring )
166
+ """
167
+ bs_direct = []
168
+ bs_lensed = []
169
+ bs_p_ring = []
170
+
171
+ if not fill:
172
+ for i, ring in enumerate(rings):
173
+ # array of impact parameters for the direct image
174
+ start = bmin if i==0 else rings[i-1]["lensed"][1]
175
+ stop = ring["retro_lensed"][0]
176
+ bs_direct.append( np.linspace(start, stop, Npoints) )
177
+
178
+ # array of impact parameters for the lensed image
179
+ bs_lensed.append(
180
+ np.concatenate([
181
+ np.linspace(ring["retro_lensed"][0],ring["retro_lensed"][1],Npoints) ,
182
+ np.linspace(ring["lensed"][0],ring["lensed"][1],Npoints)
183
+ ]).reshape(-1,)
184
+ )
185
+
186
+ # array of impact parameters for the ring image
187
+ bs_p_ring.append(
188
+ np.concatenate([
189
+ np.linspace(ring["retro_p_ring"][0],ring["retro_p_ring"][1],Npoints) ,
190
+ np.linspace(ring["p_ring"][0],ring["p_ring"][1],Npoints)
191
+ ]).reshape(-1,)
192
+ )
193
+
194
+ # right tail of array for the direct image
195
+ bs_direct.append( np.linspace(rings[-1]["lensed"][1], bmax, Npoints) )
196
+ else:
197
+ bs_direct.append( np.linspace(bmin, bmax, Npoints) )
198
+ for i, ring in enumerate(rings):
199
+ # array of impact parameters for the lensed image
200
+ bs_lensed.append(
201
+ np.linspace(ring["retro_lensed"][0],ring["lensed"][1],Npoints)
202
+ )
203
+
204
+ # array of impact parameters for the ring image
205
+ bs_p_ring.append(
206
+ np.linspace(ring["retro_p_ring"][0],ring["p_ring"][1],Npoints)
207
+ )
208
+
209
+ bs_direct = np.concatenate(bs_direct)
210
+ bs_lensed = np.concatenate(bs_lensed)
211
+ bs_p_ring = np.concatenate(bs_p_ring)
212
+
213
+ if joint:
214
+ total = np.sort( np.concatenate([bs_direct, bs_lensed, bs_p_ring]) )
215
+ return total[(total>=bmin) & (total<=bmax)]
216
+ else:
217
+ return [bs_direct, bs_lensed, bs_p_ring]
gravityp/shadows.py ADDED
@@ -0,0 +1,38 @@
1
+ """This module contains the implementation of the simulation of the optical
2
+ appearance of a compact object and observed intensity profile.
3
+ """
4
+
5
+ import numpy as np
6
+
7
+ from .utils import to_tuple
8
+ from .transfer_functions import transfer_function
9
+
10
+
11
+ __all__ = ["redshift", "observed_intensity", "total_intensity", "compute_points"]
12
+
13
+
14
+ def redshift(r, radial_fun, radial_params):
15
+ """Gravitational redshift"""
16
+ return np.sqrt(radial_fun(r, *to_tuple(radial_params)))
17
+
18
+ def observed_intensity(eval_points, bs, rbs, emission_model, kwargs):
19
+ radial_fun = kwargs['radial_fun']
20
+ radial_params = kwargs['radial_params']
21
+ intensity = emission_model(rbs)*redshift(rbs, radial_fun, radial_params)**4
22
+ return np.interp(eval_points, bs, intensity, left=0, right=0)
23
+
24
+ def total_intensity(bs, bs_transfer_list, emission_model, kwargs, correction=0):
25
+ intensity = np.zeros(shape=bs.shape) # Important to use bs.shape instead of len(bs)
26
+
27
+ for order, bb in enumerate(bs_transfer_list):
28
+ points = transfer_function(bb, order, correction, **kwargs)
29
+ intensity += observed_intensity(bs, *points, emission_model, kwargs)
30
+
31
+ return intensity
32
+
33
+ def compute_points(bs_transfer_list, emission_model, kwargs, Npixels):
34
+ x = np.linspace(-10, 10, np.sqrt(Npixels).astype(int) )
35
+ y = np.linspace(-10, 10, np.sqrt(Npixels).astype(int) )
36
+ X,Y = np.meshgrid(x, y)
37
+ B = np.sqrt(X**2 + Y**2)
38
+ return total_intensity(B, bs_transfer_list, emission_model, kwargs)
@@ -0,0 +1,44 @@
1
+ """This module contains the transfer function implementation and related
2
+ functionalities.
3
+ """
4
+
5
+ import numpy as np
6
+ from scipy.interpolate import CubicSpline
7
+
8
+ from .geodesic_integration import compute_geodesic
9
+
10
+
11
+ __all__ = ["transfer_function", "compute_inner_shadow"]
12
+
13
+
14
+ def transfer_function(bb, order, correction: float=0, **kwargs):
15
+ bs = []
16
+ rbs = []
17
+
18
+ for b in np.atleast_1d(bb):
19
+ (rr,phi) = compute_geodesic(b, **kwargs)
20
+
21
+ if abs(phi[-1]) > ( order*np.pi + np.pi/2 + correction ):
22
+ f_hat = CubicSpline(phi,rr)
23
+ r_b = f_hat(order*np.pi+np.pi/2+correction)
24
+ rbs.append(r_b)
25
+ bs.append(b)
26
+
27
+ return ( np.array(bs), np.array(rbs) )
28
+
29
+ def compute_inner_shadow(bmin, bmax, Npoints=10, **kwargs):
30
+ """Computes the minimum value of the impact parameter for which a light ray intersects the accretion disk"""
31
+ maxiters = 500
32
+ for i in range(maxiters):
33
+ bs = np.linspace(bmin,bmax,Npoints)
34
+ (b, _) = transfer_function(bs, order=0, **kwargs)
35
+ inner_shadow = b[0]
36
+ diff = abs(b[1]-b[0])
37
+ if 2*diff < 1e-5:
38
+ break
39
+ bmin = inner_shadow-diff
40
+ bmax = inner_shadow+diff
41
+ if 2*diff < 1e-5:
42
+ return inner_shadow
43
+ else:
44
+ raise ValueError(f'Inner shadow has not been found after {maxiters} iterations.')
gravityp/utils.py ADDED
@@ -0,0 +1,15 @@
1
+ """This module contains some utility functions
2
+ """
3
+ __all__ = ["to_tuple", "to_list"]
4
+
5
+ def to_tuple(var) -> tuple:
6
+ if var.__class__ != tuple:
7
+ return (var,)
8
+ else:
9
+ return var
10
+
11
+ def to_list(var) -> list:
12
+ if var.__class__ != list:
13
+ return [var]
14
+ else:
15
+ return var
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: gravityp
3
+ Version: 0.1.0
4
+ Summary: GRAVITYp is a Python library to study the optical appearance of spherically symmetric compact objects in General Relativity. It stands for Geodesic RAys and Visualization of IntensiTY profiles.
5
+ Author-email: Alvaro Salazar-Cuadros <a.salazar@uva.es>, Diego Saez-Chillon Gomez <diego.saez@uva.es>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/alvaroslzar/GRAVITYp
8
+ Project-URL: Issues, https://github.com/alvaroslzar/GRAVITYp/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: numpy>=1.26
15
+ Requires-Dist: scipy>=1.11
16
+ Requires-Dist: matplotlib>=3.8
17
+ Provides-Extra: notebooks
18
+ Requires-Dist: jupyter>=1.0; extra == "notebooks"
19
+ Requires-Dist: ipykernel>=6.0; extra == "notebooks"
20
+ Dynamic: license-file
21
+
22
+ # GRAVITYp
23
+
24
+ **GRAVITYp** is a Python package for studying the optical appearance of spherically symmetric compact objects in General Relativity.
25
+ It stands for **G**eodesic **RA**ys and **V**isualization of **I**ntensi**TY** **p**rofiles.
26
+
27
+ ## About the project
28
+
29
+ This is the Python implementation of **GRAVITYp**, which is originally a numerical code in Wolfram language developed by G.J. Olmo, D. Rubiera García, J.L. Rosa, D. Sáez-Chillón and collaborators [arXiv:2307.06778v2](https://arxiv.org/abs/2307.06778v2).
30
+ It was developed to study the optical appearance or *shadow* cast by black holes, wormholes and different kinds of exotic compact objects, inspired by the work of S.E. Gralla et al. [arXiv:1906.00873](https://arxiv.org/abs/1906.00873).
31
+
32
+ Then, Á. Salazar Cuadros preferred to work on Python seeking faster execution times for simulations and a language better suited for Version Control Systems (VCS), and that's how the Python implementation was born.
33
+ The goal of this open source version is to provide open access to anyone interested in using the package or learning how simulated black hole shadow images are generated.
34
+
35
+ ## Installation
36
+
37
+ You can clone `GRAVITYp` locally in editable mode for research and development:
38
+
39
+ ```bash
40
+ git clone git@github.com:alvaroslzar/GRAVITYp.git # SSH
41
+ git clone https://github.com/alvaroslzar/GRAVITYp.git # HTTPS
42
+ ```
43
+
44
+ Then, install it in your virtual environment
45
+
46
+ ```bash
47
+ cd GRAVITYp
48
+ pip install -e .
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ ### Quick example
54
+
55
+ ```python
56
+ import numpy as np
57
+ from gravityp import make_geodesics_plot
58
+
59
+ def g_rr(r: float, dummy) -> float:
60
+ """Radial function g^{rr} of the Schwarzschild metric in units of M=1"""
61
+ return 1 - 2./r
62
+
63
+ def g_thth(r: float, dummy) -> float:
64
+ """Areal radius squared g^{theta theta} of the Schwarzschild in units of M=1"""
65
+ return r**2
66
+
67
+ # Dictionary codifying Schwarzschild metric and required params
68
+ Schwarzschild_kwargs = {
69
+ 'r_phs': [3.,], # Photon sphere at r=3M
70
+ 'inner_edge': 2., # Inner edge of disk at horizon r=2M
71
+ 'radial_fun': g_rr, # Above defined radial function
72
+ 'radial_params': None, # g_rr has no additional params
73
+ 'areal': g_thth, # Above defined areal radius squared
74
+ 'areal_params': None, # g_thth has no additional params
75
+ }
76
+
77
+ # Array of impact parameters from 0M to 10M
78
+ impact_parameters = np.arange(0,10,0.2)
79
+ make_geodesics_plot(impact_parameters, figsize=(4,4), **Schwarzschild_kwargs)
80
+ ```
81
+
82
+ The output image looks like this:
83
+
84
+ ![ray-tracing](docs/images/README_ray_tracing.png)
85
+
86
+ For a more detailed explanation, check the Jupyter notebooks in the [Examples](Examples/) folder for details.
87
+
88
+ ### Intensity profiles and black hole shadows
89
+
90
+ The primary use is to obtain the observed intensity profile $I_\mathrm{ob}(b)$ as a function of the impact parameter $b$ given some predefined emission profile $I_\mathrm{em}(r)$.
91
+ The emitted light is due to the matter in the accretion disk, which is assumed to be both geometrically and optically thin.
92
+ Moreover, the orientation is assumed to be face-on with respect to the accretion disk, so the optical appearance has have rotational symmetry.
93
+
94
+ In the following images, the emission profile of the accretion disk has been chosen to peak at the event horizon $r=2M$, and the optical appearance corresponds to a Schwarzschild black hole.ç
95
+ The code properly reproduces the predicted light rings up to two intersections with the accretion disk, as well as the central brightness depression or *shadow* characteristic of compact objects such as black holes.
96
+
97
+ ![emitted](docs/images/README_emitted.png)
98
+ ![observed](docs/images/README_observed.png)
99
+ ![shadow](docs/images/README_shadow.png)
100
+
101
+ ### Further references
102
+
103
+ Watch Prof. D. Rubiera García's [talk](https://www.youtube.com/live/q0a4RXdxk4o?si=njtj2yOmCwim25ix) for an overview of the main concepts involved in the ray-tracing method and analysis of intensity profiles.
104
+
105
+ For an introductory discussion of the inner workings of the Wolfram Mathematica code, watch Prof. G.J. Olmo's [tutorial](https://www.youtube.com/live/f5-s2gVd5xE?si=xCtJxFQikmkLehdU); for a more user-friendly application, watch Dr. J.L. Rosa's [talk](https://www.youtube.com/live/9k8qMq9V814?si=MtXYMg0exd_6_37t).
106
+
107
+ Finally, for a more advanced topic regarding compact objects, watch Prof. D. Sáez-Chillón's [contribution](https://www.youtube.com/watch?v=8pR8kE_ABzQ).
108
+
109
+ ## Citation
110
+
111
+ The use of **GRAVITYp** in scientific publications must be properly acknowledged.
112
+ Please cite the following:
113
+
114
+ **BibTeX**
115
+ ```
116
+ @article{Nojiri:2026tjn,
117
+ author = "Nojiri, Shin'ichi and Odintsov, Sergei D. and S{\'a}ez-Chill{\'o}n G{\'o}mez, Diego and Cuadros, {\'A}lvaro Salazar",
118
+ title = "{Horizon singularity, energy conditions and shadows in time-dependent and spherically symmetric spacetime}",
119
+ eprint = "2608.15740",
120
+ archivePrefix = "arXiv",
121
+ primaryClass = "gr-qc",
122
+ reportNumber = "KEK-TH-2861, KEK-Cosmo-0429",
123
+ month = "8",
124
+ year = "2026"
125
+ }
126
+ ```
127
+
128
+ ## Publications using GRAVITYp
129
+
130
+ Let us know if you use **GRAVITYp** in your publication, and we will add it to the [list of publications](/docs/PUBLICATIONS.md)!
131
+
132
+ ## Author
133
+
134
+ The author and mantainer of the Python code is Álvaro Salazar Cuadros.
135
+
136
+ ## License
137
+
138
+ The software is licensed under the [MIT license](LICENSE).
@@ -0,0 +1,14 @@
1
+ gravityp/__init__.py,sha256=lSOy26eNN6H-fi9e9v-sIc4vgSjgUK2T36T5FELgLBo,1562
2
+ gravityp/emission_models.py,sha256=3FJIL8SdKG9YlC_1dozsXT8MF0Q7_lp7CBsEpJNL0tc,1460
3
+ gravityp/geodesic_integration.py,sha256=sHZmALrsjrpuZVc0kON_BGpz07BvNy3sSrob6RmRPAU,8628
4
+ gravityp/plots.py,sha256=_oswJiiSY4WuSQT6FnjSnuxCzEsILWWjzk7kYN3PJJQ,10792
5
+ gravityp/ray_tracing.py,sha256=-cToOp21A_0ayJcua8c8QnVWBtsCC0Newi1O9QP-Z-4,1957
6
+ gravityp/rings.py,sha256=IBHoXPLnJgElHZwm8VOYCZHBNhLMiCroz72oR-uti1s,8508
7
+ gravityp/shadows.py,sha256=S4UNd9UP0ixiAkoXY-one4xEsMkZ3MTaIKEP7NI1juQ,1509
8
+ gravityp/transfer_functions.py,sha256=3kGmrR6MEGkftxcUta7qUoobNz_Vu2XYOxuuVOCQg8c,1393
9
+ gravityp/utils.py,sha256=Un9Utr6rgo2khdOBKEuNusNtjmVLxlx-togNva_sgzM,320
10
+ gravityp-0.1.0.dist-info/licenses/LICENSE,sha256=NjoB5QmKxUj3TOcRiyHclLcpmm3r0mkQ5cVjpsZpTlE,1099
11
+ gravityp-0.1.0.dist-info/METADATA,sha256=v_l50jUEQWIrgyIriZLB9cufyMKZ8VCACVxm7ynq8lA,6382
12
+ gravityp-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ gravityp-0.1.0.dist-info/top_level.txt,sha256=NaRVIJYGbyb0YlqxrYCQhT62De0qjDRH-On3q0RvtSQ,9
14
+ gravityp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Álvaro Salazar Cuadros
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ gravityp