#!/usr/bin/env python3 """Exercise camera defaults/errors and single-frame/movie agreement (CPU builds).""" import os from pathlib import Path import struct import subprocess import sys import tempfile import zlib def fits_max(path): """Largest positive sample in the renderer's three-plane float FITS.""" data = path.read_bytes() cards, offset = [], 0 while True: block = data[offset:offset + 2880] assert len(block) == 2880, f'truncated FITS header: {path}' offset += 2880 cards.extend(block[i:i + 80] for i in range(0, 2880, 80)) if any(card.startswith(b'END') for card in cards[-36:]): break values = {} for card in cards: if card[8:10] == b'= ': values[card[:8].decode().strip()] = card[10:30].decode().strip() shape = tuple(int(values[f'NAXIS{axis}']) for axis in (1, 2, 3)) count = shape[0] * shape[1] * shape[2] payload = data[offset:offset + count * 4] assert len(payload) == count * 4, f'truncated FITS payload: {path}' return max(struct.unpack(f'>{count}f', payload)) BUILD = Path(sys.argv[1] if len(sys.argv) > 1 else 'build/Release').resolve() TESTDIR = Path(sys.argv[2]).resolve() if len(sys.argv) > 2 else BUILD ENV = dict(os.environ, OMP_NUM_THREADS='4') FITSDIFF = Path(__file__).resolve().parent.parent / 'scripts' / 'fits_floatdiff.py' PATH_MAX = os.pathconf('/', 'PC_PATH_MAX') def run(binary, *args, ok=True, env=ENV): result = subprocess.run([str(binary), *map(str, args)], env=env, capture_output=True, text=True) if (result.returncode == 0) != ok: raise AssertionError(f'{binary.name} {args}: {result.returncode}\n{result.stderr}') return result def png_payload(path, dimensions): data = path.read_bytes() assert data[:8] == b'\x89PNG\r\n\x1a\n' offset, compressed = 8, bytearray() while offset < len(data): count, kind = struct.unpack_from('>I4s', data, offset) payload = data[offset + 8:offset + 8 + count] if kind == b'IHDR': assert struct.unpack_from('>II', payload) == dimensions if kind == b'IDAT': compressed.extend(payload) offset += count + 12 return zlib.decompress(compressed) def ppm_payload(path, dimensions): data = path.read_bytes() assert data[:2] == b'P6' fields, index = [], 2 while len(fields) < 3: while data[index:index + 1].isspace(): index += 1 if data[index:index + 1] == b'#': while data[index:index + 1] not in (b'\n', b''): index += 1 continue start = index while not data[index:index + 1].isspace(): index += 1 fields.append(int(data[start:index])) index += 1 assert (fields[0], fields[1]) == dimensions, (fields, dimensions) return data[index:index + fields[0] * fields[1] * 3] def image_payload(path, dimensions=(64, 48), allow_black=False): data = path.read_bytes() raw = ppm_payload(path, dimensions) if data[:2] == b'P6' else png_payload(path, dimensions) assert raw, f'missing image data: {path}' assert allow_black or any(raw), f'empty image: {path}' return raw # Version 3 wire layout. The first 100 bytes are the v2 provenance; the v3 # adaptive policy appends 9 doubles and a u32 (76 bytes) so the provenance # block ends at 176. Each frame header is 48 bytes, so vertex payload starts at # 224. A v3 vertex keeps the 84-byte v2 record and appends three u64 cost # counters; a triangle stays 32 bytes. The CRC covers only the vertex+triangle # payload, never the header. MAP_PROVENANCE_END = 176 MAP_FRAME_HEADER_START = MAP_PROVENANCE_END MAP_VERTEX_START = MAP_PROVENANCE_END + 48 MAP_VERTEX_SIZE = 84 + 24 MAP_TRIANGLE_SIZE = 32 def map_vertices(path): data = path.read_bytes() assert data[:8] == b'GRLENS\x01\x00' assert struct.unpack_from(' PATH_MAX too_long = run(binary, *common, '--draw-mesh', '--catalog', tmp / 'missing_catalog.csv', '--output', long_path, ok=False) assert 'Mesh overlay output path is too long' in too_long.stderr, too_long.stderr assert 'Blackbody backend' not in (too_long.stdout + too_long.stderr), too_long.stderr assert 'PSF cache ready' not in (too_long.stdout + too_long.stderr) errors = [ (['--observer-time'], None), (['--observer-time', ''], None), (['--observer-time', 'bad'], None), (['--observer-time', 'nan'], None), (['--observer-time', 'inf'], None), (['--observer-time', '-inf'], None), (['--observer-track', 'missing.csv', '--observer-time', 0], 'cannot be combined'), (['--frames-dir', tmp, '--observer-time', 0], 'cannot be combined'), (['--lens-map-input', 'missing.grlens', '--observer-time', 0], 'cannot be combined'), (['--observer-position', 1, 2], None), (['--observer-position', 1, 2, 'nan'], None), (['--observer-velocity', 0, 0, 'inf'], None), (['--look-ra-deg', 'nan'], None), (['--look-dec-deg', 'inf'], None), (['--observer-radius', 'nan'], None), (['--observer-radius', 0], None), (['--camera-roll-deg', 'nan'], None), (['--observer-position', 0, 0, 0], 'Cannot infer'), (['--observer-position', 3, 4, 5, '--observer-radius', 30], 'mutually exclusive'), (['--observer-velocity', 10, 0, 0], 'not timelike'), (['--observer-inward-speed', 0], None), (['--observer-track', 'missing.csv', '--observer-velocity', 0, 0, 0], 'cannot be combined'), (['--frames-dir', tmp, '--look-ra-deg', 0], 'cannot be combined'), (['--lens-map-input', 'missing.grlens', '--camera-roll-deg', 0], 'cannot be combined'), (['--tone-map', 'unknown'], None), (['--tone-map'], None), (['--tone-map-p'], None), (['--tone-map-p', 0], None), (['--tone-map-p', 0.5], None), (['--tone-map-p', 'nan'], None), (['--tone-map-p', 'inf'], None), (['--tone-map', 'reinhard', '--tone-map-p', 2], 'applies only'), (['--sensor-bloom-limit', 1], 'specified together'), (['--sensor-bloom-transfer', 0.5], 'specified together'), (['--sensor-bloom-limit', 0], None), (['--sensor-bloom-limit', -1], None), (['--sensor-bloom-limit', 'nan'], None), (['--sensor-bloom-limit', 'inf'], None), (['--sensor-bloom-limit', 1, '--sensor-bloom-transfer', -0.1], None), (['--sensor-bloom-limit', 1, '--sensor-bloom-transfer', 1], None), (['--sensor-bloom-limit', 1, '--sensor-bloom-transfer', 1.5], None), (['--sensor-bloom-limit', 1, '--sensor-bloom-transfer', 'nan'], None), (['--sensor-bloom-limit', 1, '--sensor-bloom-transfer', 'inf'], None), (['--dark-threshold', 'nan'], None), (['--dark-threshold', 'inf'], None), (['--dark-threshold', 0], None), (['--dark-threshold', -1], None), ] if backend == 'schwarzschild': errors += [(['--observer-position', 1.75, 0, 0], 'not timelike')] render('inside', '--observer-position', 1.75, 0, 0, '--observer-velocity', -0.5, 0, 0, '--look-ra-deg', 0, '--look-dec-deg', 0) # A camera inside the old r=1.5 capture cutoff is a normal target. render('inside_cutoff', '--observer-position', 1.5, 0, 0, '--observer-velocity', -0.5, 0, 0, '--look-ra-deg', 0, '--look-dec-deg', 0) # The camera-relative dark threshold is configurable. render('dark_threshold_10', '--dark-threshold', 10) for options, message in errors: missing_catalog = tmp / 'should_not_be_created.csv' result = run(binary, '--catalog', missing_catalog, *options, ok=False) if message: assert message in result.stderr, result.stderr assert not missing_catalog.exists(), result.stderr assert 'PSF cache ready' not in (result.stdout + result.stderr) track = tmp / f'{backend}.csv' run(TESTDIR / f'test_observer_{backend}', track) single_map, movie_map = tmp / 'single.grlens', tmp / 'movie.grlens' single = render('moving', '--observer-position', 3, -4, 5, '--observer-velocity', 0.2, -0.1, 0.3, '--look-ra-deg', 37, '--look-dec-deg', -23, '--camera-roll-deg', 19, '--lens-map-output', single_map) run(binary, *common, '--observer-track', track, '--frames-dir', tmp, '--frames-prefix', backend, '--duration', 0, '--fps', 1, '--lens-map-output', movie_map) movie = image_payload(tmp / f'{backend}_000000.{ext}') assert single == movie, f'{backend}: single/movie image mismatch' a, ta = map_vertices(single_map) b, tb = map_vertices(movie_map) assert len(a) == len(b) and ta == tb max_error = 0 for x, y in zip(a, b): # Indices 9..11 are end_id/outcome/reason; indices 12..14 are the # per-vertex integration cost counters, which are not physical ray # provenance and may differ between the single and movie schedulers. assert x[9:12] == y[9:12], 'ray endpoint provenance mismatch' max_error = max(max_error, *(abs(v - w) for v, w in zip(x[:9], y[:9]))) assert max_error < 1e-9, max_error # Movie frames keep the clean primary image and gain correctly named # mesh siblings; never "frame_000000.png_mesh.png". movie_mesh_dir = tmp / f'{backend}_movie_mesh' movie_mesh_dir.mkdir() run(binary, *common, '--observer-track', track, '--frames-dir', movie_mesh_dir, '--frames-prefix', 'frame', '--duration', 0, '--fps', 1, '--draw-mesh') assert image_payload(movie_mesh_dir / f'frame_000000.{ext}') == movie assert (movie_mesh_dir / f'frame_000000_mesh.{ext}').exists() assert not (movie_mesh_dir / f'frame_000000.{ext}_mesh.{ext}').exists() # A map import must still work without evaluating a camera/metric, and # must follow the same clean-main plus mesh-sibling rule. assert single == render('import', '--lens-map-input', single_map) # A structurally valid v2 map with an explicit metric failure must not # bypass the live-tracing publication gate. Recompute the payload CRC # so this tests completion semantics rather than corruption detection. incomplete_map = tmp / f'{backend}_incomplete.grlens' payload = bytearray(single_map.read_bytes()) struct.pack_into(' 0.0 bloom_limit = base_peak / 100.0 else: bloom_limit = 1.0 hdr_args = ['--hdr-output'] if hdr_available else [] bloom_output = tmp / f'{backend}_bloom.{ext}' bloom_run = run(binary, *common, *hdr_args, '--sensor-bloom-limit', bloom_limit, '--sensor-bloom-transfer', 0.5, '--output', bloom_output) assert image_payload(bloom_output) != baseline assert 'Sensor bloom:' in bloom_run.stdout, bloom_run.stdout report = bloom_run.stdout.split('Sensor bloom:', 1)[1].splitlines()[0] fields = dict(token.split('=', 1) for token in report.split() if '=' in token) assert int(fields['saturated']) > 0, report assert int(fields['iterations'].split('/')[0]) >= 1, report assert float(fields['peak']) >= bloom_limit, report if hdr_available: # The raw FITS is written before the model runs, so it stays # byte-identical to the baseline even though the display changes. bloom_fits = tmp / f'{backend}_bloom_HDR.fits' assert bloom_fits.exists(), bloom_run.stderr diff = subprocess.run([sys.executable, str(FITSDIFF), str(base_fits), str(bloom_fits)], capture_output=True, text=True) assert diff.returncode == 0, diff.stdout + diff.stderr assert 'mismatches=0 max_abs=0 max_rel=0' in diff.stdout, diff.stdout # The mesh overlay is drawn on the already-bloomed frame, so the primary # image is unchanged by --draw-mesh and the diagnostic lines do not feed # back into the overflow model. bloom_mesh_output = tmp / f'{backend}_bloom_mesh.{ext}' run(binary, *common, *hdr_args, '--sensor-bloom-limit', bloom_limit, '--sensor-bloom-transfer', 0.5, '--draw-mesh', '--output', bloom_mesh_output) assert image_payload(bloom_mesh_output) == image_payload(bloom_output) bloom_mesh_sibling = tmp / f'{backend}_bloom_mesh_mesh.{ext}' assert bloom_mesh_sibling.exists() assert image_payload(bloom_mesh_sibling) != image_payload(bloom_output) if hdr_available: bloom_mesh_fits = tmp / f'{backend}_bloom_mesh_HDR.fits' diff = subprocess.run([sys.executable, str(FITSDIFF), str(base_fits), str(bloom_mesh_fits)], capture_output=True, text=True) assert diff.returncode == 0, diff.stdout + diff.stderr assert 'mismatches=0 max_abs=0 max_rel=0' in diff.stdout, diff.stdout if backend == 'schwarzschild': # Two inward-looking free-fall samples at r=6.2696 and r=3.1593. # At 16:9 the latter frame finishes in generation 0, while the # former still needs refinement. Finishing the empty batch used # to abort the whole movie in generation 1. mixed = Path(__file__).parent / 'fixtures/schwarzschild_mixed_refinement.csv' parallel_map = tmp / 'mixed-parallel.grlens' result = run(binary, *common, '--height', 36, '--fov-deg', 60, '--refine-max-level', 3, '--observer-track', mixed, '--movie-track-samples', '--frames-dir', tmp, '--frames-prefix', 'mixed', '--verbose', '--lens-map-output', parallel_map) assert 'Ray trace generation 1: frame 0 added' in result.stdout assert 'Ray trace generation 0: frame 1 added' not in result.stdout for frame in range(2): image_payload(tmp / f'mixed_{frame:06d}.{ext}', dimensions=(64, 36), allow_black=True) # Thread scheduling must preserve endpoints, frame/sample IDs and # the resulting adaptive mesh across the entire slab sweep. for threads in (1, 16): comparison_map = tmp / f'mixed-{threads}-threads.grlens' run(binary, *common, '--height', 36, '--fov-deg', 60, '--refine-max-level', 3, '--observer-track', mixed, '--movie-track-samples', '--frames-dir', tmp, '--frames-prefix', f'mixed-{threads}-threads', '--lens-map-output', comparison_map, env=dict(ENV, OMP_NUM_THREADS=str(threads))) assert parallel_map.read_bytes() == comparison_map.read_bytes(), \ f'movie lens map changed with {threads} threads' print('schwarzschild: movie lens map identical with 1, 4 and 16 threads', flush=True) print(f'{backend}: CLI checks passed; single/movie image identical, map max error {max_error:.3g}', flush=True) # In the moving bubble, (t, x) -> (t+T, x+v_s*T) preserves the # metric and physical ray endpoints. This detects a stale t=0 in either # camera metric evaluation or ray initialization, not just map metadata. alc = BUILD / 'alcubierre_sky' if alc.exists(): reference = None for time in (0, 12.5, -12.5): path = tmp / f'alcubierre_time_{time}.grlens' run(alc, *common, '--alcubierre-vs', 0.3, '--alcubierre-radius', 1, '--observer-time', time, '--observer-position', 0.3 * time, 0, 0, '--observer-velocity', 0.3, 0, 0, '--look-ra-deg', 0, '--look-dec-deg', 0, '--lens-map-output', path, '--output', tmp / f'alcubierre.{ext}') assert struct.unpack_from('