History / version TQF4J8DY · saved
Cite this version
@software{black_hole_2026, author = {ScienceHub}, title = {Black hole}, version = {TQF4J8DY}, year = {2026}, publisher = {ScienceHub}, url = {https://sciencehub.sh/sciencehub-team/black-hole/history/ver_01M4SGGSMV4DH4CJMGTQF4J8DY} }
ScienceHub (2026). Black hole (Version TQF4J8DY) [Computer software]. ScienceHub. https://sciencehub.sh/sciencehub-team/black-hole/history/ver_01M4SGGSMV4DH4CJMGTQF4J8DYcff-version: 1.2.0 message: "If you use this version, cite it as below." title: "Black hole" type: software authors: - name: "ScienceHub" version: "TQF4J8DY" date-released: "2026-10-13" url: "https://sciencehub.sh/sciencehub-team/black-hole/history/ver_01M4SGGSMV4DH4CJMGTQF4J8DY"
This address names exactly this version. The project page cites all versions.
black-hole.py
import marimo
__generated_with = "0.25.1"
app = marimo.App()
@app.cell(hide_code=True)
def _(mo):
mo.md(r"""
# What a black hole looks like
Light passing a black hole follows the geometry of spacetime around it.
For a non-rotating black hole of mass $M$ that geometry is the
Schwarzschild solution, and a light ray in its equatorial plane obeys one
equation (units with $G = c = 1$, $u = M/r$, angle $\varphi$):
$$\frac{d^2u}{d\varphi^2} + u = 3u^2$$
Without the $3u^2$ term this is a straight line. With it, rays bend, some
circle the hole before escaping, and every ray aimed closer than a
critical distance falls in. This notebook solves that equation and
uses it twice: to draw what a camera near the hole would see, and to
follow single rays.
""")
return
@app.cell
def _():
import marimo as mo
import numpy as np
import matplotlib.pyplot as plt
return mo, np, plt
@app.cell(hide_code=True)
def _(mo):
mo.md(r"""
## The view from nearby
Surround the hole with a thin, glowing disc of gas, the way matter
spirals into real black holes, and trace one ray backwards from each
pixel of a camera. Every ray lies in a plane through the hole, so the
same equation decides where it meets the disc. Rays that miss the disc
and escape show the sky behind, displaced by the same bending.
The left side is brighter because the gas there moves towards the
camera (Doppler beaming); the arch over the top and the ring
underneath are the far side of the disc, seen around the hole. This is
the calculation J.-P. Luminet made by hand in 1979.
""")
return
@app.cell
def _(np):
def ray_table(b_max=30.0, nb=1500, step=0.002, max_angle=3.2 * np.pi):
"""r(phi) for rays arriving from infinity with impact parameter b (units of M).
Returns b grid, phi grid, and u = M/r (nan once the ray is inside the horizon)."""
b = np.linspace(0.2, b_max, nb)
n = int(max_angle / step) + 1
u = np.zeros(nb); du = 1 / b
out = np.full((n, nb), np.nan, dtype=np.float32)
out[0] = 0
alive = np.ones(nb, bool)
escape = np.full(nb, np.nan)
prev = u.copy()
for i in range(1, n):
f = lambda x: 3 * x * x - x
k1u, k1v = du, f(u)
k2u, k2v = du + step/2*k1v, f(u + step/2*k1u)
k3u, k3v = du + step/2*k2v, f(u + step/2*k2u)
k4u, k4v = du + step*k3v, f(u + step*k3u)
u = np.where(alive, u + step/6*(k1u+2*k2u+2*k3u+k4u), 0.5)
du = np.where(alive, du + step/6*(k1v+2*k2v+2*k3v+k4v), 0.0)
crossed = alive & np.isnan(escape) & (u < 0) & (i > 1)
escape[crossed] = (i - 1 + prev[crossed] / (prev[crossed] - u[crossed])) * step
alive &= (u < 0.5) & (u >= 0)
prev = u.copy()
out[i] = np.where(alive, u, np.nan)
return b, step, out, escape
def render(width, height, incl_deg=80, fov=34.0, r_in=6.0, r_out=24.0, table=None, ss=1, stars=None):
b_grid, step, U, escape = table
W, H = width*ss, height*ss
x = (np.arange(W) - W/2 + 0.5) / W * fov
y = (np.arange(H) - H/2 + 0.5) / W * fov
X, Y = np.meshgrid(x, -y)
b = np.hypot(X, Y); th = np.arctan2(Y, X)
i = np.radians(incl_deg)
phi0 = np.mod(np.arctan2(-np.cos(i), np.sin(i)*np.sin(th)), np.pi)
img = np.zeros_like(b)
bi = (b - b_grid[0]) / (b_grid[1]-b_grid[0])
valid_b = (bi >= 0) & (bi < len(b_grid)-1)
bi = np.clip(bi, 0, len(b_grid)-1.001); b0 = bi.astype(int); fb = bi - b0
for k in (2, 1, 0): # far images first, nearest image drawn last
phi = phi0 + k*np.pi
pi_ = phi / step
ok = valid_b & (pi_ < U.shape[0]-1)
pi_ = np.clip(pi_, 0, U.shape[0]-1.001); p0 = pi_.astype(int); fp = pi_ - p0
u = (U[p0, b0]*(1-fp)*(1-fb) + U[p0+1, b0]*fp*(1-fb) + U[p0, b0+1]*(1-fp)*fb + U[p0+1, b0+1]*fp*fb)
with np.errstate(invalid='ignore', divide='ignore'):
r = 1/u
hit = ok & np.isfinite(r) & (r >= r_in) & (r <= r_out)
# Keplerian emitter; photon L_z = -alpha sin i, alpha = b cos(theta)
omega = r**-1.5
g = np.sqrt(1 - 3/r) / (1 + omega * X * np.sin(i))
flux = (1 - np.sqrt(r_in/r)) / r**3 # Page-Thorne-like radial profile
I = flux * g**4
img = np.where(hit, I, img)
# Sky: rays that miss the disc and escape leave along the swept angle phi_f.
sky = np.zeros_like(img)
if stars is not None:
phi_f = np.interp(b, b_grid, np.nan_to_num(escape, nan=-1.0))
free = (img == 0) & (phi_f > 0) & (b < b_grid[-1])
n_obs = np.array([0.0, -np.sin(i), np.cos(i)]) # observer direction, disc normal = z
e_x = np.array([1.0, 0.0, 0.0])
e_y = np.array([0.0, np.cos(i), np.sin(i)])
ex = np.cos(th)[..., None] * e_x + np.sin(th)[..., None] * e_y
d = -(np.cos(phi_f)[..., None] * n_obs + np.sin(phi_f)[..., None] * ex)
lon = np.arctan2(d[..., 1], d[..., 0]); lat = np.arcsin(np.clip(d[..., 2], -1, 1))
h, w = stars.shape
px = ((lon + np.pi) / (2 * np.pi) * w).astype(int) % w
py = np.clip(((np.pi / 2 - lat) / np.pi * h).astype(int), 0, h - 1)
sky = np.where(free, stars[py, px], 0.0)
if ss > 1:
img = img.reshape(height, ss, width, ss).mean(axis=(1, 3))
sky = sky.reshape(height, ss, width, ss).mean(axis=(1, 3))
return img, sky
def star_texture(w=4096, h=2048, n=9000, seed=7):
"""Equirectangular sky with stars spread uniformly over the sphere."""
rng = np.random.default_rng(seed)
z = rng.uniform(-1, 1, n); lon = rng.uniform(-np.pi, np.pi, n)
lat = np.arcsin(z)
x = ((lon + np.pi) / (2 * np.pi) * w).astype(int) % w
y = np.clip(((np.pi / 2 - lat) / np.pi * h).astype(int), 0, h - 1)
tex = np.zeros((h, w), np.float32)
np.add.at(tex, (y, x), rng.pareto(1.6, n).astype(np.float32) * 0.15 + 0.05)
for dy, dx in ((0, 1), (1, 0)):
tex += 0.35 * np.roll(np.roll(tex, dy, 0), dx, 1)
return np.clip(tex, 0, 1)
RAY_TABLE = ray_table(b_max=60, nb=1200, step=0.003, max_angle=3.3 * np.pi)
STARS = star_texture()
return RAY_TABLE, STARS, render
@app.cell
def _(mo):
inclination = mo.ui.slider(0, 89, step=1, value=84, label="Camera angle above the disc axis (degrees)")
inclination
return (inclination,)
@app.cell
def _(RAY_TABLE, STARS, inclination, mo, np, plt, render):
import io
from matplotlib import cm
disc, sky = render(640, 360, incl_deg=inclination.value, fov=44, r_out=18, table=RAY_TABLE, ss=1, stars=STARS)
level = np.clip(disc / np.percentile(disc[disc > 0], 99.7), 0, 1) ** 0.55
picture = np.clip(cm.afmhot(level / 1.1)[..., :3] + np.clip(sky, 0, 1)[..., None] * np.array([0.85, 0.9, 1.0]), 0, 1)
buffer = io.BytesIO()
plt.imsave(buffer, picture, format="png")
mo.image(buffer.getvalue(), width="100%")
return
@app.cell
def _(np):
B_CRITICAL = 3 * np.sqrt(3) # impact parameter of the photon sphere, in units of M
R_START = 60.0 # rays start this far from the hole, in units of M
def trace(b, step=0.004, max_angle=6 * np.pi):
"""Trace rays with impact parameters `b` and return their paths.
Integrates u'' = 3u^2 - u with fourth-order Runge-Kutta in the angle
swept since the start, all rays at once. A ray ends when it crosses the
horizon (r = 2M) or leaves the far boundary.
"""
b = np.atleast_1d(np.asarray(b, dtype=float))
u = np.full_like(b, 1 / R_START)
du = np.sqrt(np.maximum(1 / b**2 - u**2 * (1 - 2 * u), 0))
psi = np.arcsin(np.minimum(b / R_START, 1.0))
alive = np.ones_like(b, dtype=bool)
captured = np.zeros_like(b, dtype=bool)
history_u, history_psi, history_alive = [u], [psi], [alive]
def accel(u):
return 3 * u**2 - u
while alive.any() and (psi[alive] < max_angle).any():
k1u, k1v = du, accel(u)
k2u, k2v = du + step / 2 * k1v, accel(u + step / 2 * k1u)
k3u, k3v = du + step / 2 * k2v, accel(u + step / 2 * k2u)
k4u, k4v = du + step * k3v, accel(u + step * k3u)
u = np.where(alive, u + step / 6 * (k1u + 2 * k2u + 2 * k3u + k4u), u)
du = np.where(alive, du + step / 6 * (k1v + 2 * k2v + 2 * k3v + k4v), du)
psi = np.where(alive, psi + step, psi)
fell = alive & (u >= 0.5)
left = alive & (u <= 1 / R_START) & (du < 0)
captured |= fell
history_u.append(u)
history_psi.append(psi)
history_alive.append(alive)
alive = alive & ~(fell | left) & (psi < max_angle)
r = 1 / np.maximum(np.array(history_u), 1e-9)
angle = np.array(history_psi)
drawn = np.array(history_alive)
paths = [
np.column_stack([-r[drawn[:, i], i] * np.cos(angle[drawn[:, i], i]),
r[drawn[:, i], i] * np.sin(angle[drawn[:, i], i])])
for i in range(len(b))
]
return paths, captured, psi
return B_CRITICAL, trace
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## Single rays
Move the slider to aim a ray past the hole.
""")
return
@app.cell
def _(B_CRITICAL, mo):
aim = mo.ui.slider(
start=2.0,
stop=12.0,
step=0.01,
value=round(B_CRITICAL + 0.02, 2),
label="Impact parameter b (in units of M)",
full_width=True,
)
aim
return (aim,)
@app.cell
def _(np, trace):
fan_paths, fan_captured, _ = trace(np.linspace(1.5, 11.5, 24))
return fan_captured, fan_paths
@app.cell
def _(aim, fan_captured, fan_paths, np, plt, trace):
(ray,), (ray_captured,), (ray_angle,) = [x[:1] for x in trace([aim.value])]
fig, ax = plt.subplots(figsize=(7, 5.2))
for path, fell in zip(fan_paths, fan_captured):
ax.plot(*path.T, lw=0.7, color="#9a6700" if fell else "#8c959f", alpha=0.55)
ax.plot(*ray.T, lw=2.2, color="#cf222e" if ray_captured else "#0969da")
ax.add_patch(plt.Circle((0, 0), 2, color="black", zorder=3))
ax.add_patch(plt.Circle((0, 0), 3, fill=False, ls="--", color="#6e7781", lw=0.8))
ax.set(xlim=(-22, 22), ylim=(-14, 16), aspect="equal", xlabel="x / M", ylabel="y / M")
ax.set_title(
f"b = {aim.value:.2f} M: "
+ (
"falls in"
if ray_captured
else f"escapes after turning {np.degrees(ray_angle - np.pi):.0f}° away from straight"
)
)
fig.tight_layout()
fig
return
@app.cell(hide_code=True)
def _(B_CRITICAL, mo):
mo.md(f"""
The black disc is the event horizon at $r = 2M$. The dashed circle is the
photon sphere at $r = 3M$, where light can orbit the hole. Rays aimed with
$b < 3\\sqrt{{3}}\\,M \\approx {B_CRITICAL:.3f}\\,M$ (amber) fall in; rays
aimed just above it wind around the photon sphere before escaping. Set
the slider to 5.20 and then to 5.19 to see the boundary.
""")
return
@app.cell(hide_code=True)
def _(mo):
mo.md(r"""
## Deflection angle against Einstein's prediction
Far from the hole the deflection is small, and Einstein's weak-field
result $\delta = 4M/b$ holds. That is the figure Eddington's 1919
eclipse expedition tested for light grazing the Sun. Close to the hole
the full equation departs from it and the deflection grows without
bound as $b$ approaches the critical value.
""")
return
@app.cell
def _(B_CRITICAL, np, plt):
def deflection(b, step=0.002):
"""Total bending of rays that come from and return to infinity (u = 0)."""
u = np.zeros_like(b)
du = 1 / b
psi = np.zeros_like(b)
out = np.full_like(b, np.nan)
while np.isnan(out).any() and psi[0] < 8 * np.pi:
k1u, k1v = du, 3 * u**2 - u
k2u, k2v = du + step / 2 * k1v, 3 * (u + step / 2 * k1u) ** 2 - (u + step / 2 * k1u)
k3u, k3v = du + step / 2 * k2v, 3 * (u + step / 2 * k2u) ** 2 - (u + step / 2 * k2u)
k4u, k4v = du + step * k3v, 3 * (u + step * k3u) ** 2 - (u + step * k3u)
new_u = u + step / 6 * (k1u + 2 * k2u + 2 * k3u + k4u)
du = du + step / 6 * (k1v + 2 * k2v + 2 * k3v + k4v)
crossed = np.isnan(out) & (new_u < 0) & (psi > 0)
out[crossed] = psi[crossed] + step * u[crossed] / (u[crossed] - new_u[crossed])
u, psi = new_u, psi + step
return out - np.pi
impact = B_CRITICAL + np.geomspace(0.005, 95, 50)
bent = np.degrees(deflection(impact))
fig2, ax2 = plt.subplots(figsize=(7, 3.6))
ax2.loglog(impact, bent, color="#0969da", lw=2, label="Schwarzschild, integrated")
ax2.loglog(impact, np.degrees(4 / impact), color="#8c959f", ls="--", label="4M/b, weak field")
ax2.axvline(B_CRITICAL, color="#9a6700", lw=0.8)
ax2.set(xlabel="impact parameter b / M", ylabel="deflection (degrees)")
ax2.legend(frameon=False)
fig2.tight_layout()
fig2
return
@app.cell(hide_code=True)
def _(mo):
mo.md(r"""
## The shadow of M87*
A distant observer sees a dark disc where rays with $b < 3\sqrt{3}\,M$
would have come from. Its angular diameter is
$2 \cdot 3\sqrt{3}\, GM / (c^2 D)$. For the black hole in the galaxy
M87, with a mass of about 6.5 billion Suns at 16.8 megaparsecs (the
values the Event Horizon Telescope used in 2019), that gives:
""")
return
@app.cell
def _(mo, np):
G, c = 6.674e-11, 2.998e8
M_SUN, MPC = 1.989e30, 3.086e22
MICROARCSEC = np.radians(1 / 3.6e9)
mass = mo.ui.number(start=0.1, stop=20, step=0.1, value=6.5, label="Mass (billion Suns)")
distance = mo.ui.number(start=1, stop=100, step=0.1, value=16.8, label="Distance (Mpc)")
mo.hstack([mass, distance], justify="start")
return G, MICROARCSEC, MPC, M_SUN, c, distance, mass
@app.cell
def _(B_CRITICAL, G, MICROARCSEC, MPC, M_SUN, c, distance, mass, mo):
theta = 2 * B_CRITICAL * G * mass.value * 1e9 * M_SUN / (c**2 * distance.value * MPC)
mo.md(
f"""
**Shadow diameter: {theta / MICROARCSEC:.1f} microarcseconds.** The Event
Horizon Telescope measured a ring of 42 ± 3 microarcseconds
(EHT Collaboration 2019, ApJL 875, L1). The ring sits slightly outside
the shadow because the light we see is emitted near it.
"""
)
return
@app.cell(hide_code=True)
def _(mo):
mo.md("""
---
**Method.** Fourth-order Runge-Kutta in the swept angle, step 0.004 rad
(0.002 for the deflection curve). The pictured rays start at $r = 60M$;
the deflection curve integrates from and back to infinity ($u = 0$). **References.** K. Schwarzschild (1916); S. Chandrasekhar,
*The Mathematical Theory of Black Holes* (1983), ch. 3; Event Horizon
Telescope Collaboration, ApJL 875 L1 (2019); J.-P. Luminet, A&A 75 (1979) 228.
The disc is thin and Keplerian, emits from $r = 6M$ to $18M$ with a
Page-Thorne-like profile, and the star field is synthetic.
Fork it to trace a rotating (Kerr) black hole or a thick, turbulent disc.
""")
return
if __name__ == "__main__":
app.run()