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.
166 lines
8.6 KiB
Python
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()
|