Files
GR-raytracing/tests/test_observer.c
T
wyj f7380cbf75 Feat: Rework ray termination into escaped/dark/unresolved/incomplete
Replace the position capture cutoff with a camera-relative dark threshold
shared by every backend, and carry explicit outcome/reason provenance
through the ray, RayPool, adaptive mesh, lens-map and replay paths.

- eval/eval_slab return SpacetimePointStatus; remove SPACETIME_RAY_CAPTURED
  and the Schwarzschild capture radius; decouple observer construction from
  ray position.
- RayEndpoint stores RayOutcome/RayReason plus the last trusted state;
  budget exhaustion is retryable UNRESOLVED, data/integration failures are
  INCOMPLETE.
- Normal dark terminal is L - L0 >= --dark-threshold (default 8), with L0
  taken at the camera event and kept distinct from the worldtube entry
  energy; photon energy and frequency ratio are never reset.
- Implement E/D/U triangle decisions with merged budget retries, persistent
  probe witnesses promoted in place by vertex identity, conformity settling,
  and approximate-black boundary provenance with achieved-scale statistics.
- Add RayPool continuation state and per-ray step budgets.
- Bump lens-map to v2 with explicit end/outcome/reason, approx_black,
  threshold/retry/geometry provenance and per-frame retry counts; reject v1.
- Gate production output on incomplete/error results, overridable with
  --allow-incomplete.
- Update AGENTS.md, the design document and usage docs; add the termination
  oracle and regression coverage.

make -B -j4 BUILD_TYPE=Debug test passes with bit-identical reference HDRs.
2026-10-05 06:22:47 -04:00

159 lines
8.0 KiB
C

