fix(fusion): harden ESKF GPS failure handling

This commit is contained in:
2026-07-24 12:33:26 +03:00
parent 93766b007f
commit ea055a36d2
7 changed files with 661 additions and 145 deletions
+9 -1
View File
@@ -338,7 +338,8 @@ Bu nedenle sorumluluk iki katmana ayrilir:
olup olmadigini izler ve gerekli recovery/reset kararini burada verir.
Mevcut yalın recovery, son başarılı position fusion'dan sonra yedi saniyelik
timeout dolmuşsa ve en az üç rejected position fix'i kendi arasında zamansal
timeout dolmuşsa, platform harici rest bilgisi ve IMU/GNSS hareket vetolarıyla
duragansa ve en az üç rejected position fix'i kendi arasında zamansal
tutarlıysa yalnız position state ve position covariance'ini GNSS'e yeniden
ankrajlar. Position cross-covariance'lari gecersiz korelasyon tasimamasi icin
sifirlanir; velocity, attitude ve IMU bias state'leri korunur. Hard NIS gate
@@ -569,6 +570,13 @@ make profile
- sekiz ayrı deterministic noise/timing seed'i;
- 30 s boyunca 500 Hz stationary IMU ve gecikmiş 1 Hz GPS.
`gps_failure_scenario_test.c`, gerçek `gps_live_5min_20260713.csv` kaydındaki
3.297 Hz ölçüm hızı, 300 ms medyan periyot, 1.803 s maksimum boşluk ve yaklaşık
8.6 m yatay RMS saçılım ölçeğini kullanır. Normal akışın yanında hareket
sırasında 12 s tam kesinti, yüzde 35 rastgele kayıp ve 1.8 s boşluklar, gecikmiş
ölçümlerin toplu dönüşü ve kesinti sonrasında tutarlı fakat 80/-50/30 m hatalı
üç fix senaryoları regression testidir.
### Sentetik hareketli senaryo
Hareketli senaryo 200 Hz IMU ile 60 s sürer. Ground-truth profil şöyledir:
+13 -3
View File
@@ -10,7 +10,7 @@ IMU_PROFILE_SOURCE := ../../gps_system/src/adapters/imu_fusion_profile.c
.PHONY: all test gps-system-test scenario-logs sanitize profile benchmark clean
all: standalone_gps_integrity_test eskf_motion_test fusion_rest_detector_test fusion_synthetic_test imu_quality_synthetic_test async_tilt_recovery_test async_rate_matrix_test gps_reacquisition_test platform_scenario_matrix_test
all: standalone_gps_integrity_test eskf_motion_test fusion_rest_detector_test fusion_synthetic_test imu_quality_synthetic_test async_tilt_recovery_test async_rate_matrix_test gps_reacquisition_test platform_scenario_matrix_test gps_failure_scenario_test
standalone_gps_integrity_test: standalone_gps_integrity_test.c $(GPS_TIME_SOURCE) $(GPS_EPOCH_SOURCE)
$(CC) $(CFLAGS) $^ -o $@ $(LDLIBS)
@@ -39,7 +39,10 @@ gps_reacquisition_test: gps_reacquisition_test.c $(ESKF_SOURCES)
platform_scenario_matrix_test: platform_scenario_matrix_test.c $(ESKF_SOURCES)
$(CC) $(CFLAGS) $^ -o $@ $(LDLIBS)
test: standalone_gps_integrity_test eskf_motion_test fusion_rest_detector_test fusion_synthetic_test imu_quality_synthetic_test async_tilt_recovery_test async_rate_matrix_test gps_reacquisition_test platform_scenario_matrix_test
gps_failure_scenario_test: gps_failure_scenario_test.c $(ESKF_SOURCES)
$(CC) $(CFLAGS) $^ -o $@ $(LDLIBS)
test: standalone_gps_integrity_test eskf_motion_test fusion_rest_detector_test fusion_synthetic_test imu_quality_synthetic_test async_tilt_recovery_test async_rate_matrix_test gps_reacquisition_test platform_scenario_matrix_test gps_failure_scenario_test
./standalone_gps_integrity_test
./eskf_motion_test
./fusion_rest_detector_test
@@ -49,6 +52,7 @@ test: standalone_gps_integrity_test eskf_motion_test fusion_rest_detector_test f
./async_rate_matrix_test
./gps_reacquisition_test
./platform_scenario_matrix_test
./gps_failure_scenario_test
gps-system-test: standalone_gps_integrity_test
./standalone_gps_integrity_test
@@ -122,6 +126,12 @@ sanitize:
-o platform_scenario_matrix_test_san $(LDLIBS)
ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 \
UBSAN_OPTIONS=halt_on_error=1 ./platform_scenario_matrix_test_san
$(CC) -std=c11 -O1 -g -Wall -Wextra -Wpedantic -Werror \
-fsanitize=address,undefined -fno-omit-frame-pointer \
-I../../gps_system/header gps_failure_scenario_test.c $(ESKF_SOURCES) \
-o gps_failure_scenario_test_san $(LDLIBS)
ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 \
UBSAN_OPTIONS=halt_on_error=1 ./gps_failure_scenario_test_san
profile:
$(CC) -std=c11 -O2 -g -pg -Wall -Wextra -Wpedantic \
@@ -136,4 +146,4 @@ benchmark: standalone_gps_integrity_test
./standalone_gps_integrity_test --bench-double 100000
clean:
rm -f standalone_gps_integrity_test standalone_gps_integrity_test_san standalone_gps_integrity_test_gprof eskf_motion_test eskf_motion_test_san fusion_rest_detector_test fusion_rest_detector_test_san fusion_synthetic_test fusion_synthetic_test_san fusion_synthetic_test_gprof fusion_synthetic_trace imu_quality_synthetic_test imu_quality_synthetic_test_san async_tilt_recovery_test async_tilt_recovery_test_san async_rate_matrix_test async_rate_matrix_test_san gps_reacquisition_test gps_reacquisition_test_san platform_scenario_matrix_test platform_scenario_matrix_test_san gmon.out
rm -f standalone_gps_integrity_test standalone_gps_integrity_test_san standalone_gps_integrity_test_gprof eskf_motion_test eskf_motion_test_san fusion_rest_detector_test fusion_rest_detector_test_san fusion_synthetic_test fusion_synthetic_test_san fusion_synthetic_test_gprof fusion_synthetic_trace imu_quality_synthetic_test imu_quality_synthetic_test_san async_tilt_recovery_test async_tilt_recovery_test_san async_rate_matrix_test async_rate_matrix_test_san gps_reacquisition_test gps_reacquisition_test_san platform_scenario_matrix_test platform_scenario_matrix_test_san gps_failure_scenario_test gps_failure_scenario_test_san gmon.out
@@ -119,6 +119,51 @@ static void test_zero_update_primitives(void)
}
}
static double quaternion_yaw(const double quaternion[4])
{
return atan2(
2.0 * (quaternion[0] * quaternion[3] +
quaternion[1] * quaternion[2]),
1.0 - 2.0 * (quaternion[2] * quaternion[2] +
quaternion[3] * quaternion[3]));
}
static void test_external_rest_cannot_erase_constant_turn(void)
{
fusion_engine_config_t config;
fusion_engine_t engine;
fusion_engine_default_config(&config, 1000000u);
CHECK(fusion_engine_init(&engine, &config),
"constant-turn engine initialization");
for(unsigned int sample = 0u ; sample <= 8u * 52u ; sample++)
{
uint64_t ticks = ((uint64_t)sample * 1000000u + 26u) / 52u;
double time_s = (double)ticks / 1000000.0;
fusion_imu_measurement_t imu;
memset(&imu, 0, sizeof(imu));
imu.timestamp_ticks = ticks;
imu.accel_mps2[2] = config.eskf.gravity_mps2;
imu.gyro_rps[2] =
(time_s >= 3.0) ? 20.0 * TEST_PI / 180.0 : 0.0;
imu.temperature_c = 25.0;
/* Models a stale/incorrect external landed flag. Inertial motion must
* still veto ZUPT and zero-gyro fusion. */
imu.vehicle_at_rest = true;
CHECK(fusion_engine_push_imu(&engine, &imu),
"constant-turn IMU accepted");
}
CHECK(engine.state == FUSION_ENGINE_RUNNING,
"constant-turn engine remains running");
double yaw_deg = quaternion_yaw(
engine.current.filter.body_to_nav_quaternion) * 180.0 / TEST_PI;
CHECK(fabs(yaw_deg - 100.0) < 1.0,
"stale external rest flag cannot erase a real constant turn");
CHECK(fabs(engine.current.filter.gyro_bias_rps[2]) <
0.5 * TEST_PI / 180.0,
"constant angular rate is not learned as gyro bias");
}
static void test_gravity_alignment(void)
{
eskf_t filter;
@@ -747,6 +792,7 @@ int main(void)
{
test_stationary_propagation();
test_zero_update_primitives();
test_external_rest_cannot_erase_constant_turn();
test_gravity_alignment();
test_position_nis_gate();
test_motion_transitions();
@@ -0,0 +1,436 @@
#include "fusion/fusion_engine.h"
#include <math.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#define TIMEBASE_HZ 1000000u
#define IMU_RATE_HZ 52u
#define GPS_PERIOD_TICKS 300000u
#define MAX_GPS_EVENTS 256u
#define PI 3.14159265358979323846
/*
* Timing/noise scale comes from logs/gps_live_5min_20260713.csv:
* 988 fixes in 299.697 s (3.297 Hz), 300 ms median interval, 1.803 s maximum
* interval, and about 8.6 m stationary horizontal RMS scatter. The 6 m
* per-axis covariance below reproduces that horizontal RMS scale.
*/
typedef enum {
SCENARIO_NORMAL = 0,
SCENARIO_MOVING_OUTAGE,
SCENARIO_INTERMITTENT,
SCENARIO_BURST_RETURN,
SCENARIO_BAD_RESTART
} scenario_kind_t;
typedef struct {
fusion_position_measurement_t measurement;
uint64_t delivery_ticks;
} gps_event_t;
typedef struct {
const char* name;
double horizontal_rmse_m;
double maximum_outage_error_m;
double error_at_outage_end_m;
double final_horizontal_error_m;
double maximum_gps_state_jump_m;
double speed_at_outage_start_mps;
double speed_at_outage_end_mps;
double horizontal_sigma_at_outage_end_m;
uint64_t gps_accepted;
uint64_t gps_rejected;
uint64_t gps_recoveries;
uint64_t stale_rejected;
uint64_t replay_count;
unsigned int generated;
unsigned int dropped;
bool finite;
} scenario_metrics_t;
static uint32_t random_state;
static double uniform_random(void)
{
random_state = 1664525u * random_state + 1013904223u;
return ((double)((random_state >> 8) & 0x00ffffffu) + 0.5) /
16777216.0;
}
static double gaussian_random(void)
{
double first = fmax(uniform_random(), 1.0e-12);
double second = uniform_random();
return sqrt(-2.0 * log(first)) * cos(2.0 * PI * second);
}
static void truth_at(double time_s, double position[3],
double velocity[3], double acceleration[3])
{
memset(position, 0, 3u * sizeof(double));
memset(velocity, 0, 3u * sizeof(double));
memset(acceleration, 0, 3u * sizeof(double));
if(time_s < 3.0)
{
return;
}
if(time_s < 5.0)
{
double elapsed = time_s - 3.0;
acceleration[0] = 1.5;
velocity[0] = 1.5 * elapsed;
position[0] = 0.75 * elapsed * elapsed;
return;
}
if(time_s < 50.0)
{
velocity[0] = 3.0;
position[0] = 3.0 + 3.0 * (time_s - 5.0);
return;
}
if(time_s < 52.0)
{
double elapsed = time_s - 50.0;
acceleration[0] = -1.5;
velocity[0] = 3.0 - 1.5 * elapsed;
position[0] = 138.0 + 3.0 * elapsed - 0.75 * elapsed * elapsed;
return;
}
position[0] = 141.0;
}
static fusion_imu_measurement_t make_imu(
uint64_t ticks, unsigned int sequence, double gravity_mps2)
{
double time_s = (double)ticks / (double)TIMEBASE_HZ;
double position[3];
double velocity[3];
double acceleration[3];
truth_at(time_s, position, velocity, acceleration);
(void)position;
(void)velocity;
fusion_imu_measurement_t imu;
memset(&imu, 0, sizeof(imu));
imu.timestamp_ticks = ticks;
imu.accel_mps2[0] = acceleration[0] + 0.030;
imu.accel_mps2[1] = -0.020;
imu.accel_mps2[2] = gravity_mps2 + 0.050;
imu.gyro_rps[0] = 0.0008;
imu.gyro_rps[1] = -0.0005;
imu.gyro_rps[2] = 0.0006;
if(time_s >= 3.0)
{
/* Small temperature-like residual drift after startup calibration. */
imu.accel_mps2[0] += 0.012 * (time_s - 3.0) / 62.0;
}
for(unsigned int axis = 0u ; axis < 3u ; axis++)
{
double phase = 0.37 * (double)sequence + 1.7 * (double)axis;
imu.accel_mps2[axis] += 0.025 * sin(phase);
imu.gyro_rps[axis] += 0.0007 * cos(1.31 * phase);
}
imu.temperature_c = 25.0 + 0.02 * time_s;
imu.sequence = sequence;
imu.vehicle_at_rest = (time_s < 2.9) || (time_s >= 52.5);
return imu;
}
static fusion_position_measurement_t make_gps(
uint64_t capture_ticks, bool bad_restart_fix)
{
double position[3];
double velocity[3];
double acceleration[3];
truth_at((double)capture_ticks / (double)TIMEBASE_HZ,
position, velocity, acceleration);
(void)velocity;
(void)acceleration;
fusion_position_measurement_t gps;
memset(&gps, 0, sizeof(gps));
gps.timestamp_ticks = capture_ticks;
gps.position_enu_m[0] = position[0] + 6.0 * gaussian_random();
gps.position_enu_m[1] = position[1] + 6.0 * gaussian_random();
gps.position_enu_m[2] = position[2] + 10.0 * gaussian_random();
if(bad_restart_fix)
{
gps.position_enu_m[0] += 80.0;
gps.position_enu_m[1] -= 50.0;
gps.position_enu_m[2] += 30.0;
}
gps.covariance_enu_m2[0] = 36.0;
gps.covariance_enu_m2[4] = 36.0;
gps.covariance_enu_m2[8] = 100.0;
gps.source_quality = 80.0;
gps.source_valid = true;
return gps;
}
static bool event_should_drop(scenario_kind_t kind, double capture_s)
{
if((kind == SCENARIO_MOVING_OUTAGE) &&
(capture_s >= 15.0) && (capture_s < 27.0))
{
return true;
}
if((kind == SCENARIO_BAD_RESTART) &&
(capture_s >= 15.0) && (capture_s < 23.1))
{
return true;
}
if(kind == SCENARIO_INTERMITTENT)
{
bool natural_long_gap =
((capture_s >= 12.0) && (capture_s < 13.8)) ||
((capture_s >= 31.0) && (capture_s < 32.8));
return natural_long_gap || (uniform_random() < 0.35);
}
return false;
}
static unsigned int build_events(
scenario_kind_t kind, gps_event_t events[MAX_GPS_EVENTS],
unsigned int* dropped_out)
{
unsigned int count = 0u;
unsigned int dropped = 0u;
uint64_t previous_delivery = 0u;
for(uint64_t capture = 3300000u ; capture <= 64000000u ;
capture += GPS_PERIOD_TICKS)
{
double capture_s = (double)capture / (double)TIMEBASE_HZ;
if(event_should_drop(kind, capture_s))
{
dropped++;
continue;
}
if(count >= MAX_GPS_EVENTS)
{
break;
}
bool bad_restart_fix = (kind == SCENARIO_BAD_RESTART) &&
(capture_s >= 23.1) && (capture_s < 24.0);
events[count].measurement = make_gps(capture, bad_restart_fix);
uint64_t delay = 120000u + (uint64_t)(250000.0 * uniform_random());
uint64_t delivery = capture + delay;
if((kind == SCENARIO_BURST_RETURN) &&
(capture_s >= 15.0) && (capture_s < 20.5))
{
delivery = 20700000u;
}
/* A receiver normally publishes epochs in capture order even when its
* processing latency jitters. Preserve that property here. */
if(delivery < previous_delivery)
{
delivery = previous_delivery;
}
events[count].delivery_ticks = delivery;
previous_delivery = delivery;
count++;
}
*dropped_out = dropped;
return count;
}
static double horizontal_error(const fusion_engine_t* engine, uint64_t ticks)
{
double truth_position[3];
double truth_velocity[3];
double truth_acceleration[3];
truth_at((double)ticks / (double)TIMEBASE_HZ,
truth_position, truth_velocity, truth_acceleration);
(void)truth_velocity;
(void)truth_acceleration;
return hypot(engine->current.filter.position_m[0] - truth_position[0],
engine->current.filter.position_m[1] - truth_position[1]);
}
static scenario_metrics_t run_scenario(
scenario_kind_t kind, const char* name, uint32_t seed)
{
static fusion_engine_t engine;
static gps_event_t events[MAX_GPS_EVENTS];
fusion_engine_config_t config;
scenario_metrics_t metrics;
memset(&metrics, 0, sizeof(metrics));
metrics.name = name;
random_state = seed;
unsigned int dropped = 0u;
unsigned int event_count = build_events(kind, events, &dropped);
metrics.generated = event_count;
metrics.dropped = dropped;
fusion_engine_default_config(&config, TIMEBASE_HZ);
if(!fusion_engine_init(&engine, &config))
{
return metrics;
}
fusion_position_measurement_t origin;
memset(&origin, 0, sizeof(origin));
origin.timestamp_ticks = 1u;
origin.covariance_enu_m2[0] = 36.0;
origin.covariance_enu_m2[4] = 36.0;
origin.covariance_enu_m2[8] = 100.0;
origin.source_quality = 100.0;
origin.source_valid = true;
if(!fusion_engine_push_position(&engine, &origin, NULL, NULL))
{
return metrics;
}
unsigned int event_head = 0u;
double squared_error_sum = 0.0;
unsigned int error_count = 0u;
unsigned int imu_count = 65u * IMU_RATE_HZ;
for(unsigned int sample = 0u ; sample <= imu_count ; sample++)
{
uint64_t ticks =
((uint64_t)sample * TIMEBASE_HZ + IMU_RATE_HZ / 2u) / IMU_RATE_HZ;
fusion_imu_measurement_t imu = make_imu(
ticks, sample, config.eskf.gravity_mps2);
if(!fusion_engine_push_imu(&engine, &imu))
{
return metrics;
}
while((event_head < event_count) &&
(events[event_head].delivery_ticks <= ticks))
{
double before[3];
memcpy(before, engine.current.filter.position_m, sizeof(before));
(void)fusion_engine_push_position(
&engine, &events[event_head].measurement, NULL, NULL);
double jump = hypot(
engine.current.filter.position_m[0] - before[0],
engine.current.filter.position_m[1] - before[1]);
if(jump > metrics.maximum_gps_state_jump_m)
{
metrics.maximum_gps_state_jump_m = jump;
}
event_head++;
}
if(engine.state != FUSION_ENGINE_RUNNING)
{
continue;
}
double time_s = (double)ticks / (double)TIMEBASE_HZ;
double error = horizontal_error(&engine, ticks);
if(time_s >= 3.0)
{
squared_error_sum += error * error;
error_count++;
}
if((time_s >= 15.0) && (time_s <= 27.0) &&
(error > metrics.maximum_outage_error_m))
{
metrics.maximum_outage_error_m = error;
}
if(fabs(time_s - 27.0) < 0.5 / (double)IMU_RATE_HZ)
{
metrics.error_at_outage_end_m = error;
metrics.speed_at_outage_end_mps =
hypot(engine.current.filter.velocity_mps[0],
engine.current.filter.velocity_mps[1]);
metrics.horizontal_sigma_at_outage_end_m = sqrt(
engine.current.filter.covariance[0][0] +
engine.current.filter.covariance[1][1]);
}
if(fabs(time_s - 15.0) < 0.5 / (double)IMU_RATE_HZ)
{
metrics.speed_at_outage_start_mps =
hypot(engine.current.filter.velocity_mps[0],
engine.current.filter.velocity_mps[1]);
}
}
metrics.horizontal_rmse_m =
(error_count != 0u) ? sqrt(squared_error_sum / (double)error_count) : NAN;
metrics.final_horizontal_error_m =
horizontal_error(&engine, 65u * TIMEBASE_HZ);
metrics.gps_accepted = engine.diagnostics.position_accepted;
metrics.gps_rejected = engine.diagnostics.position_nis_rejected;
metrics.gps_recoveries = engine.diagnostics.position_recovery_count;
metrics.stale_rejected = engine.diagnostics.position_stale_rejected;
metrics.replay_count = engine.diagnostics.replay_count;
metrics.finite = (engine.state == FUSION_ENGINE_RUNNING) &&
eskf_state_is_finite(&engine.current.filter) &&
isfinite(metrics.horizontal_rmse_m) &&
isfinite(metrics.final_horizontal_error_m);
return metrics;
}
static void print_metrics(const scenario_metrics_t* metrics)
{
printf("%-18s fixes=%3u drop=%2u accepted/rejected=%3llu/%2llu "
"recovery=%llu stale=%llu rmse=%5.2fm outage-max/end=%5.2f/%5.2fm "
"speed15/27=%4.2f/%4.2f sigma27=%5.1fm jump=%5.2fm final=%5.2fm finite=%u\n",
metrics->name, metrics->generated, metrics->dropped,
(unsigned long long)metrics->gps_accepted,
(unsigned long long)metrics->gps_rejected,
(unsigned long long)metrics->gps_recoveries,
(unsigned long long)metrics->stale_rejected,
metrics->horizontal_rmse_m,
metrics->maximum_outage_error_m,
metrics->error_at_outage_end_m,
metrics->speed_at_outage_start_mps,
metrics->speed_at_outage_end_mps,
metrics->horizontal_sigma_at_outage_end_m,
metrics->maximum_gps_state_jump_m,
metrics->final_horizontal_error_m,
metrics->finite ? 1u : 0u);
}
int main(void)
{
scenario_metrics_t normal = run_scenario(
SCENARIO_NORMAL, "normal", 0x10203040u);
scenario_metrics_t outage = run_scenario(
SCENARIO_MOVING_OUTAGE, "12s-moving-outage", 0x20304050u);
scenario_metrics_t intermittent = run_scenario(
SCENARIO_INTERMITTENT, "intermittent", 0x30405060u);
scenario_metrics_t burst = run_scenario(
SCENARIO_BURST_RETURN, "burst-return", 0x40506070u);
scenario_metrics_t restart = run_scenario(
SCENARIO_BAD_RESTART, "bad-restart", 0x50607080u);
puts("Real-log-scaled GNSS failure scenarios: 3.3 Hz, 6 m horizontal / 10 m vertical sigma");
print_metrics(&normal);
print_metrics(&outage);
print_metrics(&intermittent);
print_metrics(&burst);
print_metrics(&restart);
bool common_ok =
normal.finite && outage.finite && intermittent.finite && burst.finite &&
restart.finite &&
(normal.horizontal_rmse_m < 5.0) &&
/* Position-only dead reckoning is allowed to drift, but the error must
* remain statistically covered and recover once GNSS returns. */
(outage.maximum_outage_error_m <
2.0 * outage.horizontal_sigma_at_outage_end_m) &&
(outage.horizontal_sigma_at_outage_end_m > 10.0) &&
(outage.final_horizontal_error_m < 5.0) &&
(intermittent.final_horizontal_error_m < 6.0) &&
(burst.stale_rejected == 0u) &&
(burst.replay_count > 0u);
bool restart_safe =
(restart.gps_recoveries == 0u) &&
(restart.gps_rejected >= 3u) &&
(restart.maximum_gps_state_jump_m < 50.0) &&
(restart.final_horizontal_error_m < 6.0);
if(!common_ok || !restart_safe)
{
fprintf(stderr,
"FAIL: GNSS outage/reacquisition safety regression "
"(common=%u restart-safe=%u)\n",
common_ok ? 1u : 0u, restart_safe ? 1u : 0u);
return 1;
}
puts("PASS: realistic GNSS loss, intermittent delivery and reacquisition stayed bounded.");
return 0;
}