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.
118 lines
5.6 KiB
Python
118 lines
5.6 KiB
Python
#!/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()
|