#include "geodesic.h"
#include "observer_track.h"
#include <math.h>
#include <stdio.h>
#include <string.h>
#define CHECK(condition) do { if (!(condition)) { \
fprintf(stderr, "observer regression failed at line %d: %s\n", __LINE__, #condition); \
return 1; } } while (0)
/* Independent covariant four-metric contraction in extended precision. */
static long double dot(const MetricData *m, const double a[4], const double b[4]) {
long double g[4][4] = {{0}};
g[0][0] = -(long double)m->alpha * m->alpha;
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j) {
g[i + 1][j + 1] = m->gamma[i][j];
g[0][0] += (long double)m->gamma[i][j] * m->beta[i] * m->beta[j];
g[0][i + 1] += (long double)m->gamma[i][j] * m->beta[j];
g[i + 1][0] = g[0][i + 1];
}
long double result = 0;
for (int i = 0; i < 4; ++i)
for (int j = 0; j < 4; ++j) result += g[i][j] * a[i] * b[j];
return result;
}
static int check_state(const MetricData *m, const ObserverCamera *c,
const ObserverState *o) {
CHECK(o->coordinate_time == c->coordinate_time);
CHECK(o->tetrad[0][0] > 0);
for (int i = 0; i < 3; ++i) {
CHECK(o->coordinate_position[i] == c->position[i]);
CHECK(fabs(o->tetrad[0][i + 1] / o->tetrad[0][0] - c->velocity[i]) < 1e-12);
}
for (int a = 0; a < 4; ++a)
for (int b = 0; b < 4; ++b)
CHECK(fabsl(dot(m, o->tetrad[a], o->tetrad[b]) -
(a == b ? (a == 0 ? -1 : 1) : 0)) < 1e-11L);
const double n[3] = {0.36, 0.48, 0.8};
double k[4];
for (int mu = 0; mu < 4; ++mu) {
k[mu] = o->tetrad[0][mu];
for (int a = 0; a < 3; ++a) k[mu] -= n[a] * o->tetrad[a + 1][mu];
}
CHECK(k[0] > 0 && fabsl(dot(m, k, k)) < 1e-11L);
CHECK(fabsl(dot(m, k, o->tetrad[0]) + 1) < 1e-11L);
return 0;
}
int main(int argc, char **argv) {
SpacetimeSource source = {0};
CHECK(spacetime_create_default(&source) == 0);
ObserverCamera camera = {.position = {3, -4, 5}, .velocity = {0.2, -0.1, 0.3},
.look_ra_deg = 37, .look_dec_deg = -23, .roll_deg = 19};
MetricData metric;
ObserverState state;
CHECK(spacetime_eval(&source, 0, camera.position, &metric) == 0);
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_OK);
CHECK(check_state(&metric, &camera, &state) == 0);
if (argc == 2) {
ObserverSample sample = {.coordinate_time = 0, .proper_time = 0};
memcpy(sample.coordinate_position, state.coordinate_position, sizeof sample.coordinate_position);
memcpy(sample.tetrad, state.tetrad, sizeof sample.tetrad);
ObserverTrack track = {.samples = &sample, .count = 1};
CHECK(observer_track_write_csv(&track, argv[1]) == 0);
}
/* A rescaled/shifted coordinate system can have timelike |dx/dt| > 1.
* The builder must use the metric, not impose a Euclidean speed limit. */
const MetricData shifted_metric = {.alpha = 2, .beta = {0.1, -0.2, 0.3},
.gamma = {{1, 0.1, 0}, {0.1, 1.2, 0.1}, {0, 0.1, 0.9}}};
const ObserverCamera fast_coordinate = {.velocity = {1.2, 0, 0},
.look_ra_deg = 123, .look_dec_deg = 45, .roll_deg = -31};
CHECK(observer_from_coordinate_camera(&shifted_metric, &fast_coordinate,
&state, NULL) == OBSERVER_BUILD_OK);
CHECK(check_state(&shifted_metric, &fast_coordinate, &state) == 0);
MetricData rounded_metric = shifted_metric;
rounded_metric.gamma[1][0] = nextafter(rounded_metric.gamma[1][0], INFINITY);
CHECK(observer_from_coordinate_camera(&rounded_metric, &fast_coordinate,
&state, NULL) == OBSERVER_BUILD_OK);
CHECK(check_state(&rounded_metric, &fast_coordinate, &state) == 0);
MetricData invalid_metric = shifted_metric;
invalid_metric.gamma[2][2] = -1;
CHECK(observer_from_coordinate_camera(&invalid_metric, &fast_coordinate,
&state, NULL) == OBSERVER_BUILD_INVALID_INPUT);
const double ras[] = {0, 37, 90, 180, 359.9};
const double decs[] = {-90, -23, 0, 45, 90};
for (int a = 0; a < 5; ++a)
for (int b = 0; b < 5; ++b) {
camera.look_ra_deg = ras[a]; camera.look_dec_deg = decs[b];
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_OK);
CHECK(check_state(&metric, &camera, &state) == 0);
}
camera.look_ra_deg = 0; camera.look_dec_deg = 0; camera.roll_deg = 0;
ObserverState unrolled;
CHECK(observer_from_coordinate_camera(&metric, &camera, &unrolled, NULL) == OBSERVER_BUILD_OK);
camera.roll_deg = 90;
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_OK);
for (int mu = 0; mu < 4; ++mu) {
CHECK(fabs(state.tetrad[2][mu] - unrolled.tetrad[3][mu]) < 1e-12);
CHECK(fabs(state.tetrad[3][mu] + unrolled.tetrad[2][mu]) < 1e-12);
}
camera.velocity[0] = NAN;
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_INVALID_INPUT);
camera.velocity[0] = 10;
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_NON_TIMELIKE);
camera.velocity[0] = 0;
camera.roll_deg = INFINITY;
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_INVALID_INPUT);
#ifdef SPACETIME_SCHWARZSCHILD
/* Ingoing radial light seen from the horizon and its interior must still
* trace backwards to the external sky, rather than be classified captured. */
const GeodesicTraceConfig trace = {.coordinate_time_step = 0.05,
.max_steps = 8192, .threshold = {.kind = THRESHOLD_LOG_ENERGY_GROWTH, .value = 8.0, .policy_version = 3}};
for (int i = 0; i < 3; ++i) {
camera = (ObserverCamera){.position = {2.25 - 0.25 * i, 0, 0},
.velocity = {-0.5, 0, 0}};
CHECK(spacetime_eval(&source, 0, camera.position, &metric) == 0);
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_OK);
CHECK(check_state(&metric, &camera, &state) == 0);
const RayEndpoint ray = geodesic_trace_past(&source, &state, (double[]){1, 0, 0}, &trace);
CHECK(ray.outcome == RAY_OUTCOME_ESCAPED);
CHECK(fabs(ray.n_infinity[0] - 1) < 1e-12);
/* Radial ingoing KS photon has k^r=-k^t and conserved E=k^t. The
* asymptotic exterior transfers the photon to infinity, where
* g = E_camera / E_infinity = 1 / k^t. */
const double energy = state.tetrad[0][0] - state.tetrad[1][0];
CHECK(fabs(ray.frequency_ratio - 1.0 / energy) < 1e-10 * (1.0 / energy));
memset(camera.velocity, 0, sizeof camera.velocity);
if (i > 0)
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_NON_TIMELIKE);
}
#else
/* For transverse velocity along Y, projected +X remains F=(0,1,0,0).
* Independently, k=(gamma,-1,gamma*v,0) gives aberration and Doppler. */
camera = (ObserverCamera){.velocity = {0, 0.6, 0}};
CHECK(spacetime_eval(&source, 0, camera.position, &metric) == 0);
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_OK);
const GeodesicTraceConfig trace = {.coordinate_time_step = 1, .max_steps = 2048};
const RayEndpoint ray = geodesic_trace_past(&source, &state, (double[]){1, 0, 0}, &trace);
CHECK(ray.outcome == RAY_OUTCOME_ESCAPED);
CHECK(fabs(ray.n_infinity[0] - 0.8) < 1e-12);
CHECK(fabs(ray.n_infinity[1] + 0.6) < 1e-12);
CHECK(fabs(ray.frequency_ratio - 0.8) < 1e-12);
camera.position[0] = 25; camera.position[1] = -30; camera.position[2] = 10;
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_OK);
const RayEndpoint shifted = geodesic_trace_past(&source, &state, (double[]){1, 0, 0}, &trace);
CHECK(shifted.outcome == ray.outcome && fabs(shifted.frequency_ratio - ray.frequency_ratio) < 1e-12);
for (int i = 0; i < 3; ++i) CHECK(fabs(shifted.n_infinity[i] - ray.n_infinity[i]) < 1e-12);
camera.velocity[1] = 1;
CHECK(observer_from_coordinate_camera(&metric, &camera, &state, NULL) == OBSERVER_BUILD_NON_TIMELIKE);
#endif
spacetime_destroy(&source);
puts("coordinate-camera regression passed");
return 0;
}