Files
GR-raytracing/scripts/schwarzschild_camera_track.py
wyj b69c9cfd45 Feat: generate freely falling Schwarzschild camera tracks
Integrate timelike geodesics and Fermi-Walker tetrads in ingoing Kerr-Schild coordinates, sampled at a configurable proper-time cadence.

Add movie-track-samples to preserve CSV events as frames. Document usage and singularity guards, and cover analytic orbits, transport convergence, sampling, and CSV rendering.
2026-09-06 05:56:51 -04:00

166 lines
8.6 KiB
Python

#!/usr/bin/env python3
"""Free-fall camera with parallel (= geodesic Fermi-Walker) transport.
Ingoing Cartesian Kerr-Schild, signature -+++, G=c=M=1. Requires numpy/scipy.
The renderer's Schwarzschild backend fixes M=1 as well.
"""
import argparse
import csv
import math
from pathlib import Path
import sys
import numpy as np
from scipy.integrate import solve_ivp
ETA = np.diag([-1., 1., 1., 1.])
HEADER = ['t', 'tau', 'x', 'y', 'z'] + [f'e{a}{c}' for a in range(4) for c in 'txyz']
def metric_connection(x):
"""Analytic g and Christoffels; dg[k,mu,nu] = partial_k g_mu_nu."""
r = np.linalg.norm(x)
if not np.isfinite(r) or r <= 0:
raise ValueError('metric undefined at r=0 or nonfinite position')
n = x / r
ell = np.r_[1., n]
f = 2 / r
g = ETA + f * np.outer(ell, ell)
raised = ETA @ ell
inverse = ETA - f * np.outer(raised, raised)
dg = np.zeros((4, 4, 4))
for k in range(3):
dl = np.r_[0., (np.eye(3)[k] - n[k] * n) / r]
dg[k+1] = f * (np.outer(dl, ell) + np.outer(ell, dl)
- n[k] / r * np.outer(ell, ell))
connection = .5 * np.einsum('ml,alb->mab', inverse,
dg + dg.transpose(2, 1, 0) - dg.transpose(1, 0, 2))
return g, connection
def initial_state(position, velocity, ra=None, dec=None, roll=0., tetrad=None, t0=0.):
g, _ = metric_connection(position)
u = np.r_[1., velocity]
q = u @ g @ u
if not np.isfinite(q) or q >= 0:
raise ValueError('coordinate velocity must be future timelike: g(1,v;1,v) < 0')
u /= math.sqrt(-q)
if tetrad is not None:
e = np.asarray(tetrad, dtype=float)
if e.shape != (4, 4) or not np.all(np.isfinite(e)):
raise ValueError('initial tetrad must contain four rows of four finite components')
if not np.allclose(e[0], u, rtol=1e-9, atol=1e-9):
raise ValueError('initial tetrad e0 must agree with the specified coordinate velocity')
if np.max(np.abs(e @ g @ e.T - ETA)) > 1e-8:
raise ValueError('initial tetrad must be Lorentz orthonormal')
if np.linalg.det(e) <= 0:
raise ValueError('initial tetrad must have forward cross up = right orientation')
else:
if ra is None:
direction = -np.asarray(position) / np.linalg.norm(position)
ra = math.degrees(math.atan2(direction[1], direction[0])) % 360
dec = math.degrees(math.asin(direction[2]))
a, d = np.deg2rad([ra, dec])
e = np.array([u, [0, math.cos(d)*math.cos(a), math.cos(d)*math.sin(a), math.sin(d)],
[0, -math.sin(d)*math.cos(a), -math.sin(d)*math.sin(a), math.cos(d)],
[0, math.sin(a), -math.cos(a), 0]])
for i in range(1, 4):
for _ in range(2):
for j in range(i):
e[i] -= (-1 if j == 0 else 1) * (e[i] @ g @ e[j]) * e[j]
e[i] /= math.sqrt(e[i] @ g @ e[i])
angle = math.radians(roll)
up, right = e[2].copy(), e[3].copy()
e[2] = math.cos(angle)*up + math.sin(angle)*right
e[3] = -math.sin(angle)*up + math.cos(angle)*right
return np.r_[t0, position, e.ravel()]
def rhs(tau, state):
_, connection = metric_connection(state[1:4])
e = state[4:].reshape(4, 4)
# e0 is u: transporting all four legs also integrates the timelike geodesic.
de = -np.einsum('mab,a,ib->im', connection, e[0], e)
return np.r_[e[0], de.ravel()]
def integrate(state, duration, fps, stop_radius=1e-3, rtol=1e-10, atol=1e-12):
if np.linalg.norm(state[1:4]) <= stop_radius:
raise ValueError('initial radius must exceed stop-radius')
if duration == 0:
return np.array([0.]), state[None, :], None
def stop(tau, y):
return np.linalg.norm(y[1:4]) - stop_radius
stop.terminal = True
stop.direction = -1
solution = solve_ivp(rhs, (0., duration), state, method='DOP853',
rtol=rtol, atol=atol, events=stop, dense_output=True)
if not solution.success:
raise ValueError(f'integration failed at tau={solution.t[-1]:.17g}: {solution.message}')
end = solution.t[-1]
# Include tau=0 and every full 1/fps interval; never add an off-cadence endpoint.
count = int(math.floor(np.nextafter(end * fps, np.inf))) + 1
tau = np.arange(count, dtype=float) / fps
tau = tau[tau <= end]
states = solution.sol(tau).T
if not np.all(np.isfinite(states)) or np.any(np.diff(states[:, 0]) <= 0):
raise ValueError('nonfinite trajectory or coordinate time lost monotonicity')
return tau, states, end if solution.t_events[0].size else None
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter)
p.add_argument('--output', type=Path, required=True, help='21-column movie CSV')
p.add_argument('--position', nargs=3, type=float, required=True, metavar=('X', 'Y', 'Z'), help='initial KS Cartesian position in M')
p.add_argument('--velocity', nargs=3, type=float, default=[0., 0., 0.], help='coordinate dx/dt, dy/dt, dz/dt (not local 3-speed)')
p.add_argument('--look-ra-deg', type=float, help='initial forward RA; default points toward origin')
p.add_argument('--look-dec-deg', type=float, help='initial forward Dec; specify together with RA')
p.add_argument('--roll-deg', type=float, default=0., help='initial roll, same sign as renderer')
p.add_argument('--tetrad', type=float, nargs=16, help='explicit row-major e0,e1,e2,e3 in t,x,y,z; replaces look/roll')
p.add_argument('--t0', type=float, default=0., help='initial KS coordinate time')
p.add_argument('--fps', type=float, default=30., help='samples per unit proper time M')
p.add_argument('--duration', type=float, required=True, help='requested elapsed proper time in M')
p.add_argument('--stop-radius', type=float, default=1e-3, help='numerical singularity guard in M, strictly between 0 and 2')
p.add_argument('--rtol', type=float, default=1e-10, help='DOP853 relative error tolerance')
p.add_argument('--atol', type=float, default=1e-12, help='DOP853 absolute error tolerance')
args = p.parse_args()
try:
numbers = [*args.position, *args.velocity, args.roll_deg, args.t0, args.fps,
args.duration, args.stop_radius, args.rtol, args.atol]
numbers += [v for v in (args.look_ra_deg, args.look_dec_deg) if v is not None]
if not all(math.isfinite(v) for v in numbers):
raise ValueError('all numeric arguments must be finite')
if args.fps <= 0 or args.duration < 0 or not 0 < args.stop_radius < 2 or min(args.rtol, args.atol) <= 0:
raise ValueError('require fps,rtol,atol > 0, duration >= 0 and 0 < stop-radius < 2')
if (args.look_ra_deg is None) != (args.look_dec_deg is None):
raise ValueError('specify both look-ra-deg and look-dec-deg')
if args.look_ra_deg is not None and not (0 <= args.look_ra_deg < 360 and abs(args.look_dec_deg) <= 90):
raise ValueError('require 0 <= RA < 360 and -90 <= Dec <= 90')
if args.tetrad is not None and (args.look_ra_deg is not None or args.roll_deg != 0):
raise ValueError('tetrad conflicts with look/roll')
e = None if args.tetrad is None else np.array(args.tetrad).reshape(4, 4)
state = initial_state(args.position, args.velocity, args.look_ra_deg,
args.look_dec_deg, args.roll_deg, e, args.t0)
tau, states, stopped = integrate(state, args.duration, args.fps,
args.stop_radius, args.rtol, args.atol)
error = max(np.max(np.abs(s[4:].reshape(4, 4) @ metric_connection(s[1:4])[0]
@ s[4:].reshape(4, 4).T - ETA)) for s in states)
if error > 1e-6:
raise ValueError(f'tetrad norm drift {error:.3g} exceeds 1e-6; tighten tolerances')
with args.output.open('w', newline='') as stream:
writer = csv.writer(stream)
writer.writerow(HEADER)
for t, s in zip(tau, states):
writer.writerow(format(v, '.17g') for v in np.r_[s[0], t, s[1:]])
print(f'Wrote {len(tau)} frames; tau=0..{tau[-1]:.17g}, t={states[0,0]:.17g}..{states[-1,0]:.17g}; max tetrad error={error:.3g}', file=sys.stderr)
if stopped is not None:
print(f'Stopped early at r={args.stop_radius:g} M, tau={stopped:.17g} (numerical guard before r=0).', file=sys.stderr)
print('Render with --observer-track <CSV> --movie-track-samples --frames-dir <DIR>; encode at the chosen fps.', file=sys.stderr)
except (ValueError, OSError, OverflowError) as exc:
p.error(str(exc))
if __name__ == '__main__':
main()