Compare commits

...
3 Commits
16 changed files with 1325 additions and 136 deletions

No files matched your search

+2
View File
@@ -299,6 +299,8 @@ test: $(CAMERA_TEST_TARGETS) $(TEST_TARGET) $(ADAPTIVE_GEODESIC_TEST_TARGET) $(A
$(SENSOR_BLOOM_TEST_TARGET)
python3 tests/test_camera_cli.py $(BUILD_DIR) $(TEST_OUT_DIR)
python3 tests/test_adaptive_cli.py $(BUILD_DIR) $(TEST_OUT_DIR)
python3 tests/test_ray_diagnostics.py $(BUILD_DIR) $(TEST_OUT_DIR)
python3 tests/test_output_streams.py $(BUILD_DIR) $(TEST_OUT_DIR)
tone-map-test: $(TONE_MAP_TEST_TARGET)
$(TONE_MAP_TEST_TARGET)
+14 -1
View File
@@ -381,7 +381,9 @@ typedef struct {
单张与电影共享 `ObserverState` 和 ray 初始化。单张不是只指定三维位置:
由事件、坐标速度、指向与 roll 生成完整四速度和 tetrad。
目前解析单张事件取 $t=0$;此构造不要求静态时空或静止观测者。
`--observer-time T` 指定单张相机事件的坐标时(默认 $t=0$,接受任意有限值,
包括负值);metric 求值、ray 初始化与 lens-map 帧元数据使用同一事件时刻。
此构造不要求静态时空或静止观测者;瞬时相机的 proper time 仍以零为参考。
`--observer-position X Y Z` 与 `--look-ra-deg` / `--look-dec-deg` 独立。
只有位置时取朝原点的坐标方向;只有指向时令 $\mathbf x=-R\mathbf d$。
@@ -777,6 +779,17 @@ witness 提升为正式 midpoint 时原地复用同一 vertex id,只保留一
provenance 保存 coordinate-time step 与初始 step 预算,使实际积分来源与成本可
replay。
诊断输出所有权:所有 ray 失败报告只在 CLI/main 的串行后处理阶段发出,
ray-tracing 的 OpenMP worker 与 geodesic 热循环不写任何日志(既有 PSF splat
诊断不属于此范围)。稳定的 `ray_reason_name` 覆盖全部
`RayReason`;默认(非 verbose)也按 frame/phase 输出 `INCOMPLETE` 的 reason
直方图与真实 frame id、相机坐标时间,verbose 或 GR_DEBUG 才追加每 reason 有界
数量的代表性样本(generation/phase、sample id/kind、持久 vertex id、film 坐标、
可信 endpoint 的 stop 状态与 accepted/rejected/RHS 计数)并报告被抑制数量。
`UNRESOLVED/BUDGET_EXHAUSTED` 只在帧边界统计确认存在 blocking triangle 时按帧
报告,与数值 `INCOMPLETE` 区分;replay 使用文件中保存的几何与顶点诊断,不虚构
stop 状态,也不把未重试的旧失败当作新失败重复计数。
---
# 18A. 渐近外区、escape worldtube 与 endpoint 协议
+7 -7
View File
@@ -301,7 +301,7 @@ static void report_group(const DummyPsfSink *sink, int full_only,
if (!full_only || sink->samples[i].events == sink->event_capacity)
++count;
if (count == 0) {
fprintf(stderr, "Dummy PSF %s chunks: none\n", label);
fprintf(stdout, "Dummy PSF %s chunks: none\n", label);
return;
}
double *events = count > SIZE_MAX / (6 * sizeof *events)
@@ -338,7 +338,7 @@ static void report_group(const DummyPsfSink *sink, int full_only,
"maximum_successive_triangle_jump_px"};
for (size_t field = 0; field < 6; ++field) {
qsort(series[field], count, sizeof **series, compare_double);
fprintf(stderr,
fprintf(stdout,
"Dummy PSF %s %s: min=%.3f p10=%.3f p25=%.3f p50=%.3f "
"p75=%.3f p90=%.3f p99=%.3f max=%.3f\n",
label, names[field], series[field][0],
@@ -349,7 +349,7 @@ static void report_group(const DummyPsfSink *sink, int full_only,
percentile(series[field], count, 0.90),
percentile(series[field], count, 0.99), series[field][count - 1]);
}
fprintf(stderr,
fprintf(stdout,
"Dummy PSF %s adaptive criterion: %zu/%zu chunks tile16-eligible "
"(events>=8192 and events/occupied_32px_tiles>=32)\n",
label, adaptive, count);
@@ -386,7 +386,7 @@ static void report_support_group(const DummyPsfSink *sink, int full_only,
for (size_t field = 0; field < 5; ++field) {
double *series = values + field * count;
qsort(series, count, sizeof *series, compare_double);
fprintf(stderr,
fprintf(stdout,
"Dummy PSF %s %s: min=%.3f p10=%.3f p25=%.3f p50=%.3f "
"p75=%.3f p90=%.3f p99=%.3f max=%.3f\n",
label, names[field], series[0],
@@ -395,7 +395,7 @@ static void report_support_group(const DummyPsfSink *sink, int full_only,
percentile(series, count, 0.90), percentile(series, count, 0.99),
series[count - 1]);
}
fprintf(stderr,
fprintf(stdout,
"Dummy PSF %s support totals: references=%zu tasks=%zu summed_first_pass=%.3f s\n",
label, total_refs, total_tasks, total_pass);
free(values);
@@ -407,7 +407,7 @@ void dummy_psf_sink_report(const DummyPsfSink *sink) {
size_t full = 0;
for (size_t i = 0; i < sink->count; ++i)
full += sink->samples[i].events == sink->event_capacity;
fprintf(stderr,
fprintf(stdout,
"Dummy PSF diagnostic only: no HDR or image was accumulated/written.\n"
"Dummy PSF chunks: total=%zu full=%zu partial=%zu events=%zu "
"capacity=%zu selector_tile=%dpx\n",
@@ -416,7 +416,7 @@ void dummy_psf_sink_report(const DummyPsfSink *sink) {
report_group(sink, 0, "all");
report_group(sink, 1, "full");
if (sink->support_stats) {
fputs("Dummy PSF support diagnostic: 16px rectangular cache-limited first pass only; no references were materialized or uploaded.\n", stderr);
fputs("Dummy PSF support diagnostic: 16px rectangular cache-limited first pass only; no references were materialized or uploaded.\n", stdout);
report_support_group(sink, 0, "all");
report_support_group(sink, 1, "full");
}
+4 -4
View File
@@ -2260,7 +2260,7 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
(size_t)dummy_workers, completed, 1);
}
if (psf_event_sink_destroy(&owner)) dummy_failed = 1;
fprintf(stderr,
fprintf(stdout,
"Dummy PSF producers: %d workers; classification/chunk wall %.3f s\n",
dummy_workers, omp_get_wtime() - dummy_start);
copy_psf_splat_stats(psf_stats, (CatalogSplatStats){
@@ -2349,14 +2349,14 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
}
omp_destroy_lock(&submit_lock);
if (psf_event_sink_destroy(&owner)) hip_failed = 1;
fprintf(stderr, "HIP producers: %d workers; summed generation %.3f s, submission/fallback %.3f s; wall %.3f s\n",
fprintf(stdout, "HIP producers: %d workers; summed generation %.3f s, submission/fallback %.3f s; wall %.3f s\n",
hip_workers, hip_generate_seconds, hip_submit_seconds, omp_get_wtime() - hip_start);
fprintf(stderr,
fprintf(stdout,
"HIP PSF accumulation: atomic %zu batches, tile16 %zu batches; selector %.3f s, binning %.3f s\n",
owner.hip_timing.atomic_batch_count, owner.hip_timing.tile_batch_count,
owner.hip_timing.selection_seconds, owner.hip_timing.bin_seconds);
if (owner.hip_timing.tile_batch_count)
fprintf(stderr,
fprintf(stdout,
"HIP PSF tile workload: references %zu, tasks %zu, merges %zu; peak references/chunk %zu\n",
owner.hip_timing.tile_reference_count, owner.hip_timing.tile_task_count,
owner.hip_timing.tile_merge_count,
+26
View File
@@ -9,6 +9,32 @@ typedef struct {
double x[3], Pi[3], log_alpha_p0;
} Derivative;
const char *ray_reason_name(RayReason reason) {
switch (reason) {
case RAY_REASON_NONE:
return "NONE";
case RAY_REASON_REDSHIFT_LIMIT:
return "REDSHIFT_LIMIT";
case RAY_REASON_BUDGET_EXHAUSTED:
return "BUDGET_EXHAUSTED";
case RAY_REASON_TIME_RANGE_EXHAUSTED:
return "TIME_RANGE_EXHAUSTED";
case RAY_REASON_OUT_OF_DOMAIN:
return "OUT_OF_DOMAIN";
case RAY_REASON_INVALID_METRIC:
return "INVALID_METRIC";
case RAY_REASON_INTEGRATION_ERROR:
return "INTEGRATION_ERROR";
case RAY_REASON_UNSUPPORTED:
return "UNSUPPORTED";
case RAY_REASON_PROTOCOL_ERROR:
return "PROTOCOL_ERROR";
case RAY_REASON_IO_ERROR:
return "IO_ERROR";
}
return "UNKNOWN";
}
static double dot(const double a[3], const double b[3]) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
+4
View File
@@ -28,6 +28,10 @@ typedef enum {
RAY_REASON_IO_ERROR
} RayReason;
/* Stable name for a RayReason (never NULL; out-of-range values return
* "UNKNOWN"). Pure and thread-safe, so it may be called from any reporter. */
const char *ray_reason_name(RayReason reason);
/* Monitored quantity used by the dark-redshift termination policy. `LOG_P0`
* is ln(p^0) = L - ln(alpha); it differs from `LOG_ALPHA_P0` by a local
* function of position and must not be confused with the true infinity
+2 -2
View File
@@ -605,7 +605,7 @@ static int submit_prepared(HipPsfSink *sink, HipPsfPreparedChunk *prepared,
sink->timing.event_count += event_count;
const auto now = std::chrono::steady_clock::now();
if (std::chrono::duration<double>(now - sink->last_report).count() >= 5.0) {
std::fprintf(stderr,
std::fprintf(stdout,
"HIP PSF progress: %zu submitted / %zu completed events; %zu / %zu batches timed; kernel %.3f s\n",
sink->timing.event_count, sink->completed_events,
sink->timing.timed_batch_count, sink->timing.batch_count,
@@ -641,7 +641,7 @@ static int submit_prepared(HipPsfSink *sink, HipPsfPreparedChunk *prepared,
sink->timing.event_count += event_count;
const auto now = std::chrono::steady_clock::now();
if (std::chrono::duration<double>(now - sink->last_report).count() >= 5.0) {
std::fprintf(stderr, "HIP PSF progress: %zu submitted / %zu completed events; %zu / %zu batches timed; kernel %.3f s\n",
std::fprintf(stdout, "HIP PSF progress: %zu submitted / %zu completed events; %zu / %zu batches timed; kernel %.3f s\n",
sink->timing.event_count, sink->completed_events, sink->timing.timed_batch_count,
sink->timing.batch_count, sink->timing.kernel_seconds);
sink->last_report = now;
+715 -99
View File
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -16,23 +16,23 @@ static int movie_output_default_write(void *context, const MovieOutputJob *job,
if (write_rgb8_image(job->output_path, job->clean_rgb8, job->width,
job->height, settings))
return -1;
fprintf(stderr, "Rendered %zu images from %zu catalog stars to %s (ok%s)\n",
fprintf(stdout, "Rendered %zu images from %zu catalog stars to %s (ok%s)\n",
job->images, job->catalog_stars, job->output_path, job->note);
if (job->draw_mesh && job->mesh_rgb8 != NULL) {
if (write_rgb8_image(job->mesh_path, job->mesh_rgb8, job->width,
job->height, settings))
return -1;
fprintf(stderr, "Wrote mesh overlay image: %s\n", job->mesh_path);
fprintf(stdout, "Wrote mesh overlay image: %s\n", job->mesh_path);
}
if (job->has_psf_stats) {
if (job->fast_mode)
fprintf(stderr,
fprintf(stdout,
"Fast PSF splats: deposited %zu, wing-clipped %zu, discarded "
"below min-Y %zu\n",
job->psf_stats.cached_splats, job->psf_stats.cached_wing_clipped,
job->psf_stats.discarded_below_min_y);
else
psf_kernel_cache_report(NULL, &job->psf_stats, stderr);
psf_kernel_cache_report(NULL, &job->psf_stats, stdout);
}
return 0;
}
+2 -2
View File
@@ -251,7 +251,7 @@ void psf_kernel_cache_report(const PsfKernelCache *cache,
stats->gpu_upload_seconds, stats->gpu_kernel_seconds,
stats->gpu_download_seconds);
if (stats != NULL && stats->discarded_below_min_y != 0)
fputs("Warning: --psf-min-y discarded one or more PSF events.\n", stream);
fputs("Warning: --psf-min-y discarded one or more PSF events.\n", stderr);
#ifdef GR_DEBUG
if (stats != NULL)
fprintf(stream, "Debug: max raw magnification %.6g; magnification-clamped "
@@ -705,7 +705,7 @@ int fast_psf_accumulator_resolve(FastPsfAccumulator *accumulator,
accumulator->fftw_last_timing = timing;
accumulator->fftw_frame_seconds = omp_get_wtime() - start;
if (accumulator->verbose)
fprintf(stderr,
fprintf(stdout,
"Fast FFTW frame: zero_pack=%.6f forward=%.6f "
"multiply=%.6f inverse=%.6f crop_downsample=%.6f "
"total=%.6f\n",
+4 -4
View File
@@ -130,9 +130,9 @@ def endpoint_deviation(a, b):
return mismatches, worst
def trace_cost(stderr, label):
def trace_cost(text, label):
match = re.search(label + r' trace cost: accepted=(\d+) rejected=(\d+) '
r'rhs=(\d+)', stderr)
r'rhs=(\d+)', text)
return None if match is None else match.groups()
@@ -263,8 +263,8 @@ with tempfile.TemporaryDirectory(prefix='gr-adaptive-cli-',
replay_hdr = replay_out.with_name(replay_out.stem + '_HDR.fits')
assert dflt_out.with_name(dflt_out.stem + '_HDR.fits').read_bytes() \
== replay_hdr.read_bytes()
live_cost = trace_cost(dflt_run.stderr, 'Frame 0')
replay_cost = trace_cost(replay_run.stderr, 'Imported map')
live_cost = trace_cost(dflt_run.stdout, 'Frame 0')
replay_cost = trace_cost(replay_run.stdout, 'Imported map')
assert live_cost is not None and replay_cost is not None
assert live_cost == replay_cost, (live_cost, replay_cost)
+57 -11
View File
@@ -115,14 +115,15 @@ def map_vertices(path):
return values, data[offset:offset + triangles * MAP_TRIANGLE_SIZE]
with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
with tempfile.TemporaryDirectory(prefix='gr-camera-cli-', dir='/tmp/opencode') as directory:
tmp = Path(directory)
for backend in ('minkowski', 'schwarzschild'):
binary = BUILD / f'{backend}_sky'
help_text = run(binary, '--help').stdout
ext = 'png' if '.png' in help_text else 'ppm'
hdr_available = '--hdr-output' in help_text
for option in ('--observer-position', '--observer-velocity', '--camera-roll-deg'):
hdr_available = any(line.startswith(' --hdr-output ')
for line in help_text.splitlines())
for option in ('--observer-time', '--observer-position', '--observer-velocity', '--camera-roll-deg'):
assert option in help_text
assert '--tone-map' in help_text and '--tone-map-p' in help_text
assert '--sensor-bloom-limit' in help_text
@@ -149,7 +150,7 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
fast_path = tmp / f'minkowski_fast.{ext}'
fast = run(binary, *common, '--fast-mode', '--fast-supersample', 2,
'--output', fast_path)
assert 'Fast FFTW:' in fast.stderr, fast.stderr
assert 'Fast FFTW:' in fast.stdout, fast.stdout
assert image_payload(fast_path)
# Equivalent independently specified and inferred camera geometry.
@@ -162,6 +163,15 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
assert pole == render('pole_explicit', '--observer-position', 0, 0, 30,
'--look-ra-deg', 0, '--look-dec-deg', -90)
default = render('default')
# Stationary backends are time-translation invariant; the event time
# must nevertheless survive in lens-map metadata, including negatives.
for time in (0, 12.5, -12.5):
timed_map = tmp / f'{backend}_time_{time}.grlens'
assert default == render('timed', '--observer-time', time,
'--lens-map-output', timed_map)
saved_time, proper_time = struct.unpack_from(
'<dd', timed_map.read_bytes(), MAP_FRAME_HEADER_START + 8)
assert saved_time == time and proper_time == 0
pos = (0, 0, 0) if backend == 'minkowski' else (0, 0, 30)
assert default == render('default_explicit', '--observer-position', *pos,
'--look-ra-deg', 90, '--look-dec-deg', -90)
@@ -217,10 +227,19 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
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.stderr, too_long.stderr
assert 'PSF cache ready' not in 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),
@@ -275,7 +294,7 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
if message:
assert message in result.stderr, result.stderr
assert not missing_catalog.exists(), result.stderr
assert 'PSF cache ready' not in result.stderr
assert 'PSF cache ready' not in (result.stdout + result.stderr)
track = tmp / f'{backend}.csv'
run(TESTDIR / f'test_observer_{backend}', track)
@@ -378,8 +397,8 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
'--sensor-bloom-transfer', 0.5,
'--output', bloom_output)
assert image_payload(bloom_output) != baseline
assert 'Sensor bloom:' in bloom_run.stderr, bloom_run.stderr
report = bloom_run.stderr.split('Sensor bloom:', 1)[1].splitlines()[0]
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
@@ -424,8 +443,8 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
'--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.stderr
assert 'Ray trace generation 0: frame 1 added' not in result.stderr
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)
@@ -443,3 +462,30 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-') as directory:
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 / 'alcubierre.png')
assert struct.unpack_from('<d', path.read_bytes(),
MAP_FRAME_HEADER_START + 8)[0] == time
vertices, triangles = map_vertices(path)
if reference is None:
reference = vertices, triangles
continue
expected, expected_triangles = reference
assert len(vertices) == len(expected) and triangles == expected_triangles
for actual, baseline in zip(vertices, expected):
assert actual[9:12] == baseline[9:12]
assert max(abs(a - b) for a, b in zip(actual[:9], baseline[:9])) < 1e-8
print('alcubierre: nonzero camera-time translation checks passed', flush=True)
+28 -1
View File
@@ -3,9 +3,35 @@
#include <math.h>
#include <stdio.h>
#include <string.h>
static int nearly_equal(double a, double b) { return fabs(a - b) < 1e-12; }
/* Every enumerator must have a stable label, and an out-of-range value must be
* reported as UNKNOWN rather than indexing past a table. */
static int check_reason_names(void) {
const RayReason reasons[] = {
RAY_REASON_NONE, RAY_REASON_REDSHIFT_LIMIT,
RAY_REASON_BUDGET_EXHAUSTED, RAY_REASON_TIME_RANGE_EXHAUSTED,
RAY_REASON_OUT_OF_DOMAIN, RAY_REASON_INVALID_METRIC,
RAY_REASON_INTEGRATION_ERROR, RAY_REASON_UNSUPPORTED,
RAY_REASON_PROTOCOL_ERROR, RAY_REASON_IO_ERROR};
int failed = 0;
for (size_t i = 0; i < sizeof reasons / sizeof reasons[0]; ++i) {
const char *name = ray_reason_name(reasons[i]);
if (name == NULL || name[0] == '\0' || strcmp(name, "UNKNOWN") == 0) {
fprintf(stderr, "reason %d has no stable label\n", (int)reasons[i]);
failed = 1;
}
}
const int unknown = (int)RAY_REASON_IO_ERROR + 7;
if (strcmp(ray_reason_name((RayReason)unknown), "UNKNOWN") != 0) {
fputs("out-of-range reason is not UNKNOWN\n", stderr);
failed = 1;
}
return failed;
}
static int check_ray(const SpacetimeSource *source,
const ObserverState *observer,
const double local_direction[3],
@@ -36,7 +62,8 @@ int main(void) {
metric.alpha != 1.0 ||
!spacetime_slab_eval(slab, 0.25, (double[]){0.0, 0.0, 0.0}, &metric))
return 1;
int result = check_ray(&source, &observer, (double[]){1.0, 0.0, 0.0},
int result = check_reason_names() ||
check_ray(&source, &observer, (double[]){1.0, 0.0, 0.0},
(double[]){0.0, 0.0, -1.0}) ||
check_ray(&source, &observer, (double[]){0.0, 0.0, 1.0},
(double[]){1.0, 0.0, 0.0});
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Regression test for the stdout/stderr split of the production CLI.
Normal progress, startup/backend configuration, successful file outputs,
timings and cache summaries are normal informational output and must go to
stdout. Genuine warnings, errors and the Debug per-event direct-fallback
diagnostic go to stderr. A mixed success/error line such as
``Rendered ... (ok|write failed)`` must follow its outcome.
The checks capture the two streams separately so that a future change which
silently moves a normal message to stderr (or an error to stdout) is caught.
Small deterministic Minkowski scenes keep the runtime short.
"""
import os
import subprocess
import sys
import tempfile
from pathlib import Path
# Keep scratch data inside the pre-approved OpenCode scratch directory.
TMP_ROOT = Path('/tmp/opencode')
TMP_ROOT.mkdir(parents=True, exist_ok=True)
BUILD = Path(sys.argv[1] if len(sys.argv) > 1 else 'build/Release').resolve()
ENV = dict(os.environ, OMP_NUM_THREADS='4')
COMMON = ['--catalog', 'assets/sky_grid_5deg.csv', '--width', 16, '--height', 8,
'--fov-deg', 80, '--exposure', 1e-3, '--coarse-cell-pixels', 8,
'--refine-max-level', 0, '--psf-relative-tail', 1e-4]
def run(binary, *args, env=ENV):
return subprocess.run([str(binary), *map(str, args)], env=env,
capture_output=True, text=True)
def stderr_errors(result):
"""Non-advisory stderr lines.
A Debug build deliberately logs each direct PSF fallback from the active
splat worker; those ``Debug:`` lines are real diagnostics, not a misplaced
normal message, so they are excluded when the Debug startup banner is
present on stdout.
"""
lines = [line for line in result.stderr.splitlines() if line.strip()]
if 'Debug build:' in result.stdout:
lines = [line for line in lines if not line.startswith('Debug:')]
return lines
binary = BUILD / 'minkowski_sky'
assert binary.exists(), f'missing {binary}'
# The image extension follows the build's compiled writer, exactly as
# tests/test_camera_cli.py detects it from --help (a libpng build advertises
# `.png`, a PNG-less build advertises `.ppm`).
help_text = run(binary, '--help').stdout
ext = 'png' if '.png' in help_text else 'ppm'
with tempfile.TemporaryDirectory(prefix='gr-output-streams-',
dir=str(TMP_ROOT)) as directory:
tmp = Path(directory)
# 1) --help is informational: complete usage on stdout, nothing on stderr.
help_result = run(binary, '--help')
assert help_result.returncode == 0, help_result.stderr
assert 'Usage:' in help_result.stdout, help_result.stdout
assert help_result.stderr == '', help_result.stderr
# 2) An unknown option is an error: usage diagnostic on stderr only.
bad_result = run(binary, '--not-an-option')
assert bad_result.returncode != 0, bad_result.stdout
assert bad_result.stdout == '', bad_result.stdout
assert bad_result.stderr != '', 'missing error diagnostic on stderr'
# 3) A successful non-verbose single-frame render puts every startup,
# statistics and success line on stdout with an empty stderr.
single_out = tmp / f'single.{ext}'
single = run(binary, *COMMON, '--output', single_out)
assert single.returncode == 0, single.stderr
assert single_out.exists(), single.stderr
assert 'Blackbody backend:' in single.stdout, single.stdout
assert 'PSF cache ready:' in single.stdout, single.stdout
assert 'Rendered' in single.stdout and '(ok)' in single.stdout, single.stdout
assert '(write failed)' not in single.stdout, single.stdout
assert not stderr_errors(single), single.stderr
# 4) Verbose progress (including the trace-cost line) is still stdout.
verbose_out = tmp / f'verbose.{ext}'
verbose = run(binary, *COMMON, '--verbose', '--output', verbose_out)
assert verbose.returncode == 0, verbose.stderr
assert 'Frame 0: tracing' in verbose.stdout, verbose.stdout
assert 'Frame 0 trace cost:' in verbose.stdout, verbose.stdout
assert not stderr_errors(verbose), verbose.stderr
# 5) A short movie exercises the asynchronous writer; its per-frame logs,
# timing summary and writer summary are stdout, stderr stays empty. The
# 2 s fixture track plus --duration 1 --fps 1 yields two frames, so the
# async producer/writer overlap is actually exercised.
track = tmp / 'track.csv'
track_run = run(binary, '--write-minkowski-accel-track', track,
'--duration', 2, '--fps', 30, '--proper-acceleration', 1.52)
assert track_run.returncode == 0, track_run.stderr
assert track.exists(), track_run.stderr
frames_dir = tmp / 'frames'
frames_dir.mkdir()
movie = run(binary, *COMMON, '--observer-track', track, '--frames-dir',
frames_dir, '--frames-prefix', 'frame', '--duration', 1,
'--fps', 1, '--verbose', '--output', tmp / f'movie.{ext}')
assert movie.returncode == 0, movie.stderr
assert (frames_dir / f'frame_000000.{ext}').exists(), movie.stderr
assert (frames_dir / f'frame_000001.{ext}').exists(), movie.stderr
assert 'Ray trace generation' in movie.stdout, movie.stdout
assert movie.stdout.count('Rendered') == 2, movie.stdout
assert movie.stdout.count('(ok)') == 2, movie.stdout
assert 'Movie timing total' in movie.stdout, movie.stdout
assert 'Movie writer summary:' in movie.stdout, movie.stdout
assert 'Movie end-to-end wall:' in movie.stdout, movie.stdout
assert not stderr_errors(movie), movie.stderr
# 6) A genuine warning goes to stderr and does not disturb the success line
# on stdout. The fast-mode preview advisory is deterministic in the CPU
# build.
fast_out = tmp / f'fast.{ext}'
fast = run(binary, *COMMON, '--fast-mode', '--output', fast_out)
assert fast.returncode == 0, fast.stderr
assert 'Fast mode is a preview approximation' in fast.stderr, fast.stderr
assert fast.stdout.count('Rendered') == 1, fast.stdout
assert '(ok)' in fast.stdout, fast.stdout
# The only stderr content is the advisory: no normal line leaked across.
assert len(stderr_errors(fast)) == 1, fast.stderr
# 7) A failed write must route the mixed success/error line to stderr and
# leave stdout free of the success wording.
missing_dir = tmp / 'missing_subdir' / f'out.{ext}'
failed = run(binary, *COMMON, '--output', missing_dir)
assert failed.returncode != 0, failed.stdout
assert '(write failed)' in failed.stderr, failed.stderr
assert '(write failed)' not in failed.stdout, failed.stdout
# 8) A rejected camera velocity is an error on stderr, not stdout.
velocity = run(binary, *COMMON, '--observer-velocity', 10, 0, 0,
'--output', tmp / f'velocity.{ext}')
assert velocity.returncode != 0, velocity.stdout
assert 'not timelike' in velocity.stderr, velocity.stderr
# stdout may hold only the Debug startup banner; no render ran.
assert 'Rendered' not in velocity.stdout, velocity.stdout
print('output-stream checks passed: normal success stdout / diagnostics '
'stderr', flush=True)
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""Regression test for always-on ray failure diagnostics.
Every INCOMPLETE ray endpoint must be reported on stderr with its reason and
the affected frame id / camera coordinate time even without ``--verbose``, so a
long movie never needs a rerun to be diagnosed. ``--verbose`` (or any Debug
build) adds a bounded set of representative samples with film/cost
localization. Budget-incomplete ``UNRESOLVED/BUDGET_EXHAUSTED`` frames are
reported separately from numerical ``INCOMPLETE`` failures, and the publication
refusal must not recommend a larger retry budget for a pure integration error.
A deterministic DP54 integration error is produced without a large workload:
strict tolerances, ``min == initial == max`` step, and one allowed rejection
make the first trial step fail as ``INTEGRATION_ERROR``. Small 16x8 scenes
keep every case short. Two-frame movie coverage uses the existing
``test_observer_<backend>`` fixture track duplicated to two rows (the
Schwarzschild metric is stationary, so the same tetrad is valid at both
times); the test then checks that each failing sample is counted once across
all time slabs.
"""
import os
import re
import struct
import subprocess
import sys
import tempfile
from pathlib import Path
# Keep scratch data inside the pre-approved OpenCode scratch directory.
TMP_ROOT = Path('/tmp/opencode')
TMP_ROOT.mkdir(parents=True, exist_ok=True)
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')
# v3 lens-map layout (see src/lens_map.c); only enough to count stored
# INCOMPLETE vertices. The first frame header follows the 40..176 v3
# provenance block.
VERSION_OFFSET = 8
FRAME_COUNT_OFFSET = 32
FRAME_HEADER_START = 176
VERTEX_SIZE = 108
OUTCOME_INDEX = 10
INCOMPLETE_OUTCOME = 3
COMMON = ['--catalog', 'assets/sky_grid_5deg.csv', '--width', 16, '--height', 8,
'--fov-deg', 80, '--exposure', 1e-3, '--coarse-cell-pixels', 8,
'--refine-max-level', 0, '--psf-relative-tail', 1e-4]
# Deterministic DP54 integration failure: tight tolerances, a single fixed step
# bound, and one allowed rejection. Every ray rejects its first trial step and
# reports INTEGRATION_ERROR instead of a fabricated terminal category.
INJECT = ['--integrator', 'dp54',
'--ode-rtol', '1e-15', '--ode-atol-x', '1e-15',
'--ode-atol-pi', '1e-15', '--ode-atol-l', '1e-15',
'--ode-initial-step', '0.5', '--ode-min-step', '0.5',
'--ode-max-step', '0.5', '--ode-max-rejections', '1']
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}: rc={result.returncode}\n'
f'stdout:\n{result.stdout}\nstderr:\n{result.stderr}')
return result
def is_debug(result):
return 'Debug build:' in result.stdout
def incomplete_total(stderr):
return sum(int(m) for m in re.findall(r'INCOMPLETE=(\d+)', stderr))
def frame_reported(stderr, frame_id):
return re.search(rf'Ray failures: frame {frame_id} camera_t=', stderr) is not None
def map_incomplete_vertices(path):
data = path.read_bytes()
assert data[:8] == b'GRLENS\x01\x00', f'not a lens map: {path}'
version = struct.unpack_from('<I', data, VERSION_OFFSET)[0]
assert version == 3, f'expected v3 map, got v{version}'
frame_count = struct.unpack_from('<Q', data, FRAME_COUNT_OFFSET)[0]
offset = FRAME_HEADER_START
incomplete = 0
for _ in range(frame_count):
vertices, triangles = struct.unpack_from('<QQ', data, offset + 24)
offset += 48
for _ in range(vertices):
fields = struct.unpack_from('<9dIIIQQQ', data, offset)
offset += VERTEX_SIZE
if fields[OUTCOME_INDEX] == INCOMPLETE_OUTCOME:
incomplete += 1
offset += triangles * 32 + 4 # triangles plus the frame payload CRC
return incomplete
# A required renderer or fixture must not be skipped silently under make test.
binary = BUILD / 'schwarzschild_sky'
assert binary.exists(), f'missing {binary}'
observer_test = TESTDIR / 'test_observer_schwarzschild'
assert observer_test.exists(), f'missing {observer_test}'
help_text = run(binary, '--help').stdout
ext = 'png' if '.png' in help_text else 'ppm'
for option in ('--verbose', '--allow-incomplete', '--observer-track',
'--movie-track-samples', '--lens-map-output', '--lens-map-input',
'--refine-max-level'):
assert option in help_text, (option, help_text)
with tempfile.TemporaryDirectory(prefix='gr-ray-diagnostics-',
dir=str(TMP_ROOT)) as directory:
tmp = Path(directory)
# 1) A normal scene emits no ray-failure or budget diagnostics at all, so a
# clean run is not polluted by the always-on summary. The refinement
# variant exercises the always-installed diagnostics callback across
# finished generations without producing any failure.
normal = tmp / f'normal.{ext}'
normal_run = run(binary, *COMMON, '--output', normal)
assert normal_run.returncode == 0
assert 'Ray failures:' not in normal_run.stderr, normal_run.stderr
assert 'UNRESOLVED/BUDGET_EXHAUSTED' not in normal_run.stderr, normal_run.stderr
refined = tmp / f'refined.{ext}'
refined_run = run(binary, *COMMON, '--refine-max-level', '2',
'--refine-angle-abs-deg', '0.1', '--output', refined)
assert refined_run.returncode == 0
assert 'Ray failures:' not in refined_run.stderr, refined_run.stderr
# 2) Non-verbose INCOMPLETE: the reason histogram, frame id, and camera time
# are on stderr even though --verbose was not passed.
single = tmp / f'single.{ext}'
single_run = run(binary, *COMMON, *INJECT, '--allow-incomplete',
'--output', single)
err = single_run.stderr
assert 'Ray failures:' in err, err
assert 'INCOMPLETE=' in err and 'INTEGRATION_ERROR' in err, err
assert frame_reported(err, 0), err
if not is_debug(single_run):
assert 'ray failure:' not in err, err
assert 'film=' not in err, err
else:
# A Debug build prints representative detail without --verbose.
assert 'ray failure:' in err and 'film=' in err, err
# 3) Verbose adds bounded representative samples with localization.
verbose = tmp / f'verbose.{ext}'
verbose_run = run(binary, *COMMON, *INJECT, '--allow-incomplete', '--verbose',
'--output', verbose)
err = verbose_run.stderr
assert 'ray failure:' in err, err
assert 'reason=INTEGRATION_ERROR' in err, err
assert 'vertex=' in err and 'film=(' in err, err
assert 'accepted=' in err and 'rejected=' in err and 'rhs=' in err, err
# Bounded detail: at most RAY_DIAG_REPS_PER_REASON per reason, remainder
# reported as suppressed.
reps = sum(1 for line in err.splitlines()
if 'ray failure: frame=0 reason=' in line
and 'suppressed=' not in line)
assert reps <= 3, err
assert 'suppressed=' in err, err
# 4) Refinement enabled with the failing trace: the always-installed
# diagnostics callback is part of the build, but the uniform strict
# injection fails every initial vertex, so no probe generation runs and
# only the initial scan reports (documented limit: probe/refinement
# witness failures are not reachable from this deterministic scene).
refine_map = tmp / 'refine_inc.grlens'
refine_run = run(binary, *COMMON, *INJECT, '--refine-max-level', '1',
'--refine-angle-abs-deg', '0.1', '--allow-incomplete',
'--lens-map-output', refine_map,
'--output', tmp / f'refine_inc.{ext}')
err = refine_run.stderr
assert frame_reported(err, 0), err
stored = map_incomplete_vertices(refine_map)
assert stored > 0
assert incomplete_total(err) == stored, \
f'refine histogram {incomplete_total(err)} != stored {stored}'
# 5) Two-frame movie. (a) A default, non-verbose run must identify both
# frames and count each failing sample exactly once across the time
# slabs, matching the saved map.
track_single = tmp / 'track_single.csv'
run(observer_test, track_single)
rows = [line for line in track_single.read_text().splitlines()
if line.strip() and not line.startswith('#')
and not line[0].isalpha()]
assert len(rows) == 1, rows
fields = rows[0].split(',')
fields[0], fields[1] = '1', '1'
header = next(line for line in track_single.read_text().splitlines()
if line.startswith('t,'))
track = tmp / 'track2.csv'
track.write_text(header + '\n' + rows[0] + '\n' + ','.join(fields) + '\n')
frames_dir = tmp / 'frames'
frames_dir.mkdir()
movie_map = tmp / 'movie.grlens'
movie_run = run(binary, *COMMON, *INJECT, '--allow-incomplete',
'--slab-duration', '0.4', '--observer-track', track,
'--movie-track-samples', '--frames-dir', frames_dir,
'--lens-map-output', movie_map)
err = movie_run.stderr
assert frame_reported(err, 0) and frame_reported(err, 1), err
stored = map_incomplete_vertices(movie_map)
assert stored > 0, 'movie map has no INCOMPLETE vertices'
assert incomplete_total(err) == stored, \
f'histogram {incomplete_total(err)} != stored {stored}; ' \
'a sample was re-counted across slabs'
if not is_debug(movie_run):
assert 'ray failure:' not in err, err
# (b) Verbose movie adds request kind, persistent vertex id, film position
# and cost, bounded per reason.
movie_verbose_dir = tmp / 'frames_verbose'
movie_verbose_dir.mkdir()
movie_verbose = run(binary, *COMMON, *INJECT, '--allow-incomplete',
'--verbose', '--slab-duration', '0.4',
'--observer-track', track, '--movie-track-samples',
'--frames-dir', movie_verbose_dir)
err = movie_verbose.stderr
assert 'sample=' in err and 'kind=vertex' in err and 'vertex=' in err, err
assert 'film=(' in err, err
assert 'accepted=' in err and 'rejected=' in err and 'rhs=' in err, err
reps = sum(1 for line in err.splitlines()
if 'ray failure:' in line and 'suppressed=' not in line)
assert reps <= 6, err # two frames, one reason each, <=3 reps per reason
# (c) Render-only replay of the two-frame map without --allow-incomplete
# reports both frames' reasons before the publication gate refuses, and
# never invents an unpersisted trusted stop state.
map_frames_dir = tmp / 'map_frames'
map_frames_dir.mkdir()
refused = run(binary, '--catalog', 'assets/sky_grid_5deg.csv',
'--lens-map-input', movie_map, '--frames-dir', map_frames_dir,
'--output', tmp / f'map_refused.{ext}', ok=False)
err = refused.stderr
assert frame_reported(err, 0) and frame_reported(err, 1), err
assert 'INTEGRATION_ERROR' in err, err
assert 'phase=import' in err, err
assert 'Incomplete render refused' in err, err
assert err.index('frame 0') < err.index('Incomplete render refused'), err
single_map = tmp / 'single.grlens'
run(binary, *COMMON, *INJECT, '--allow-incomplete', '--lens-map-output',
single_map, '--output', tmp / f'maplive.{ext}')
replay = tmp / f'replay.{ext}'
replay_run = run(binary, '--catalog', 'assets/sky_grid_5deg.csv',
'--lens-map-input', single_map, '--allow-incomplete',
'--verbose', '--output', replay)
err = replay_run.stderr
assert 'Ray failures:' in err and frame_reported(err, 0), err
assert 'INTEGRATION_ERROR' in err and 'phase=import' in err, err
assert 'stop_t=' not in err and 'trusted=1' not in err, err
# 6) Budget exhaustion is a distinct, always-on message, and its refusal
# still points at the retry budget. A replay of an allowed budget map
# must not present the unpersisted continuation time as observed.
budget_map = tmp / 'budget.grlens'
run(binary, *COMMON, '--integrator', 'dp54',
'--trace-lookback-time', '1e-6', '--retry-lookback-increment', '0',
'--max-total-lookback-time', '1e-6', '--allow-incomplete',
'--lens-map-output', budget_map, '--output', tmp / f'budget_allow.{ext}')
replay_budget = run(binary, '--catalog', 'assets/sky_grid_5deg.csv',
'--lens-map-input', budget_map, '--allow-incomplete',
'--verbose', '--output', tmp / f'budget_replay.{ext}')
err = replay_budget.stderr
assert 'UNRESOLVED/BUDGET_EXHAUSTED' in err, err
assert 'continuation_t=-' in err, err
assert 'continuation_t=-1' not in err, \
'replay invented an unpersisted continuation time'
budget = tmp / f'budget.{ext}'
budget_run = run(binary, *COMMON, '--integrator', 'dp54',
'--trace-lookback-time', '1e-6',
'--retry-lookback-increment', '0',
'--max-total-lookback-time', '1e-6',
'--output', budget, ok=False)
err = budget_run.stderr
assert 'UNRESOLVED/BUDGET_EXHAUSTED' in err, err
assert re.search(r'UNRESOLVED/BUDGET_EXHAUSTED: frame 0 camera_t=', err), err
assert 'blocking_triangles=' in err and 'unresolved_samples=' in err, err
assert 'Incomplete render refused' in err, err
assert 'budget' in err.lower(), err
assert not budget.exists()
print('ray diagnostics checks passed: always-on reasons + frame/time, '
'bounded verbose samples, movie slab de-duplication and replay',
flush=True)
+12 -1
View File
@@ -18,10 +18,14 @@ for the field and camera.
Both backends accept the same instantaneous camera parameters. Position and
velocity use the backend's coordinates; velocity means `dx/dt, dy/dt, dz/dt`,
not a local physical speed. The analytic single-frame event is at `t=0`.
not a local physical speed. The single-frame event defaults to `t=0`; use
`--observer-time T` to select another coordinate time (any finite value,
including negative times). Metric evaluation and past-directed ray tracing
start at that event, and saved lens maps retain its coordinate time.
| Option | Meaning / default |
| --- | --- |
| `--observer-time T` | Single-frame camera coordinate time, default `0`; cannot be combined with movie/track or lens-map input |
| `--observer-position X Y Z` | Coordinate position; if look is omitted, point toward the origin |
| `--look-ra-deg RA`, `--look-dec-deg DEC` | Coordinate look direction; missing angle defaults to RA=90°, Dec=-90° |
| `--observer-radius R` | Positive radius used only to infer position, default 30; conflicts with explicit position |
@@ -618,6 +622,13 @@ overlay alpha-composites image-plane triangle edges as one-pixel-wide 0.5
linear-gray diagnostic lines at 0.5 opacity. The line rasterizer uses
coverage-based antialiasing.
Normal progress and summaries go to stdout; warnings, errors, and Debug
diagnostics go to stderr. Successful runs exit `0` even if warnings are emitted.
Incomplete ray failures and budget-exhausted frames report their reasons and
affected frames on stderr even without `--verbose`; `--verbose` and Debug builds
add bounded per-sample localization (film position and integration cost).
## HDR output
To preserve a single-frame render for later exposure and tone-mapping work,