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:
1 parent
8d011b7ec5
commit
b69c9cfd45
9 files changed
+446
-3
No files matched your search
@@ -30,6 +30,23 @@ int main(void) {
|
||||
-(sqrt(1.0 + 1.52 * 1.52) - 1.0) / 1.52) ||
|
||||
!nearly_equal(proper_time, asinh(1.52) / 1.52))
|
||||
goto done;
|
||||
movie_destroy(&movie);
|
||||
/* Nonuniform coordinate times must survive the row-per-frame path exactly. */
|
||||
for (size_t i = 0; i < loaded.count; ++i)
|
||||
loaded.samples[i].coordinate_time += 0.001 * i * i;
|
||||
if (movie_init_track_samples(&movie, &loaded) ||
|
||||
movie.frame_count != loaded.count)
|
||||
goto done;
|
||||
for (size_t i = 0; i < loaded.count; ++i) {
|
||||
if (movie.frames[i].coordinate_time != loaded.samples[i].coordinate_time ||
|
||||
movie.frames[i].observer.coordinate_time != loaded.samples[i].coordinate_time ||
|
||||
movie.frames[i].proper_time != loaded.samples[i].proper_time)
|
||||
goto done;
|
||||
for (int a = 0; a < 4; ++a)
|
||||
for (int mu = 0; mu < 4; ++mu)
|
||||
if (movie.frames[i].observer.tetrad[a][mu] != loaded.samples[i].tetrad[a][mu])
|
||||
goto done;
|
||||
}
|
||||
{
|
||||
FILE *bad = fopen(path, "w");
|
||||
ObserverTrack invalid = {0};
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user