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.
This commit is contained in:
wyj committed 2026-09-06 05:56:51 -04:00
1 parent 8d011b7ec5
commit b69c9cfd45
9 files changed
+446 -3

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Independent orbit/transport invariants and actual renderer CSV consumption."""
import importlib.util
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('track', ROOT / 'scripts/schwarzschild_camera_track.py')
track = importlib.util.module_from_spec(spec)
spec.loader.exec_module(track)
class TrackTests(unittest.TestCase):
def test_sampling_endpoints(self):
state = track.initial_state([8, 0, 0], [0, 0, 0])
for duration, count in ((0., 1), (.29, 30), (.295, 30)):
tau, states, stopped = track.integrate(state, duration, 100)
self.assertEqual(len(tau), count)
self.assertLessEqual(tau[-1], duration)
self.assertIsNone(stopped)
def test_radial_infall_through_horizon(self):
# E=1 infall from rest at infinity: dr/dtau=-sqrt(2/r).
r = 8.
w = np.sqrt(2/r)
ut = (1 + w + w*w) / (1+w)
state = track.initial_state([r, 0, 0], [-w/ut, 0, 0])
tau, states, stopped = track.integrate(state, 20, 20, stop_radius=.01)
expected_stop = 2/(3*np.sqrt(2)) * (r**1.5 - .01**1.5)
self.assertAlmostEqual(stopped, expected_stop, delta=2e-8)
radii = np.linalg.norm(states[:, 1:4], axis=1)
np.testing.assert_allclose(radii, (r**1.5 - 1.5*np.sqrt(2)*tau)**(2/3), atol=2e-8, rtol=2e-8)
self.assertLess(radii[-1], 2)
for s in states:
g, _ = track.metric_connection(s[1:4])
e = s[4:].reshape(4, 4)
np.testing.assert_allclose(e @ g @ e.T, track.ETA, atol=2e-8)
self.assertAlmostEqual(-(g @ e[0])[0], 1, delta=2e-8)
def test_circular_orbit_and_transport_convergence(self):
r = 8.
omega = r**-1.5
state = track.initial_state([r, 0, 0], [0, r*omega, 0])
# Choose a pure Schwarzschild radial leg, transformed to KS time.
# Projecting a zero-KS-time radial seed would produce a different leg.
e = state[4:].reshape(4, 4)
root = np.sqrt(1-2/r)
e[1] = [-2/r/root, -root, 0, 0]
e[2] = [0, 0, 0, 1]
e[3] = [e[0, 2]/root, 0, e[0, 0]*root, 0]
duration = 2*np.pi/omega*np.sqrt(1-3/r)
results = []
for tol in (1e-6, 1e-10):
tau, states, stopped = track.integrate(state, duration, 2, rtol=tol, atol=tol*.01)
self.assertIsNone(stopped)
angle = omega*tau/np.sqrt(1-3/r)
expected = r*np.column_stack([np.cos(angle), np.sin(angle), np.zeros_like(angle)])
results.append(np.max(np.abs(states[:, 1:4]-expected)))
self.assertLess(results[1], 2e-7)
self.assertLess(results[1], results[0]/100)
s = states[-1]
e = s[4:].reshape(4, 4)
g, _ = track.metric_connection(s[1:4])
np.testing.assert_allclose(e @ g @ e.T, track.ETA, atol=2e-8)
# Analytic parallel transport of initially inward radial e1 on circular orbit.
# In Schwarzschild components e1^r=-sqrt(1-2/r) cos(omega*tau).
n = s[1:4]/np.linalg.norm(s[1:4])
self.assertAlmostEqual(n @ e[1, 1:], -np.sqrt(1-2/r)*np.cos(omega*tau[-1]), delta=2e-8)
def test_initial_tetrad_and_invalid_velocity(self):
state = track.initial_state([2, 0, 0], [-.5, .1, 0], 40, 30, 17)
explicit = track.initial_state([2, 0, 0], [-.5, .1, 0], tetrad=state[4:].reshape(4, 4))
np.testing.assert_array_equal(state, explicit)
with self.assertRaises(ValueError):
track.initial_state([2, 0, 0], [0, 0, 0])
with self.assertRaises(ValueError):
track.initial_state([8, 0, 0], [0, 0, 0], tetrad=np.eye(4))
def test_csv_movie(self):
binary = ROOT / 'build/Release/schwarzschild_sky'
if not binary.exists():
self.fail('build Schwarzschild Release renderer before running this test')
with tempfile.TemporaryDirectory() as directory:
directory = Path(directory)
csv = directory / 'camera.csv'
command = [sys.executable, str(ROOT/'scripts/schwarzschild_camera_track.py'),
'--output', str(csv), '--position', '8', '0', '0',
'--look-ra-deg', '0', '--look-dec-deg', '0',
'--fps', '10', '--duration', '.21', '--t0', '7']
subprocess.run(command, check=True, capture_output=True, text=True)
rows = np.loadtxt(csv, delimiter=',', skiprows=1)
self.assertEqual(rows.shape, (3, 21))
np.testing.assert_allclose(rows[:, 1], [0, .1, .2])
result = subprocess.run([str(binary), '--observer-track', str(csv),
'--movie-track-samples', '--frames-dir', str(directory),
'--catalog', str(ROOT/'assets/sky_grid_5deg.csv'), '--width', '16',
'--height', '16', '--coarse-cell-pixels', '8', '--refine-max-level', '0'],
env=dict(os.environ, OMP_NUM_THREADS='2'), capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(len(list(directory.glob('frame_*.png'))), 3)
for png in directory.glob('frame_*.png'):
self.assertEqual(png.read_bytes()[:8], b'\x89PNG\r\n\x1a\n')
# Error must happen before writing an output file.
csv.unlink()
bad = subprocess.run(command + ['--velocity', '2', '0', '0'], capture_output=True)
self.assertNotEqual(bad.returncode, 0)
self.assertFalse(csv.exists())
if __name__ == '__main__':
unittest.main()