Skip to content

Commit 04303e1

Browse files
Mark Stalzerclaude
andcommitted
Add per-LH adaptive R to Kalman tracker (TE-PROC-038)
Scale each LH observation noise R by its residual relative to the fleet mean, floored at 1.0. Miscalibrated LHs get less Kalman weight without affecting well-calibrated ones. Populates the previously-stub fields light_residuals[lh], lightcap_error_by_lh, and lightcap_count_by_lh. Adds 6 AdaptiveR property tests in residual_cascade_props.c. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1a15f3e commit 04303e1

3 files changed

Lines changed: 215 additions & 5 deletions

File tree

docs/specs/tracking-engine-specs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Prefix: **TE**
3838
- [x] **TE-PROC-035**: The system shall update each lightcap measurement's noise covariance R adaptively from observed residuals: `R_k = 0.3×R_{k-1} + 0.7×(residual² + H×P×H^T)`.
3939
- [x] **TE-PROC-036**: While the tracked object is stationary (IMU variance below `kalman-stationary-*` thresholds), the system shall apply a Zero Velocity Update pseudo-measurement forcing velocity and acceleration toward zero.
4040
- [x] **TE-PROC-037**: When tracking is lost (no valid measurements for the configured timeout period), the system shall reset the Kalman state covariance to reflect high uncertainty.
41+
- [x] **TE-PROC-038**: The system shall scale each lighthouse's observation noise covariance R by the ratio of that lighthouse's EWMA residual to the fleet mean residual, so that lighthouses with above-average residuals receive proportionally less Kalman weight.
4142

4243
## Kalman Lighthouse Tracker
4344

src/survive_kalman_tracker.c

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -448,12 +448,22 @@ void survive_kalman_tracker_integrate_saved_light(SurviveKalmanTracker *tracker,
448448
};
449449

450450
SurviveObject *so = tracker->so;
451-
FLT light_var = tracker->light_var;
452451

453-
SV_DATA_LOG("light_var", &light_var, 1);
452+
// @spec TE-PROC-038
453+
// Adaptive R: scale observation noise by per-LH residual history.
454+
// A lighthouse with residuals above the fleet mean gets a larger R
455+
// (less Kalman weight); equal residuals leave light_var unchanged.
456+
FLT base_var = tracker->light_var;
457+
FLT mean_res = tracker->light_residuals_all;
458+
FLT lh_res = tracker->light_residuals[lh];
459+
FLT lh_var = (mean_res > 0 && lh_res > 0)
460+
? base_var * linmath_max(1.0, lh_res / mean_res)
461+
: base_var;
462+
463+
SV_DATA_LOG("light_var", &lh_var, 1);
454464
FLT light_vars[32] = {0};
455465
for (int i = 0; i < 32; i++)
456-
light_vars[i] = light_var;
466+
light_vars[i] = lh_var;
457467
CnMat R = cnVec(Z.rows, light_vars);
458468

459469
tracker->datalog_tag = "light_data";
@@ -466,16 +476,25 @@ void survive_kalman_tracker_integrate_saved_light(SurviveKalmanTracker *tracker,
466476

467477
tracker->last_light_time = time;
468478

479+
FLT lh_rtn;
469480
if(useJointModel && tracker->joint_lightcap_ratio < ratio) {
470481
tracker->joint_model.ks[0] = &ctx->bsd[lh].tracker->model;
471482
tracker->joint_model.ks[1] = &ctx->bsd[lh].tracker->bsd_model;
472-
rtn += cnkalman_meas_model_predict_update(time, &tracker->joint_model, &cbctx, &Z, &R);
483+
lh_rtn = cnkalman_meas_model_predict_update(time, &tracker->joint_model, &cbctx, &Z, &R);
484+
rtn += lh_rtn;
473485
tracker->stats.joint_model_sensor_cnt_sum += cnt;
474486
survive_kalman_lighthouse_report(ctx->bsd[lh].tracker);
475487
} else {
476-
rtn += cnkalman_meas_model_predict_update(time, &tracker->lightcap_model, &cbctx, &Z, &R);
488+
lh_rtn = cnkalman_meas_model_predict_update(time, &tracker->lightcap_model, &cbctx, &Z, &R);
489+
rtn += lh_rtn;
477490
tracker->stats.lightcap_model_sensor_cnt_sum += cnt;
478491
}
492+
493+
// Update per-LH residual EWMA (feeds adaptive R on subsequent frames)
494+
tracker->light_residuals[lh] *= .9;
495+
tracker->light_residuals[lh] += .1 * lh_rtn;
496+
tracker->stats.lightcap_error_by_lh[lh] += lh_rtn;
497+
tracker->stats.lightcap_count_by_lh[lh]++;
479498
}
480499

481500
tracker->datalog_tag = 0;

