Feat: Add directed asymptotic escape and analytic Schwarzschild exterior

Replace radius-only escape termination with a common asymptotic exterior protocol: declared ends, moving escape worldtubes, directed inside->outside crossings, and a PENDING_ENTRY lifecycle shared by single-frame and movie tracing.

Add an analytic Carlson-integral Schwarzschild monopole exterior (angle primitive, bracketed turning radius, ingoing Kerr-Schild coordinate-time transfer, conserved-energy frequency) so a camera outside the escape sphere is traced through an entry event.

Make the lifecycle tri-state (no ends / ready / protocol error), carry end_id through the endpoint and lens mesh, validate sources in constructors via spacetime_source_finalize(), and refresh the Schwarzschild reference images for the corrected finish.
This commit is contained in:
wyj committed 2026-10-04 03:32:28 -04:00
1 parent 04611e3e5a
commit 09a7417961
24 files changed
+3566 -58

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 188 KiB

+747
View File
@@ -0,0 +1,747 @@
#include "asymptotic.h"
#include "observer.h"
#include "ray.h"
#include "spacetime.h"
#include <math.h>
#include <stdio.h>
static int failures = 0;
#define CHECK(condition, message) \
do { \
if (!(condition)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, message); \
++failures; \
} \
} while (0)
static ObserverState flat_observer(double x, double y, double z) {
ObserverState o = {0};
o.coordinate_position[0] = x;
o.coordinate_position[1] = y;
o.coordinate_position[2] = z;
o.tetrad[0][0] = 1.0;
o.tetrad[1][1] = 1.0;
o.tetrad[2][2] = 1.0;
o.tetrad[3][3] = 1.0;
return o;
}
/* Synthetic flat exterior with a Minkowski end whose worldtube center follows
* x_c(t) = vx t + accel t^2 / 2. `constant` selects the closed quadratic path;
* otherwise the generic bracketed driver runs. */
typedef struct {
double vx;
double accel;
double radius;
double radius_rate;
double valid_t_min;
int constant;
double segment_t; /* Motion-segment boundary for the cross-segment test. */
int has_segment;
int end_descriptor_fails; /* Protocol-error injection. */
int unsupported_kind;
double invalid_center, invalid_halfwidth; /* Isolated invalid time window. */
int schwarzschild_kind; /* Declare a Schwarzschild monopole end. */
int sample_callback_fails; /* make escape_worldtube_sample return -1 */
int sample_invalid; /* valid = 0 */
int sample_nan_radius;
int sample_nonpositive_radius;
int fail_on_sample_call; /* 1-based callback invocation to fail. */
int sample_call_count;
} SyntheticContext;
static int synthetic_eval(const SpacetimeSource *source, double t,
const double x[3], MetricData *metric) {
(void)source;
(void)t;
(void)x;
*metric = (MetricData){.alpha = 1.0,
.gamma = {{1.0, 0.0, 0.0},
{0.0, 1.0, 0.0},
{0.0, 0.0, 1.0}}};
return 0;
}
static SpacetimeRayStatus synthetic_classify(const SpacetimeSource *source,
double t, const double x[3]) {
(void)source;
(void)t;
(void)x;
return SPACETIME_RAY_ACTIVE;
}
static size_t synthetic_end_count(const SpacetimeSource *source) {
(void)source;
return 1;
}
static int synthetic_end(const SpacetimeSource *source, size_t index,
SpacetimeAsymptoticEnd *out) {
const SyntheticContext *context = source->context;
if (index != 0 || context->end_descriptor_fails)
return -1;
AsymptoticExteriorKind kind = ASYMPTOTIC_EXTERIOR_MINKOWSKI;
double mass = 0.0;
if (context->unsupported_kind) {
kind = (AsymptoticExteriorKind)999;
} else if (context->schwarzschild_kind) {
kind = ASYMPTOTIC_EXTERIOR_SCHWARZSCHILD_MONOPOLE;
mass = 1.0;
}
*out = (SpacetimeAsymptoticEnd){
.end_id = 0,
.exterior_kind = kind,
.mass = mass,
.frame_origin = {0.0, 0.0, 0.0},
.frame_axes = {{1.0, 0.0, 0.0}, {0.0, 1.0, 0.0}, {0.0, 0.0, 1.0}}};
return 0;
}
static int synthetic_worldtube(const SpacetimeSource *source,
SpacetimeEndId end_id, double t,
SpacetimeEscapeWorldtubeSample *out) {
SyntheticContext *mutable_context = source->context;
const SyntheticContext *context = mutable_context;
if (end_id != 0)
return -1;
++mutable_context->sample_call_count;
if (context->fail_on_sample_call > 0 &&
mutable_context->sample_call_count == context->fail_on_sample_call)
return -1;
if (context->sample_callback_fails)
return -1;
if (context->sample_invalid) {
*out = (SpacetimeEscapeWorldtubeSample){.valid = 0};
return 0;
}
if (context->sample_nan_radius) {
*out = (SpacetimeEscapeWorldtubeSample){.radius = NAN, .valid = 1};
return 0;
}
if (context->sample_nonpositive_radius) {
*out = (SpacetimeEscapeWorldtubeSample){.radius = 0.0, .valid = 1};
return 0;
}
if (!isfinite(t) || t < context->valid_t_min) {
*out = (SpacetimeEscapeWorldtubeSample){.valid = 0};
return 0;
}
if (context->invalid_halfwidth > 0.0 &&
fabs(t - context->invalid_center) <= context->invalid_halfwidth) {
*out = (SpacetimeEscapeWorldtubeSample){.valid = 0};
return 0;
}
if (context->has_segment && t < context->segment_t) {
/* Second segment: center moves toward +x as t decreases. */
*out = (SpacetimeEscapeWorldtubeSample){
.center = {context->segment_t - t, 0.0, 0.0},
.velocity = {-1.0, 0.0, 0.0},
.radius = context->radius,
.radius_rate = context->radius_rate,
.velocity_constant = context->constant,
.valid = 1};
return 0;
}
*out = (SpacetimeEscapeWorldtubeSample){
.center = {context->vx * t + 0.5 * context->accel * t * t, 0.0, 0.0},
.velocity = {context->vx + context->accel * t, 0.0, 0.0},
.radius = context->radius,
.radius_rate = context->radius_rate,
.velocity_constant = context->constant,
.valid = 1};
return 0;
}
static double synthetic_next_segment(const SpacetimeSource *source,
SpacetimeEndId end_id, double t) {
const SyntheticContext *context = source->context;
(void)end_id;
if (context->has_segment && t > context->segment_t)
return context->segment_t;
return NAN;
}
static void synthetic_destroy(SpacetimeSource *source) {
/* The test context lives on the stack, so it is not freed; but match the
* real destroy postcondition. */
source->context = NULL;
source->ops = NULL;
}
static const SpacetimeOps synthetic_ops = {
.eval = synthetic_eval,
.classify = synthetic_classify,
.asymptotic_end_count = synthetic_end_count,
.asymptotic_end = synthetic_end,
.escape_worldtube_sample = synthetic_worldtube,
.escape_worldtube_next_segment = synthetic_next_segment,
.destroy = synthetic_destroy,
};
static void test_fixed_sphere(void) {
SpacetimeSource source = {0};
CHECK(spacetime_create_minkowski(&source, 10.0) == 0, "create minkowski");
AsymptoticRoute route;
const ObserverState inside = flat_observer(0.0, 0.0, 0.0);
CHECK(asymptotic_route_camera(&source, &inside, (double[]){1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_INSIDE,
"origin camera is inside");
const ObserverState outside = flat_observer(50.0, 0.0, 0.0);
CHECK(asymptotic_route_camera(&source, &outside, (double[]){-1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
"outside ray toward sphere enters");
CHECK(fabs(route.activate_t + 40.0) < 1e-9, "fixed-sphere entry time");
CHECK(fabs(route.x[0] - 10.0) < 1e-9 && fabs(route.x[1]) < 1e-9 &&
fabs(route.x[2]) < 1e-9,
"fixed-sphere entry position");
CHECK(asymptotic_route_camera(&source, &outside, (double[]){1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ESCAPED,
"outside ray away misses");
CHECK(fabs(route.n_infinity[0] - 1.0) < 1e-12 &&
fabs(route.n_infinity[1]) < 1e-12,
"miss direction");
CHECK(fabs(route.frequency_ratio - 1.0) < 1e-12, "flat frequency ratio");
const ObserverState tangent = flat_observer(50.0, 10.0, 0.0);
CHECK(asymptotic_route_camera(&source, &tangent, (double[]){-1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ESCAPED,
"tangent ray is not a crossing");
const ObserverState near_miss = flat_observer(50.0, 10.0 + 1e-6, 0.0);
CHECK(asymptotic_route_camera(&source, &near_miss,
(double[]){-1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ESCAPED,
"near-tangent outside ray misses");
const ObserverState near_hit = flat_observer(50.0, 10.0 - 1e-6, 0.0);
CHECK(asymptotic_route_camera(&source, &near_hit,
(double[]){-1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
"near-tangent inside ray enters");
RayEndpoint endpoint;
CHECK(asymptotic_finish_escape(&source, 0, 0.0, (double[]){10.0, 0.0, 0.0},
(double[]){-1.0, 0.0, 0.0}, 0.0,
&endpoint) == ASYMPTOTIC_OK &&
endpoint.status == RAY_ENDPOINT_ESCAPED && endpoint.end_id == 0,
"finish outward crossing");
CHECK(fabs(endpoint.n_infinity[0] - 1.0) < 1e-12 &&
fabs(endpoint.frequency_ratio - 1.0) < 1e-12,
"finish direction and frequency");
spacetime_destroy(&source);
}
static void test_large_radius_quadratic(void) {
SpacetimeSource source = {0};
CHECK(spacetime_create_minkowski(&source, 1.0e12) == 0,
"create huge minkowski sphere");
AsymptoticRoute route;
const ObserverState hit = flat_observer(2.0e12, 5.0e11, 0.0);
CHECK(asymptotic_route_camera(&source, &hit, (double[]){-1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
"large-radius hit stays quadratic");
const ObserverState miss = flat_observer(2.0e12, 2.0e12, 0.0);
CHECK(asymptotic_route_camera(&source, &miss, (double[]){-1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ESCAPED,
"large-radius miss stays quadratic");
spacetime_destroy(&source);
/* Small entry root a hair outside a large sphere: the cancellation-prone
* case for the naive formula. */
SpacetimeSource big = {0};
CHECK(spacetime_create_minkowski(&big, 1.0e9) == 0, "create 1e9 sphere");
const ObserverState just_outside = flat_observer(1.0e9 + 1e-3, 0.0, 0.0);
CHECK(asymptotic_route_camera(&big, &just_outside,
(double[]){-1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
"just-outside hit");
const double expected_delta =
just_outside.coordinate_position[0] - 1.0e9;
CHECK(fabs(-route.activate_t - expected_delta) <
1e-7 + 1e-11 * fabs(expected_delta),
"just-outside entry time within budget");
double residual;
CHECK(asymptotic_worldtube_value(&big, route.end_id, route.activate_t,
route.x, &residual) == 0 &&
fabs(residual) <= 1e-12 * 1.0e9 * 1.0e9,
"just-outside entry on worldtube");
spacetime_destroy(&big);
}
static void test_boundary_semantics_minkowski(void) {
SpacetimeSource source = {0};
CHECK(spacetime_create_minkowski(&source, 10.0) == 0,
"create minkowski");
AsymptoticRoute route;
const ObserverState on_boundary = flat_observer(10.0, 0.0, 0.0);
CHECK(asymptotic_route_camera(&source, &on_boundary,
(double[]){-1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_INSIDE,
"on-boundary past-inward is inside");
CHECK(asymptotic_route_camera(&source, &on_boundary,
(double[]){1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ESCAPED,
"on-boundary past-outward escapes");
CHECK(asymptotic_route_camera(&source, &on_boundary,
(double[]){0.0, 1.0, 0.0},
&route) == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ESCAPED,
"on-boundary tangent escapes");
spacetime_destroy(&source);
}
static void test_boundary_semantics_generic(void) {
/* velocity_constant == 0 forces the generic bracketed driver. A finite
* history bounds the outward/tangent searches, which must not be reported
* as entries (they end as TIME_RANGE_EXHAUSTED instead). */
SyntheticContext context = {.radius = 10.0,
.valid_t_min = -100.0,
.constant = 0};
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
const ObserverState on_boundary = flat_observer(10.0, 0.0, 0.0);
AsymptoticRoute route;
const AsymptoticStatus inward = asymptotic_route_camera(
&source, &on_boundary, (double[]){-1.0, 0.0, 0.0}, &route);
CHECK(inward == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_INSIDE,
"generic on-boundary inward is inside");
const AsymptoticStatus outward = asymptotic_route_camera(
&source, &on_boundary, (double[]){1.0, 0.0, 0.0}, &route);
CHECK(outward == ASYMPTOTIC_TIME_RANGE_EXHAUSTED,
"generic on-boundary outward is not an entry");
const AsymptoticStatus tangent = asymptotic_route_camera(
&source, &on_boundary, (double[]){0.0, 1.0, 0.0}, &route);
CHECK(tangent == ASYMPTOTIC_TIME_RANGE_EXHAUSTED,
"generic on-boundary tangent is not an entry");
}
static void test_negative_radius_root_guard(void) {
/* Deliberately bypasses a constructor: every sampled radius is finite and
* positive, but the algebraic root sits where R < 0. The cheap root-level
* guard must reject it instead of fabricating a negative-radius entry. */
SyntheticContext context = {.radius = 10.0,
.radius_rate = 2.0,
.valid_t_min = -1.0e30,
.constant = 1};
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
const ObserverState on_boundary = flat_observer(10.0, 0.0, 0.0);
AsymptoticRoute route;
CHECK(asymptotic_route_camera(&source, &on_boundary,
(double[]){1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_INVALID,
"negative-radius algebraic root is rejected");
}
static void test_source_finalize(void) {
/* A real constructor already finalizes: finalize is idempotent. */
SpacetimeSource good = {0};
CHECK(spacetime_create_minkowski(&good, 10.0) == 0, "create minkowski");
CHECK(spacetime_source_finalize(&good) == 0, "valid source finalizes");
spacetime_destroy(&good);
/* A structurally valid synthetic source must pass, so the failure cases
* below are attributable to their specific defect rather than to the test
* ops themselves. */
SyntheticContext well_formed = {.radius = 10.0,
.valid_t_min = -1.0e30,
.constant = 1};
SpacetimeSource valid_source = {.ops = &synthetic_ops,
.context = &well_formed};
CHECK(spacetime_source_finalize(&valid_source) == 0,
"well-formed synthetic source finalizes");
/* Structural protocol errors must be rejected before any ray trace. */
SyntheticContext bad_kind = {.radius = 10.0,
.valid_t_min = -1.0e30,
.constant = 1,
.unsupported_kind = 1};
SpacetimeSource source = {.ops = &synthetic_ops, .context = &bad_kind};
CHECK(spacetime_source_finalize(&source) != 0,
"unsupported exterior kind fails finalize");
SyntheticContext bad_desc = {.radius = 10.0,
.valid_t_min = -1.0e30,
.constant = 1,
.end_descriptor_fails = 1};
source = (SpacetimeSource){.ops = &synthetic_ops, .context = &bad_desc};
CHECK(spacetime_source_finalize(&source) != 0,
"broken end descriptor fails finalize");
}
static void test_motion_segment_domain(void) {
/* Segment 1 (t >= -50) is a static R=10 sphere; its quadratic root lies at
* s = 90, past the segment boundary. Segment 2 (t < -50) moves the center
* with velocity -1, so the true entry is at s = 70. The result must come
* from segment 2. */
SyntheticContext context = {.radius = 10.0,
.valid_t_min = -1.0e30,
.constant = 1,
.segment_t = -50.0,
.has_segment = 1};
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
const ObserverState camera = flat_observer(100.0, 0.0, 0.0);
AsymptoticRoute route;
CHECK(asymptotic_route_camera(&source, &camera,
(double[]){-1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
"entry found in the second motion segment");
CHECK(fabs(route.activate_t + 70.0) < 1e-6,
"second-segment entry, not the stale first-segment root");
}
static void test_schwarzschild_sample_failures(void) {
const ObserverState camera = flat_observer(0.0, 0.0, 0.0);
AsymptoticRoute route;
SyntheticContext base = {.radius = 256.0,
.valid_t_min = -1.0e30,
.constant = 1,
.schwarzschild_kind = 1};
SyntheticContext callback = base;
callback.sample_callback_fails = 1;
SpacetimeSource s1 = {.ops = &synthetic_ops, .context = &callback};
CHECK(asymptotic_route_camera(&s1, &camera, (double[]){1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_INVALID,
"schwarzschild callback failure is invalid");
SyntheticContext invalid = base;
invalid.sample_invalid = 1;
SpacetimeSource s2 = {.ops = &synthetic_ops, .context = &invalid};
CHECK(asymptotic_route_camera(&s2, &camera, (double[]){1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_TIME_RANGE_EXHAUSTED,
"schwarzschild valid=0 is exhausted");
SyntheticContext nan = base;
nan.sample_nan_radius = 1;
SpacetimeSource s3 = {.ops = &synthetic_ops, .context = &nan};
CHECK(asymptotic_route_camera(&s3, &camera, (double[]){1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_INVALID,
"schwarzschild NaN radius is invalid");
SyntheticContext zero = base;
zero.sample_nonpositive_radius = 1;
SpacetimeSource s4 = {.ops = &synthetic_ops, .context = &zero};
CHECK(asymptotic_route_camera(&s4, &camera, (double[]){1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_INVALID,
"schwarzschild non-positive radius is invalid");
/* Second call fails: the containment sample (#1) succeeds with the camera
* outside, and schwarzschild_route's own sample (#2) is the one that fails.
* This locks the dedicated Schwarzschild sample handling. */
SyntheticContext second = base;
second.fail_on_sample_call = 2;
SpacetimeSource s5 = {.ops = &synthetic_ops, .context = &second};
const ObserverState outside = flat_observer(500.0, 0.0, 0.0);
CHECK(asymptotic_route_camera(&s5, &outside, (double[]){1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_INVALID,
"schwarzschild_route second-sample failure is invalid");
}
static void test_end_protocol_error(void) {
const ObserverState inside = flat_observer(0.0, 0.0, 0.0);
AsymptoticRoute route;
SyntheticContext bad = {.radius = 20.0,
.valid_t_min = -1.0e30,
.constant = 1,
.end_descriptor_fails = 1};
SpacetimeSource bad_source = {.ops = &synthetic_ops, .context = &bad};
CHECK(asymptotic_route_camera(&bad_source, &inside,
(double[]){1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_INVALID,
"bad end descriptor is an explicit protocol error");
SyntheticContext unsupported = {.radius = 20.0,
.valid_t_min = -1.0e30,
.constant = 1,
.unsupported_kind = 1};
SpacetimeSource unsupported_source = {.ops = &synthetic_ops,
.context = &unsupported};
CHECK(asymptotic_route_camera(&unsupported_source, &inside,
(double[]){1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_UNSUPPORTED,
"unsupported exterior with camera inside is not silently accepted");
/* The lifecycle layer, not just the pre-route, must refuse legacy fallback
* whenever ends are declared but broken. */
MetricSlab *slab = NULL;
CHECK(spacetime_load_slab(&bad_source, 0.0, -10.0, &slab) == 0,
"bad-descriptor slab");
GeodesicRayState state = {.coordinate_time = 0.0,
.x = {1.0, 0.0, 0.0},
.Pi = {0.0, 0.0, 0.0},
.log_alpha_p0 = 0.0,
.steps = 0};
const GeodesicTraceConfig config = {.coordinate_time_step = 1.0,
.max_steps = 10};
RayEndpoint endpoint = {.frequency_ratio = 0.0,
.magnification = 1.0,
.end_id = SPACETIME_END_NONE,
.status = RAY_ENDPOINT_INVALID};
CHECK(geodesic_advance_past_ray(slab, &state, -10.0, &config, &endpoint) ==
GEODESIC_ADVANCE_FAILED &&
endpoint.status == RAY_ENDPOINT_INVALID,
"advance rejects a declared-but-broken end without legacy");
spacetime_free_slab(slab);
}
static void test_interior_crossing_bisection_failure(void) {
/* radius 20.3 makes the exit land strictly between steps: the accepted
* step goes from F < 0 (t = -119.7) to F > 0 (t = -120.7). The invalid
* window sits on the first bisection midpoint (t = -120.2), while both
* accepted-step endpoints stay valid. */
SyntheticContext context = {.radius = 20.3,
.valid_t_min = -1.0e30,
.constant = 1,
.invalid_center = -120.2,
.invalid_halfwidth = 0.05};
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
const ObserverState observer = flat_observer(100.0, 0.0, 0.0);
const GeodesicTraceConfig config = {.coordinate_time_step = 1.0,
.max_steps = 2048};
const RayEndpoint endpoint = geodesic_trace_past(
&source, &observer, (double[]){-1.0, 0.0, 0.0}, &config);
CHECK(endpoint.status == RAY_ENDPOINT_TIME_RANGE_EXHAUSTED &&
endpoint.end_id == 0,
"interior crossing bisection propagates history exhaustion");
}
static void test_generic_bisection_failure(void) {
/* The isolated invalid window lands on a bisection midpoint while the
* bracket endpoints stay valid, so only the bisection can see it. With the
* strict F < 0 entry test, the bracket is s = 80 (F == 0) to s = 90
* (F < 0), so the first midpoint is t = -85. */
SyntheticContext context = {.radius = 20.0,
.valid_t_min = -1.0e30,
.constant = 0,
.invalid_center = -85.0,
.invalid_halfwidth = 1.0};
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
const ObserverState camera = flat_observer(100.0, 0.0, 0.0);
AsymptoticRoute route;
CHECK(asymptotic_route_camera(&source, &camera, (double[]){-1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_TIME_RANGE_EXHAUSTED,
"generic worldtube bisection propagates sample failure");
}
static void test_interior_history_exhaustion(void) {
SyntheticContext context = {.radius = 20.0,
.valid_t_min = -100.0,
.constant = 1};
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
const ObserverState observer = flat_observer(100.0, 0.0, 0.0);
const GeodesicTraceConfig config = {.coordinate_time_step = 1.0,
.max_steps = 2048};
const RayEndpoint endpoint = geodesic_trace_past(
&source, &observer, (double[]){-1.0, 0.0, 0.0}, &config);
CHECK(endpoint.status == RAY_ENDPOINT_TIME_RANGE_EXHAUSTED &&
endpoint.end_id == 0,
"interior worldtube history exhaustion on a single trace");
RayPool pool;
CHECK(ray_pool_init(&pool, 1) == 0, "pool init");
CHECK(ray_pool_append(&pool, &observer, (double[]){-1.0, 0.0, 0.0}, 0, 0) ==
0,
"append exhaustion ray");
ray_pool_preroute(&pool, &source);
CHECK(pool.status[0] == RAY_POOL_PENDING, "exhaustion ray pends entry");
MetricSlab *slab = NULL;
CHECK(spacetime_load_slab(&source, -80.0, -3000.0, &slab) == 0,
"exhaustion slab");
ray_pool_activate_in_time_range(&pool, slab);
CHECK(pool.status[0] == RAY_POOL_ACTIVE, "exhaustion ray activates");
ray_pool_advance_active(&pool, slab, &config);
CHECK(pool.endpoint[0].status == RAY_ENDPOINT_TIME_RANGE_EXHAUSTED &&
pool.endpoint[0].end_id == 0 &&
pool.status[0] == RAY_POOL_TERMINATED,
"interior worldtube history exhaustion on a RayPool");
spacetime_free_slab(slab);
ray_pool_destroy(&pool);
}
static void test_round_trip(void) {
SpacetimeSource source = {0};
CHECK(spacetime_create_minkowski(&source, 10.0) == 0, "create minkowski");
MetricData metric = {.alpha = 1.0,
.gamma = {{1.0, 0.0, 0.0},
{0.0, 1.0, 0.0},
{0.0, 0.0, 1.0}}};
AsymptoticPhotonState canonical;
CHECK(asymptotic_canonical_from_backend(&source, 0, &metric, 0.0,
(double[]){3.0, 4.0, 0.0},
(double[]){-0.6, 0.8, 0.0}, 0.25,
&canonical) == 0,
"backend to canonical");
double x[3], Pi[3], log_alpha_p0;
CHECK(asymptotic_backend_from_canonical(&source, &metric, &canonical, x, Pi,
&log_alpha_p0) == 0,
"canonical to backend");
CHECK(fabs(x[0] - 3.0) < 1e-14 && fabs(x[1] - 4.0) < 1e-14 &&
fabs(Pi[0] + 0.6) < 1e-14 && fabs(Pi[1] - 0.8) < 1e-14 &&
fabs(log_alpha_p0 - 0.25) < 1e-14,
"round trip matches");
spacetime_destroy(&source);
}
static void test_moving_sphere(void) {
SyntheticContext context = {.vx = 0.5, .accel = 0.0, .radius = 25.0,
.valid_t_min = -1.0e30, .constant = 1};
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
AsymptoticRoute route;
const ObserverState head_on = flat_observer(100.0, 0.0, 0.0);
CHECK(asymptotic_route_camera(&source, &head_on, (double[]){-1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
"head-on moving-sphere entry");
CHECK(fabs(route.activate_t + 150.0) < 1e-9, "head-on entry time");
CHECK(fabs(route.x[0] + 50.0) < 1e-9, "head-on entry position");
double value;
CHECK(asymptotic_worldtube_value(&source, route.end_id, route.activate_t,
route.x, &value) == 0 &&
fabs(value) <= 1e-13 * 25.0 * 25.0,
"head-on entry lies on worldtube");
const ObserverState transverse = flat_observer(0.0, 40.0, 0.0);
CHECK(asymptotic_route_camera(&source, &transverse,
(double[]){0.0, -1.0, 0.0},
&route) == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
"transverse moving-sphere entry");
CHECK(asymptotic_worldtube_value(&source, route.end_id, route.activate_t,
route.x, &value) == 0 &&
fabs(value) <= 1e-13 * 25.0 * 25.0,
"transverse entry lies on worldtube");
const ObserverState away = flat_observer(100.0, 0.0, 0.0);
CHECK(asymptotic_route_camera(&source, &away, (double[]){1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ESCAPED,
"co-moving ray misses");
}
static void test_accelerated_worldtube(void) {
SyntheticContext context = {.vx = 0.0, .accel = 0.02, .radius = 20.0,
.valid_t_min = -1.0e30, .constant = 0};
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
const ObserverState camera = flat_observer(100.0, 0.0, 0.0);
AsymptoticRoute route;
CHECK(asymptotic_route_camera(&source, &camera, (double[]){-1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
"accelerated worldtube entry");
double value;
CHECK(asymptotic_worldtube_value(&source, route.end_id, route.activate_t,
route.x, &value) == 0 &&
fabs(value) <= 1e-13 * 20.0 * 20.0,
"accelerated entry lies on worldtube");
CHECK(route.activate_t < -40.0 && route.activate_t > -60.0,
"accelerated entry time in range");
context.valid_t_min = -30.0;
CHECK(asymptotic_route_camera(&source, &camera, (double[]){-1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_TIME_RANGE_EXHAUSTED,
"exhausted history is not a miss");
}
static void test_accelerated_segment(void) {
SyntheticContext context = {.vx = 0.0,
.accel = 0.0,
.radius = 20.0,
.valid_t_min = -1.0e30,
.constant = 0,
.segment_t = -50.0,
.has_segment = 1};
SpacetimeSource source = {.ops = &synthetic_ops, .context = &context};
const ObserverState camera = flat_observer(100.0, 0.0, 0.0);
AsymptoticRoute route;
CHECK(asymptotic_route_camera(&source, &camera, (double[]){-1.0, 0.0, 0.0},
&route) == ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
"cross-segment entry");
CHECK(route.activate_t < context.segment_t,
"entry lies past the motion-segment boundary");
CHECK(fabs(route.activate_t + 65.0) < 1e-6, "cross-segment entry time");
double value;
CHECK(asymptotic_worldtube_value(&source, route.end_id, route.activate_t,
route.x, &value) == 0 &&
fabs(value) <= 1e-13 * 20.0 * 20.0,
"cross-segment entry on worldtube");
}
static void test_ray_pool_lifecycle(void) {
SpacetimeSource source = {0};
CHECK(spacetime_create_minkowski(&source, 10.0) == 0, "create minkowski");
const ObserverState observer = flat_observer(50.0, 0.0, 0.0);
RayPool pool;
CHECK(ray_pool_init(&pool, 2) == 0, "pool init");
CHECK(ray_pool_append(&pool, &observer, (double[]){-1.0, 0.0, 0.0}, 0, 0) == 0,
"append hit");
CHECK(ray_pool_append(&pool, &observer, (double[]){1.0, 0.0, 0.0}, 0, 1) == 0,
"append miss");
ray_pool_preroute(&pool, &source);
CHECK(pool.status[0] == RAY_POOL_PENDING &&
pool.activate_t[0] < observer.coordinate_time - 1.0,
"entry ray stays pending until entry time");
CHECK(pool.status[1] == RAY_POOL_TERMINATED &&
pool.endpoint[1].status == RAY_ENDPOINT_ESCAPED,
"miss ray escapes during pre-route");
MetricSlab *early = NULL;
CHECK(spacetime_load_slab(&source, -20.0, -30.0, &early) == 0, "early slab");
ray_pool_activate_in_time_range(&pool, early);
CHECK(pool.status[0] == RAY_POOL_PENDING, "entry ray not active early");
spacetime_free_slab(early);
MetricSlab *covering = NULL;
CHECK(spacetime_load_slab(&source, 0.0, -100.0, &covering) == 0,
"covering slab");
ray_pool_activate_in_time_range(&pool, covering);
CHECK(pool.status[0] == RAY_POOL_ACTIVE &&
fabs(pool.t[0] - pool.activate_t[0]) < 1e-30,
"entry ray activates at entry time");
spacetime_free_slab(covering);
ray_pool_destroy(&pool);
spacetime_destroy(&source);
}
int main(void) {
test_fixed_sphere();
test_large_radius_quadratic();
test_round_trip();
test_moving_sphere();
test_accelerated_worldtube();
test_accelerated_segment();
test_boundary_semantics_minkowski();
test_boundary_semantics_generic();
test_negative_radius_root_guard();
test_source_finalize();
test_motion_segment_domain();
test_schwarzschild_sample_failures();
test_end_protocol_error();
test_generic_bisection_failure();
test_interior_history_exhaustion();
test_interior_crossing_bisection_failure();
test_ray_pool_lifecycle();
if (failures == 0)
puts("asymptotic regression passed");
else
fprintf(stderr, "%d asymptotic regression failures\n", failures);
return failures == 0 ? 0 : 1;
}
+494
View File
@@ -0,0 +1,494 @@
#include "asymptotic.h"
#include "asymptotic_schwarzschild.h"
#include "geodesic.h"
#include "observer.h"
#include "spacetime.h"
#include <math.h>
#include <stdio.h>
static int failures = 0;
#define CHECK(condition, message) \
do { \
if (!(condition)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, message); \
++failures; \
} \
} while (0)
static double angle_between(const double a[3], const double b[3]) {
const double dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const double cx = a[1] * b[2] - a[2] * b[1];
const double cy = a[2] * b[0] - a[0] * b[2];
const double cz = a[0] * b[1] - a[1] * b[0];
return atan2(sqrt(cx * cx + cy * cy + cz * cz), dot);
}
static void test_round_trip(void) {
SpacetimeSource source = {0};
CHECK(spacetime_create_schwarzschild_ks(&source, 1.0, 256.0, 1.5) == 0,
"create schwarzschild");
SpacetimeAsymptoticEnd end;
CHECK(spacetime_asymptotic_end(&source, 0, &end) == 0, "end descriptor");
SchwarzschildCanonical in = {.end_id = 0,
.t = 0.0,
.rho = 256.0,
.rhat = {1.0, 0.0, 0.0},
.Lhat = {0.0, 1.0, 0.0},
.beta = 5.0,
.energy = 1.0,
.radial_sign = 1};
double x[3], Pi[3], log_alpha_p0;
CHECK(asymptotic_schwarzschild_state_from_canonical(&end, &in, x, Pi,
&log_alpha_p0) == 0,
"state from canonical");
MetricData metric;
CHECK(spacetime_eval(&source, in.t, x, &metric) == 0, "metric");
SchwarzschildCanonical out;
CHECK(asymptotic_schwarzschild_canonical_from_state(
&end, &metric, in.t, x, Pi, log_alpha_p0, &out) == 0,
"canonical from state");
CHECK(fabs(out.beta - in.beta) < 1e-13, "beta round trip");
CHECK(fabs(out.energy - in.energy) < 1e-13, "energy round trip");
CHECK(out.radial_sign == in.radial_sign, "radial sign round trip");
const double axis = angle_between(out.rhat, in.rhat);
CHECK(axis < 1e-13, "position direction round trip");
spacetime_destroy(&source);
}
static void test_finish_matches_integration(void) {
SpacetimeSource near = {0}, far = {0};
CHECK(spacetime_create_schwarzschild_ks(&near, 1.0, 256.0, 1.5) == 0,
"create near");
CHECK(spacetime_create_schwarzschild_ks(&far, 1.0, 1.0e5, 1.5) == 0,
"create far");
SpacetimeAsymptoticEnd end;
CHECK(spacetime_asymptotic_end(&near, 0, &end) == 0, "near end");
const double betas[] = {0.0, 0.5, 4.0, 10.0, 30.0, 100.0, 250.0};
const int beta_count = (int)(sizeof betas / sizeof betas[0]);
for (int k = 0; k < beta_count; ++k) {
SchwarzschildCanonical canonical = {.end_id = 0,
.t = 0.0,
.rho = 256.0,
.rhat = {0.8, 0.6, 0.0},
.Lhat = {0.0, 0.0, 1.0},
.beta = betas[k],
.energy = 1.0,
.radial_sign = 1};
double x[3], Pi[3], log_alpha_p0;
CHECK(asymptotic_schwarzschild_state_from_canonical(
&end, &canonical, x, Pi, &log_alpha_p0) == 0,
"finish state build");
double n_analytic[3], freq_analytic;
CHECK(asymptotic_schwarzschild_finish(&end, &canonical, n_analytic,
&freq_analytic) == 0,
"analytic finish");
GeodesicRayState state = {.coordinate_time = 0.0,
.x = {x[0], x[1], x[2]},
.Pi = {Pi[0], Pi[1], Pi[2]},
.log_alpha_p0 = log_alpha_p0,
.steps = 0};
const GeodesicTraceConfig config = {.coordinate_time_step = 5.0,
.max_steps = 100000};
MetricSlab *slab = NULL;
CHECK(spacetime_load_slab(&far, 0.0, -1.0e6, &slab) == 0, "far slab");
RayEndpoint endpoint = {.frequency_ratio = 0, .magnification = 1.0,
.end_id = SPACETIME_END_NONE,
.status = RAY_ENDPOINT_INVALID};
const GeodesicAdvanceResult result =
geodesic_advance_past_ray(slab, &state, -1.0e6, &config, &endpoint);
spacetime_free_slab(slab);
CHECK(result == GEODESIC_ADVANCE_TERMINATED &&
endpoint.status == RAY_ENDPOINT_ESCAPED,
"far integration escapes");
/* Pipeline check only: the far integration at step 5 and escape radius
* 1e5 has its own O(1e-5..1e-3) error. Quantitative accuracy is checked
* against the high-precision reference constants below. */
const double angle_error =
angle_between(n_analytic, endpoint.n_infinity);
CHECK(angle_error < 1e-2, "finish direction matches far integration");
CHECK(fabs(freq_analytic - endpoint.frequency_ratio) /
freq_analytic < 1e-2,
"finish frequency matches far integration");
(void)angle_error;
}
spacetime_destroy(&near);
spacetime_destroy(&far);
}
/* Independent quadrature of the KS coordinate-time transfer for a camera
* outside the worldtube, used to check the analytic primitive. */
static double simpson(const double a, const double b, int panels,
double (*f)(double, const void *), const void *ctx) {
if (panels < 2)
panels = 2;
if (panels % 2)
++panels;
const double h = (b - a) / panels;
double sum = f(a, ctx) + f(b, ctx);
for (int i = 1; i < panels; ++i)
sum += (i % 2 ? 4.0 : 2.0) * f(a + i * h, ctx);
return sum * h / 3.0;
}
typedef struct {
double beta;
} TransferContext;
static double transfer_dt(double r, const void *context) {
const TransferContext *c = context;
const double Q = 1.0 - c->beta * c->beta * (1.0 - 2.0 / r) / (r * r);
return 1.0 / ((1.0 - 2.0 / r) * sqrt(Q)) + 2.0 / (r - 2.0);
}
static double transfer_dphi(double r, const void *context) {
const TransferContext *c = context;
const double Q = 1.0 - c->beta * c->beta * (1.0 - 2.0 / r) / (r * r);
return c->beta / (r * r * sqrt(Q));
}
static void test_preroute_entry(void) {
SpacetimeSource source = {0};
CHECK(spacetime_create_schwarzschild_ks(&source, 1.0, 256.0, 1.5) == 0,
"create schwarzschild");
const ObserverCamera camera = {.look_ra_deg = 0.0, .look_dec_deg = 0.0};
ObserverCamera positioned = camera;
positioned.position[0] = 500.0;
positioned.look_ra_deg = 180.0;
positioned.look_dec_deg = 0.0;
const double direction[3] = {cos(0.3), sin(0.3), 0.0};
MetricData metric;
CHECK(spacetime_eval(&source, 0.0, positioned.position, &metric) == 0,
"camera metric");
ObserverState observer;
CHECK(observer_from_coordinate_camera(&metric, &positioned, &observer,
NULL) == OBSERVER_BUILD_OK,
"camera observer");
MetricSlab *camera_slab = NULL;
CHECK(spacetime_load_slab(&source, 0.0, -1.0, &camera_slab) == 0,
"camera slab");
GeodesicRayState camera_state;
CHECK(geodesic_initialize_past_ray(camera_slab, &observer, direction,
&camera_state) == 0,
"camera state");
SpacetimeAsymptoticEnd end;
CHECK(spacetime_asymptotic_end(&source, 0, &end) == 0, "end");
SchwarzschildCanonical camera_can;
CHECK(asymptotic_schwarzschild_canonical_from_state(
&end, &metric, 0.0, camera_state.x, camera_state.Pi,
camera_state.log_alpha_p0, &camera_can) == 0,
"camera canonical");
spacetime_free_slab(camera_slab);
AsymptoticRoute route;
CHECK(asymptotic_route_camera(&source, &observer, direction, &route) ==
ASYMPTOTIC_OK &&
route.kind == ASYMPTOTIC_ROUTE_ENTRY,
"outside camera enters");
double value;
CHECK(asymptotic_worldtube_value(&source, route.end_id, route.activate_t,
route.x, &value) == 0 &&
fabs(value) < 1e-3,
"entry on worldtube");
MetricData entry_metric;
CHECK(spacetime_eval(&source, route.activate_t, route.x, &entry_metric) ==
0,
"entry metric");
SchwarzschildCanonical entry_can;
CHECK(asymptotic_schwarzschild_canonical_from_state(
&end, &entry_metric, route.activate_t, route.x, route.Pi,
route.log_alpha_p0, &entry_can) == 0,
"entry canonical");
CHECK(fabs(entry_can.beta - camera_can.beta) <
1e-12 * fmax(1.0, camera_can.beta),
"entry conserves impact parameter");
CHECK(fabs(entry_can.energy - camera_can.energy) < 1e-12,
"entry conserves energy");
CHECK(entry_can.radial_sign == -1, "entry is past-inward");
CHECK(route.activate_t < 0.0, "entry time is in the past");
const TransferContext context = {.beta = camera_can.beta};
const double t_analytic = -route.activate_t;
const double t_numeric =
simpson(256.0, 500.0, 20000, transfer_dt, &context);
CHECK(fabs(t_analytic - t_numeric) < 1e-9 * fmax(1.0, t_numeric),
"entry time matches quadrature");
const double dphi_numeric =
simpson(256.0, 500.0, 20000, transfer_dphi, &context);
const double dphi_entry = angle_between(camera_can.rhat, entry_can.rhat);
CHECK(fabs(dphi_entry - dphi_numeric) < 1e-9,
"entry azimuth matches quadrature");
if (fabs(t_analytic - t_numeric) >= 1e-9 * fmax(1.0, t_numeric) ||
fabs(dphi_entry - dphi_numeric) >= 1e-9)
fprintf(stderr, " beta=%.6g t_an=%.12g t_num=%.12g dphi_an=%.12g "
"dphi_num=%.12g\n",
camera_can.beta, t_analytic, t_numeric, dphi_entry,
dphi_numeric);
spacetime_destroy(&source);
}
/* High-precision (mpmath, 60 digits) reference values fixed into the ordinary
* C test: radial, complex-pair, three-real, grazing, and large-radius angle
* cases. */
static void test_phi_reference_constants(void) {
static const struct {
double rho, beta, value;
} cases[] = {
{256.0, 0.0, 0.0},
{256.0, 5.0, 0.019532484697919191145},
{256.0, 60.0, 0.23656231243306290715},
{64.0, 64.0, 1.4199914058161304301},
{256.0, 255.0, 1.4527184167466732533},
{1.0e6, 1.0, 1.0000000000001666664e-6},
{300.0, 3.0, 0.010000165840750676787},
{100.0, 5.3, 0.053024471018799209953},
};
for (size_t i = 0; i < sizeof cases / sizeof cases[0]; ++i) {
const double got =
asymptotic_schwarzschild_phi(cases[i].rho, cases[i].beta);
CHECK(fabs(got - cases[i].value) < 2e-13, "phi high-precision reference");
}
}
/* High-precision (mpmath, 60 digits) finish references covering radial,
* complex-pair, three-real, grazing, and large-radius scattering. The
* acceptance standard here is the error-budget-driven 1e-8 rad, not the
* measured ~1e-13. */
static void test_finish_reference_constants(void) {
SpacetimeSource source = {0};
CHECK(spacetime_create_schwarzschild_ks(&source, 1.0, 256.0, 1.5) == 0,
"create schwarzschild");
SpacetimeAsymptoticEnd end;
CHECK(spacetime_asymptotic_end(&source, 0, &end) == 0, "end");
static const struct {
double rho, beta, n[3];
} cases[] = {
{256.0, 0.0, {0.8, 0.6, 0.0}},
{256.0, 3.0, {0.80697631554502468, 0.59058380112341107, 0.0}},
{256.0, 60.0, {0.91833674808504193, 0.39579997109220491, 0.0}},
{256.0, 255.0, {0.69006511550899122, -0.72374728763399356, 0.0}},
{1.0e6, 1.0, {0.8000005999996, 0.5999991999997, 0.0}},
};
for (size_t i = 0; i < sizeof cases / sizeof cases[0]; ++i) {
SchwarzschildCanonical canonical = {.end_id = 0,
.t = 0.0,
.rho = cases[i].rho,
.rhat = {0.8, 0.6, 0.0},
.Lhat = {0.0, 0.0, 1.0},
.beta = cases[i].beta,
.energy = 2.5,
.radial_sign = 1};
double n_inf[3], frequency = 0.0;
CHECK(asymptotic_schwarzschild_finish(&end, &canonical, n_inf,
&frequency) == 0,
"finish reference runs");
CHECK(angle_between(n_inf, cases[i].n) < 1e-8,
"finish n_inf high-precision reference");
CHECK(fabs(frequency - 0.4) < 1e-10 * 0.4,
"finish frequency high-precision reference");
}
spacetime_destroy(&source);
}
/* Turning equation residual |Q| at the computed turning radius. The final
* scattering direction is validated by test_grazing_reference(). */
static void test_turning_reference(void) {
const double betas[] = {3.0 * sqrt(3.0) + 1e-9, 5.5, 6.0, 10.0,
60.0, 255.0, 3890.44};
for (size_t i = 0; i < sizeof betas / sizeof betas[0]; ++i) {
const double rho = asymptotic_schwarzschild_turning_rho(betas[i]);
CHECK(isfinite(rho) && rho > 3.0, "turning radius exists and is exterior");
const double Q =
1.0 - betas[i] * betas[i] * (1.0 - 2.0 / rho) / (rho * rho);
CHECK(fabs(Q) <= 1e-11, "turning equation residual");
}
}
/* High-precision entry coordinate-time and swept-azimuth references, checking
* both the KS time transfer and the entry direction construction. */
static void test_time_reference(void) {
SpacetimeSource source = {0};
CHECK(spacetime_create_schwarzschild_ks(&source, 1.0, 256.0, 1.5) == 0,
"create schwarzschild");
SpacetimeAsymptoticEnd end;
CHECK(spacetime_asymptotic_end(&source, 0, &end) == 0, "end");
static const struct {
double rho_cam, beta, time, dphi;
} cases[] = {
{500.0, 10.0, 246.7884398934447041137, 0.01907105306677434549856},
{500.0, 0.3, 246.693149021326385798, 0.0005718752307574524347376},
{256.5, 10.0, 0.5082474340167056157528, 0.00007620281793853560952548},
{256.5, 0.3, 0.5078666185420216617125, 0.000002284358278417004222584},
{1000.0, 50.0, 753.1528987272233278083, 0.1465476883815797019938},
{1.0e6, 10.0, 999777.308033789182372,
0.03906238263856681534781},
};
for (size_t i = 0; i < sizeof cases / sizeof cases[0]; ++i) {
SchwarzschildCanonical camera = {.end_id = 0,
.t = 0.0,
.rho = cases[i].rho_cam,
.rhat = {1.0, 0.0, 0.0},
.Lhat = {0.0, 0.0, 1.0},
.beta = cases[i].beta,
.energy = 1.0,
.radial_sign = -1};
SchwarzschildRouteKind kind = SCH_ROUTE_UNSUPPORTED;
double activate_t = 0.0, x[3], Pi[3], log_alpha_p0 = 0.0, n_inf[3],
frequency = 0.0;
CHECK(asymptotic_schwarzschild_preroute(
&end, 256.0, &camera, &kind, &activate_t, x, Pi, &log_alpha_p0,
n_inf, &frequency) == 0 &&
kind == SCH_ROUTE_ENTRY,
"reference pre-route entry");
/* Error-budget-driven mixed tolerance, well below one ODE step (0.1 M)
* and future metric cadence. */
const double time_tol = 1e-7 + 1e-11 * fabs(cases[i].time);
CHECK(fabs(-activate_t - cases[i].time) < time_tol,
"entry time high-precision reference");
const double radius =
sqrt(x[0] * x[0] + x[1] * x[1] + x[2] * x[2]);
const double rhat[3] = {x[0] / radius, x[1] / radius, x[2] / radius};
CHECK(fabs(angle_between(camera.rhat, rhat) - cases[i].dphi) < 2e-11,
"entry azimuth high-precision reference");
}
spacetime_destroy(&source);
}
/* Near-grazing references where the exterior integrals are most sensitive:
* the two sides of beta_R enter through different branches and the KS time
* integral has a near-singular endpoint. (A photon-sphere turning is not
* reachable from a camera outside R/M >= 64, so it is not tested here.) */
static void test_grazing_reference(void) {
SpacetimeSource source = {0};
CHECK(spacetime_create_schwarzschild_ks(&source, 1.0, 256.0, 1.5) == 0,
"create schwarzschild");
SpacetimeAsymptoticEnd end;
CHECK(spacetime_asymptotic_end(&source, 0, &end) == 0, "end");
const double beta_R = 256.0 / sqrt(1.0 - 2.0 / 256.0);
SchwarzschildCanonical hit = {.end_id = 0,
.t = 0.0,
.rho = 500.0,
.rhat = {1.0, 0.0, 0.0},
.Lhat = {0.0, 0.0, 1.0},
.beta = beta_R * (1.0 - 1e-12),
.energy = 1.0,
.radial_sign = -1};
SchwarzschildRouteKind kind;
double activate_t, x[3], Pi[3], log_alpha_p0, n_inf[3], frequency;
CHECK(asymptotic_schwarzschild_preroute(&end, 256.0, &hit, &kind,
&activate_t, x, Pi, &log_alpha_p0,
n_inf, &frequency) == 0 &&
kind == SCH_ROUTE_ENTRY,
"near-grazing inside enters");
const double dphi_ref = 1.0389037630217253661;
const double time_ref = 434.0116073725480308524;
const double radius = sqrt(x[0] * x[0] + x[1] * x[1] + x[2] * x[2]);
const double rhat[3] = {x[0] / radius, x[1] / radius, x[2] / radius};
CHECK(fabs(angle_between(hit.rhat, rhat) - dphi_ref) < 1e-8,
"near-grazing entry azimuth");
CHECK(fabs(-activate_t - time_ref) < 1e-7 + 1e-11 * time_ref,
"near-grazing entry time");
SchwarzschildCanonical miss = hit;
miss.beta = beta_R * (1.0 + 1e-12);
CHECK(asymptotic_schwarzschild_preroute(&end, 256.0, &miss, &kind,
&activate_t, x, Pi, &log_alpha_p0,
n_inf, &frequency) == 0 &&
kind == SCH_ROUTE_ESCAPED,
"near-grazing outside misses");
const double n_ref[3] = {-0.86581533530640297059,
-0.50036367289028986187, 0.0};
CHECK(angle_between(n_inf, n_ref) < 1e-8, "near-grazing miss n_inf");
spacetime_destroy(&source);
}
/* Deterministic coverage of the three pre-route branches: past-outward,
* past-inward hit, and past-inward miss (turn before the worldtube). */
static void test_preroute_branches(void) {
SpacetimeSource source = {0};
CHECK(spacetime_create_schwarzschild_ks(&source, 1.0, 256.0, 1.5) == 0,
"create schwarzschild");
SpacetimeAsymptoticEnd end;
CHECK(spacetime_asymptotic_end(&source, 0, &end) == 0, "end");
const double beta_R = 256.0 / sqrt(1.0 - 2.0 / 256.0);
SchwarzschildCanonical base = {.end_id = 0,
.t = 0.0,
.rho = 500.0,
.rhat = {1.0, 0.0, 0.0},
.Lhat = {0.0, 0.0, 1.0},
.beta = 10.0,
.energy = 1.0,
.radial_sign = -1};
SchwarzschildRouteKind kind;
double activate_t, x[3], Pi[3], log_alpha_p0, n_inf[3], frequency;
const double outward_eps = 1e-12;
SchwarzschildCanonical outward = base;
outward.radial_sign = 1;
CHECK(asymptotic_schwarzschild_preroute(&end, 256.0, &outward, &kind,
&activate_t, x, Pi, &log_alpha_p0,
n_inf, &frequency) == 0 &&
kind == SCH_ROUTE_ESCAPED,
"past-outward branch escapes");
CHECK(fabs(sqrt(n_inf[0]*n_inf[0]+n_inf[1]*n_inf[1]+n_inf[2]*n_inf[2]) -
1.0) < outward_eps,
"outward n_inf is unit");
CHECK(fabs(frequency - 1.0) < 1e-12, "outward frequency");
SchwarzschildCanonical hit = base;
CHECK(asymptotic_schwarzschild_preroute(&end, 256.0, &hit, &kind,
&activate_t, x, Pi, &log_alpha_p0,
n_inf, &frequency) == 0 &&
kind == SCH_ROUTE_ENTRY,
"past-inward hit branch enters");
/* Genuine on-boundary tangent: rho = R, beta = beta_R (so Q = 0), zero
* radial past component. It must not enter. */
SchwarzschildCanonical tangent = base;
tangent.rho = 256.0;
tangent.beta = beta_R;
tangent.radial_sign = 0;
CHECK(asymptotic_schwarzschild_preroute(&end, 256.0, &tangent, &kind,
&activate_t, x, Pi, &log_alpha_p0,
n_inf, &frequency) == 0 &&
kind == SCH_ROUTE_ESCAPED,
"on-boundary tangent escapes");
SchwarzschildCanonical miss = base;
miss.beta = beta_R + 5.0;
CHECK(asymptotic_schwarzschild_preroute(&end, 256.0, &miss, &kind,
&activate_t, x, Pi, &log_alpha_p0,
n_inf, &frequency) == 0 &&
kind == SCH_ROUTE_ESCAPED,
"past-inward miss branch escapes");
CHECK(fabs(sqrt(n_inf[0]*n_inf[0]+n_inf[1]*n_inf[1]+n_inf[2]*n_inf[2]) -
1.0) < outward_eps,
"miss n_inf is unit");
/* A turning ray is deflected away from the radial direction. */
CHECK(angle_between(n_inf, miss.rhat) > 1e-3,
"miss n_inf is deflected");
spacetime_destroy(&source);
}
int main(void) {
test_round_trip();
test_finish_matches_integration();
test_preroute_entry();
test_phi_reference_constants();
test_finish_reference_constants();
test_turning_reference();
test_time_reference();
test_grazing_reference();
test_preroute_branches();
if (failures == 0)
puts("asymptotic schwarzschild regression passed");
else
fprintf(stderr, "%d asymptotic schwarzschild failures\n", failures);
return failures == 0 ? 0 : 1;
}
+4 -3
View File
@@ -123,10 +123,11 @@ int main(int argc, char **argv) {
const RayEndpoint ray = geodesic_trace_past(&source, &state, (double[]){1, 0, 0}, &trace);
CHECK(ray.status == RAY_ENDPOINT_ESCAPED);
CHECK(fabs(ray.n_infinity[0] - 1) < 1e-12);
/* Radial ingoing KS photon has k^r=-k^t and conserved E=k^t.
* Current escape convention measures Eulerian energy at finite R=256. */
/* 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 - sqrt(1 + 2.0 / 256) / energy) < 2e-6);
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);