src/test_cases/residual_cascade_props.c

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,3 +774,193 @@ TEST(ResidualCascade, SingleOutlierCanTriggerCascade) {
774774
}
775775
return 0;
776776
}
777+
778+
// ── 8. Adaptive R (per-LH light_var scaling) Properties ─────────
779+
//
780+
// These test the invariants of the per-LH adaptive R formula introduced in
781+
// survive_kalman_tracker.c (TE-PROC-040):
782+
//
783+
// lh_var = (mean_res > 0 && lh_res > 0)
784+
// ? base_var * max(1.0, lh_res / mean_res)
785+
// : base_var;
786+
//
787+
// The formula must satisfy:
788+
// 1. lh_var >= base_var always (never reduces below baseline)
789+
// 2. When lh_res == mean_res, lh_var == base_var (equal residuals → no change)
790+
// 3. When lh_res > mean_res, lh_var > base_var (biased LH gets penalized)
791+
// 4. Cold start (mean_res=0 or lh_res=0) → lh_var == base_var (safe fallback)
792+
// 5. lh_var is monotonically non-decreasing in lh_res
793+
794+
static double adaptive_lh_var(double base_var, double mean_res, double lh_res) {
795+
if (mean_res <= 0 || lh_res <= 0)
796+
return base_var;
797+
double ratio = lh_res / mean_res;
798+
return base_var * (ratio > 1.0 ? ratio : 1.0);
799+
}
800+
801+
// Property 1: lh_var is always >= base_var
802+
TEST(AdaptiveR, AlwaysAtLeastBaseVar) {
803+
unsigned seed = (unsigned)time(NULL);
804+
srand(seed);
805+
806+
for (int trial = 0; trial < 10000; trial++) {
807+
double base_var = rand_range(1e-4, 1.0);
808+
double mean_res = rand_range(0.0, 1.0);
809+
double lh_res = rand_range(0.0, 1.0);
810+
811+
double lh_var = adaptive_lh_var(base_var, mean_res, lh_res);
812+
813+
if (lh_var < base_var - 1e-12) {
814+
fprintf(stderr, "AlwaysAtLeastBaseVar FAILED (seed=%u, trial=%d)\n", seed, trial);
815+
fprintf(stderr, " base_var=%.6f mean_res=%.6f lh_res=%.6f -> lh_var=%.6f\n",
816+
base_var, mean_res, lh_res, lh_var);
817+
return -1;
818+
}
819+
}
820+
return 0;
821+
}
822+
823+
// Property 2: When lh_res == mean_res (and both > 0), lh_var == base_var
824+
TEST(AdaptiveR, EqualResidualIsIdentity) {
825+
unsigned seed = (unsigned)time(NULL);
826+
srand(seed);
827+
828+
for (int trial = 0; trial < 10000; trial++) {
829+
double base_var = rand_range(1e-4, 1.0);
830+
double res = rand_range(1e-6, 1.0); // same for both mean and lh
831+
832+
double lh_var = adaptive_lh_var(base_var, res, res);
833+
834+
if (fabs(lh_var - base_var) > 1e-10) {
835+
fprintf(stderr, "EqualResidualIsIdentity FAILED (seed=%u, trial=%d)\n", seed, trial);
836+
fprintf(stderr, " base_var=%.10f res=%.10f -> lh_var=%.10f (diff=%.2e)\n",
837+
base_var, res, lh_var, fabs(lh_var - base_var));
838+
return -1;
839+
}
840+
}
841+
return 0;
842+
}
843+
844+
// Property 3: lh_res > mean_res → lh_var > base_var
845+
TEST(AdaptiveR, HighResidualInflatesVar) {
846+
unsigned seed = (unsigned)time(NULL);
847+
srand(seed);
848+
849+
for (int trial = 0; trial < 10000; trial++) {
850+
double base_var = rand_range(1e-4, 1.0);
851+
double mean_res = rand_range(1e-6, 0.5);
852+
// lh_res strictly greater than mean_res
853+
double lh_res = mean_res + rand_range(1e-6, 0.5);
854+
855+
double lh_var = adaptive_lh_var(base_var, mean_res, lh_res);
856+
857+
if (lh_var <= base_var) {
858+
fprintf(stderr, "HighResidualInflatesVar FAILED (seed=%u, trial=%d)\n", seed, trial);
859+
fprintf(stderr, " base_var=%.6f mean_res=%.6f lh_res=%.6f -> lh_var=%.6f\n",
860+
base_var, mean_res, lh_res, lh_var);
861+
return -1;
862+
}
863+
}
864+
return 0;
865+
}
866+
867+
// Property 4: Cold start (mean_res=0 or lh_res=0) → safe fallback to base_var
868+
TEST(AdaptiveR, ColdStartFallsBackToBaseVar) {
869+
unsigned seed = (unsigned)time(NULL);
870+
srand(seed);
871+
872+
for (int trial = 0; trial < 10000; trial++) {
873+
double base_var = rand_range(1e-4, 1.0);
874+
double other = rand_range(0.0, 1.0);
875+
876+
// Case A: mean_res = 0
877+
double lh_var_a = adaptive_lh_var(base_var, 0.0, other);
878+
if (fabs(lh_var_a - base_var) > 1e-12) {
879+
fprintf(stderr, "ColdStartFallsBackToBaseVar FAILED case A (seed=%u, trial=%d)\n", seed, trial);
880+
fprintf(stderr, " base_var=%.6f mean_res=0.0 lh_res=%.6f -> lh_var=%.6f\n",
881+
base_var, other, lh_var_a);
882+
return -1;
883+
}
884+
885+
// Case B: lh_res = 0
886+
double lh_var_b = adaptive_lh_var(base_var, other, 0.0);
887+
if (fabs(lh_var_b - base_var) > 1e-12) {
888+
fprintf(stderr, "ColdStartFallsBackToBaseVar FAILED case B (seed=%u, trial=%d)\n", seed, trial);
889+
fprintf(stderr, " base_var=%.6f mean_res=%.6f lh_res=0.0 -> lh_var=%.6f\n",
890+
base_var, other, lh_var_b);
891+
return -1;
892+
}
893+
}
894+
return 0;
895+
}
896+
897+
// Property 5: lh_var is monotonically non-decreasing in lh_res
898+
// (more residual error → equal or more variance, never less)
899+
TEST(AdaptiveR, MonotonicInLhResidual) {
900+
unsigned seed = (unsigned)time(NULL);
901+
srand(seed);
902+
903+
for (int trial = 0; trial < 10000; trial++) {
904+
double base_var = rand_range(1e-4, 1.0);
905+
double mean_res = rand_range(1e-6, 0.5);
906+
double lh_res_lo = rand_range(1e-6, 0.5);
907+
double lh_res_hi = lh_res_lo + rand_range(1e-6, 0.5);
908+
909+
double lh_var_lo = adaptive_lh_var(base_var, mean_res, lh_res_lo);
910+
double lh_var_hi = adaptive_lh_var(base_var, mean_res, lh_res_hi);
911+
912+
if (lh_var_hi < lh_var_lo - 1e-12) {
913+
fprintf(stderr, "MonotonicInLhResidual FAILED (seed=%u, trial=%d)\n", seed, trial);
914+
fprintf(stderr, " base_var=%.6f mean_res=%.6f\n", base_var, mean_res);
915+
fprintf(stderr, " lh_res_lo=%.6f -> lh_var=%.6f\n", lh_res_lo, lh_var_lo);
916+
fprintf(stderr, " lh_res_hi=%.6f -> lh_var=%.6f\n", lh_res_hi, lh_var_hi);
917+
return -1;
918+
}
919+
}
920+
return 0;
921+
}
922+
923+
// Property 6: Per-LH EWMA converges independently for each LH slot.
924+
// Each light_residuals[lh] slot uses the same EMA formula as light_residuals_all.
925+
// This test simulates two LHs with different steady-state residuals and verifies
926+
// that each EWMA slot converges to its own value without cross-contamination.
927+
TEST(AdaptiveR, PerLhEWMAConvergesIndependently) {
928+
unsigned seed = (unsigned)time(NULL);
929+
srand(seed);
930+
931+
for (int trial = 0; trial < 1000; trial++) {
932+
double lh0_steady = rand_range(0.001, 0.1);
933+
double lh1_steady = rand_range(0.001, 0.1);
934+
935+
double ema0 = 0.0, ema1 = 0.0;
936+
937+
// Drive both EWMA slots with their respective steady inputs
938+
for (int i = 0; i < 100; i++) {
939+
ema0 = ema_update(ema0, lh0_steady);
940+
ema1 = ema_update(ema1, lh1_steady);
941+
}
942+
943+
// Both should have converged (0.9^100 ≈ 2.7e-5, negligible)
944+
if (fabs(ema0 - lh0_steady) > 1e-3) {
945+
fprintf(stderr, "PerLhEWMAConvergesIndependently FAILED (seed=%u, trial=%d): "
946+
"ema0=%.6f target=%.6f\n", seed, trial, ema0, lh0_steady);
947+
return -1;
948+
}
949+
if (fabs(ema1 - lh1_steady) > 1e-3) {
950+
fprintf(stderr, "PerLhEWMAConvergesIndependently FAILED (seed=%u, trial=%d): "
951+
"ema1=%.6f target=%.6f\n", seed, trial, ema1, lh1_steady);
952+
return -1;
953+
}
954+
955+
// Slots must not influence each other
956+
double expected_ratio = lh1_steady / lh0_steady;
957+
double actual_ratio = ema1 / ema0;
958+
if (fabs(actual_ratio - expected_ratio) > expected_ratio * 0.01 + 1e-6) {
959+
fprintf(stderr, "PerLhEWMAConvergesIndependently FAILED cross-contamination "
960+
"(seed=%u, trial=%d): ratio=%.6f expected=%.6f\n",
961+
seed, trial, actual_ratio, expected_ratio);
962+
return -1;
963+
}
964+
}
965+
return 0;
966+
}

0 commit comments

Comments
 (0)