• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

paulmthompson / WhiskerToolbox / 18389801194

09 Oct 2025 09:35PM UTC coverage: 71.943% (+0.1%) from 71.826%
18389801194

push

github

paulmthompson
add correlation matrix to filtering interface

207 of 337 new or added lines in 5 files covered. (61.42%)

867 existing lines in 31 files now uncovered.

49964 of 69449 relevant lines covered (71.94%)

1103.53 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.63
/src/StateEstimation/Kalman/KalmanFilter.test.cpp
1
#include <Eigen/Dense>
2
#include <catch2/catch_test_macros.hpp>
3
#include <iostream>
4

5
#include "Assignment/HungarianAssigner.hpp"
6
#include "Entity/EntityGroupManager.hpp"
7
#include "Entity/EntityTypes.hpp"
8
#include "Features/IFeatureExtractor.hpp"
9
#include "IdentityConfidence.hpp"
10
#include "Kalman/KalmanFilter.hpp"
11
#include "MinCostFlowTracker.hpp"
12
#include "StateEstimator.hpp"
13
#include "TimeFrame/TimeFrame.hpp"
14
#include "Tracker.hpp"
15

16
// --- Test-Specific Mocks and Implementations ---
17

18
// A simple Line2D struct for this test, mirroring what CoreGeometry might provide.
19
struct TestLine2D {
20
    EntityId id;
21
    Eigen::Vector2d p1;
22
    Eigen::Vector2d p2;
23

24
    Eigen::Vector2d centroid() const {
805✔
25
        return (p1 + p2) / 2.0;
1,610✔
26
    }
27
};
28

29
// A concrete feature extractor for TestLine2D centroids.
30
class LineCentroidExtractor : public StateEstimation::IFeatureExtractor<TestLine2D> {
31
public:
32
    Eigen::VectorXd getFilterFeatures(TestLine2D const & data) const override {
725✔
33
        Eigen::Vector2d c = data.centroid();
725✔
34
        Eigen::VectorXd features(2);
725✔
35
        features << c.x(), c.y();
725✔
36
        return features;
1,450✔
UNCOV
37
    }
×
38

UNCOV
39
    StateEstimation::FeatureCache getAllFeatures(TestLine2D const & data) const override {
×
UNCOV
40
        StateEstimation::FeatureCache cache;
×
41
        cache[getFilterFeatureName()] = getFilterFeatures(data);
×
UNCOV
42
        return cache;
×
UNCOV
43
    }
×
44

UNCOV
45
    std::string getFilterFeatureName() const override {
×
UNCOV
46
        return "kalman_features";
×
47
    }
48

49
    StateEstimation::FilterState getInitialState(TestLine2D const & data) const override {
21✔
50
        Eigen::VectorXd initialState(4);
21✔
51
        Eigen::Vector2d centroid = data.centroid();
21✔
52
        initialState << centroid.x(), centroid.y(), 0, 0;// Position from centroid, velocity is zero
21✔
53

54
        Eigen::MatrixXd p(4, 4);
21✔
55
        p.setIdentity();
21✔
56
        p *= 100.0;// High initial uncertainty
21✔
57

58
        return {initialState, p};
21✔
59
    }
21✔
60

UNCOV
61
    std::unique_ptr<IFeatureExtractor<TestLine2D>> clone() const override {
×
UNCOV
62
        return std::make_unique<LineCentroidExtractor>(*this);
×
63
    }
64

UNCOV
65
    StateEstimation::FeatureMetadata getMetadata() const override {
×
66
        return StateEstimation::FeatureMetadata::create(
67
                "kalman_features",
68
                2,// 2D measurement
UNCOV
69
                StateEstimation::FeatureTemporalType::KINEMATIC_2D);
×
70
    }
71
};
72

73

74
TEST_CASE("StateEstimator - tracking and smoothing", "[StateEstimator][Smoothing]") {
1✔
75
    using namespace StateEstimation;
76

77
    // 1. --- SETUP ---
78

79
    // Define a constant velocity Kalman Filter
80
    double dt = 1.0;
1✔
81
    Eigen::MatrixXd F(4, 4);
1✔
82
    F << 1, 0, dt, 0,
4✔
83
         0, 1, 0, dt,
4✔
84
         0, 0, 1, 0,
5✔
85
         0, 0, 0, 1;
11✔
86

87
    Eigen::MatrixXd H(2, 4);
1✔
88
    H << 1, 0, 0, 0,
5✔
89
         0, 1, 0, 0;
5✔
90

91
    Eigen::MatrixXd Q(4, 4);
1✔
92
    Q.setIdentity();
1✔
93
    Q *= 0.1;
1✔
94

95
    Eigen::MatrixXd R(2, 2);
1✔
96
    R.setIdentity();
1✔
97
    R *= 5.0;
1✔
98

99
    auto kalman_filter = std::make_unique<KalmanFilter>(F, H, Q, R);
1✔
100
    auto feature_extractor = std::make_unique<LineCentroidExtractor>();
1✔
101

102
    // Use StateEstimator for smoothing already-grouped data
103
    StateEstimator<TestLine2D> estimator(std::move(kalman_filter), std::move(feature_extractor));
1✔
104

105
    // --- Generate Artificial Data ---
106
    std::vector<std::tuple<TestLine2D, EntityId, TimeFrameIndex>> data_source;
1✔
107

108
    EntityGroupManager group_manager;
1✔
109
    GroupId group1 = group_manager.createGroup("Group 1");
5✔
110
    GroupId group2 = group_manager.createGroup("Group 2");
5✔
111

112
    for (int i = 0; i <= 10; ++i) {
12✔
113
        // Line 1 moves from (10,10) to (60,60)
114
        TestLine2D line1 = {(EntityId) i, {10.0 + i * 5.0, 10.0 + i * 5.0}, {10.0 + i * 5.0, 10.0 + i * 5.0}};
11✔
115
        // Line 2 moves from (100,50) to (50,50)
116
        TestLine2D line2 = {(EntityId) (i + 100), {100.0 - i * 5.0, 50.0}, {100.0 - i * 5.0, 50.0}};
11✔
117

118
        data_source.emplace_back(line1, (EntityId) i, TimeFrameIndex(i));
11✔
119
        data_source.emplace_back(line2, (EntityId) (i + 100), TimeFrameIndex(i));
11✔
120
        
121
        // Add all entities to their groups
122
        group_manager.addEntityToGroup(group1, (EntityId) i);
11✔
123
        group_manager.addEntityToGroup(group2, (EntityId) (i + 100));
11✔
124
    }
125

126
    // 2. --- EXECUTION ---
127
    SmoothedGroupResults results = estimator.smoothGroups(data_source, group_manager, 
1✔
128
                                                          TimeFrameIndex(0), TimeFrameIndex(10));
1✔
129

130
    // 3. --- ASSERTIONS ---
131
    REQUIRE(results.count(group1) == 1);
1✔
132
    REQUIRE(results.count(group2) == 1);
1✔
133

134
    // We have 11 frames of data (0-10 inclusive) for the forward pass, which get smoothed.
135
    REQUIRE(results.at(group1).size() == 11);
1✔
136
    REQUIRE(results.at(group2).size() == 11);
1✔
137

138
    // Check if the smoothed results are reasonable.
139
    // The smoothed estimate at the midpoint should be close to the true position.
140
    auto const & smoothed_g1 = results.at(group1);
1✔
141
    auto const & smoothed_g2 = results.at(group2);
1✔
142

143
    // True position at frame 5 for line 1 is (10 + 5*5, 10 + 5*5) = (35, 35)
144
    REQUIRE(std::abs(smoothed_g1[5].state_mean(0) - 35.0) < 5.0);
1✔
145
    REQUIRE(std::abs(smoothed_g1[5].state_mean(1) - 35.0) < 5.0);
1✔
146

147
    // True position at frame 5 for line 2 is (100 - 5*5, 50) = (75, 50)
148
    REQUIRE(std::abs(smoothed_g2[5].state_mean(0) - 75.0) < 5.0);
1✔
149
    REQUIRE(std::abs(smoothed_g2[5].state_mean(1) - 50.0) < 5.0);
1✔
150

151
    // Check velocity estimates. Group 1 should be moving (+5, +5)
152
    REQUIRE(smoothed_g1[5].state_mean(2) > 4.0);// vx
1✔
153
    REQUIRE(smoothed_g1[5].state_mean(3) > 4.0);// vy
1✔
154

155
    // Group 2 should be moving (-5, 0)
156
    REQUIRE(smoothed_g2[5].state_mean(2) < -4.0);         // vx
1✔
157
    REQUIRE(std::abs(smoothed_g2[5].state_mean(3)) < 1.0);// vy
1✔
158
}
2✔
159

160
TEST_CASE("StateEstimator smoothing and outlier detection", "[StateEstimator]") {
1✔
161
    using namespace StateEstimation;
162

163
    // 1. --- SETUP ---
164

165
    // Define a constant velocity Kalman Filter
166
    double dt = 1.0;
1✔
167
    Eigen::MatrixXd F(4, 4);
1✔
168
    F << 1, 0, dt, 0,
4✔
169
         0, 1, 0, dt,
4✔
170
         0, 0, 1, 0,
5✔
171
         0, 0, 0, 1;
11✔
172

173
    Eigen::MatrixXd H(2, 4);
1✔
174
    H << 1, 0, 0, 0,
5✔
175
         0, 1, 0, 0;
5✔
176

177
    Eigen::MatrixXd Q(4, 4);
1✔
178
    Q.setIdentity();
1✔
179
    Q *= 0.1;
1✔
180

181
    Eigen::MatrixXd R(2, 2);
1✔
182
    R.setIdentity();
1✔
183
    R *= 5.0;
1✔
184

185
    auto kalman_filter = std::make_unique<KalmanFilter>(F, H, Q, R);
1✔
186
    auto feature_extractor = std::make_unique<LineCentroidExtractor>();
1✔
187

188
    // Create StateEstimator (separate from MinCostFlowTracker)
189
    StateEstimator<TestLine2D> estimator(std::move(kalman_filter), std::move(feature_extractor));
1✔
190

191
    // 2. --- CREATE TEST DATA ---
192
    EntityGroupManager group_manager;
1✔
193
    GroupId group1 = group_manager.createGroup("Group1");
5✔
194
    GroupId group2 = group_manager.createGroup("Group2");
5✔
195

196
    auto makeA = [](int frame, double x, double y) {
1✔
197
        TestLine2D line;
11✔
198
        line.p1 = Eigen::Vector2d(x - 1.0, y);
11✔
199
        line.p2 = Eigen::Vector2d(x + 1.0, y);
11✔
200
        return line;
11✔
201
    };
202

203
    auto makeB = [](int frame, double x, double y) {
1✔
204
        TestLine2D line;
11✔
205
        line.p1 = Eigen::Vector2d(x - 1.0, y);
11✔
206
        line.p2 = Eigen::Vector2d(x + 1.0, y);
11✔
207
        return line;
11✔
208
    };
209

210
    std::vector<std::tuple<TestLine2D, EntityId, TimeFrameIndex>> data_source;
1✔
211

212
    // Create two tracks moving in opposite directions
213
    // Group 1: Moving right (x increases) - but skip frame 5 initially
214
    for (int i = 0; i <= 10; ++i) {
12✔
215
        if (i == 5) continue;  // Skip frame 5, we'll add an outlier there
11✔
216
        EntityId entity_id = 1000 + i;
10✔
217
        data_source.emplace_back(makeA(i, 10.0 + i * 2.0, 10.0), entity_id, TimeFrameIndex(i));
10✔
218
        group_manager.addEntityToGroup(group1, entity_id);
10✔
219
    }
220

221
    // Group 2: Moving left (x decreases)
222
    for (int i = 0; i <= 10; ++i) {
12✔
223
        EntityId entity_id = 2000 + i;
11✔
224
        data_source.emplace_back(makeB(i, 50.0 - i * 2.0, 10.0), entity_id, TimeFrameIndex(i));
11✔
225
        group_manager.addEntityToGroup(group2, entity_id);
11✔
226
    }
227

228
    // Add an outlier at frame 5 for group 1 (large deviation from expected trajectory)
229
    EntityId outlier_id = 1005;
1✔
230
    data_source.emplace_back(makeA(5, 100.0, 10.0), outlier_id, TimeFrameIndex(5));  // x=100 instead of ~20
1✔
231
    group_manager.addEntityToGroup(group1, outlier_id);
1✔
232

233
    // 3. --- TEST SMOOTHING ---
234
    auto smoothed_results = estimator.smoothGroups(data_source, group_manager, 
1✔
235
                                                   TimeFrameIndex(0), TimeFrameIndex(10));
1✔
236

237
    REQUIRE(smoothed_results.size() == 2);  // Two groups
1✔
238
    REQUIRE(smoothed_results.count(group1) > 0);
1✔
239
    REQUIRE(smoothed_results.count(group2) > 0);
1✔
240

241
    // Check that we got smoothed states for both groups
242
    auto const & group1_states = smoothed_results.at(group1);
1✔
243
    auto const & group2_states = smoothed_results.at(group2);
1✔
244
    
245
    REQUIRE(group1_states.size() > 0);
1✔
246
    REQUIRE(group2_states.size() > 0);
1✔
247

248
    // 4. --- TEST OUTLIER DETECTION ---
249
    auto outlier_results = estimator.detectOutliers(data_source, group_manager,
1✔
250
                                                    TimeFrameIndex(0), TimeFrameIndex(10),
251
                                                    2.0);  // 2-sigma threshold
1✔
252

253
    // Should detect the outlier we added
254
    REQUIRE(outlier_results.outliers.size() > 0);
1✔
255
    
256
    // Check that statistics were computed
257
    REQUIRE(outlier_results.mean_innovation.count(group1) > 0);
1✔
258
    REQUIRE(outlier_results.std_innovation.count(group1) > 0);
1✔
259
    REQUIRE(outlier_results.innovation_magnitudes.count(group1) > 0);
1✔
260

261
    // The outlier should be in the results
262
    bool found_outlier = false;
1✔
263
    for (auto const & outlier : outlier_results.outliers) {
4✔
264
        if (outlier.entity_id == outlier_id) {
3✔
265
            found_outlier = true;
1✔
266
            REQUIRE(outlier.group_id == group1);
1✔
267
            REQUIRE(outlier.frame == TimeFrameIndex(5));
1✔
268
            REQUIRE(outlier.innovation_magnitude > outlier.threshold_used);
1✔
269
        }
270
    }
271
    REQUIRE(found_outlier);
1✔
272
}
2✔
273

274
TEST_CASE("StateEstimator - multiple outliers with large jumps", "[StateEstimator][Outliers][Comprehensive]") {
1✔
275
    using namespace StateEstimation;
276

277
    // 1. --- SETUP ---
278
    double dt = 1.0;
1✔
279
    Eigen::MatrixXd F(4, 4);
1✔
280
    F << 1, 0, dt, 0,
4✔
281
         0, 1, 0, dt,
4✔
282
         0, 0, 1, 0,
5✔
283
         0, 0, 0, 1;
11✔
284

285
    Eigen::MatrixXd H(2, 4);
1✔
286
    H << 1, 0, 0, 0,
5✔
287
         0, 1, 0, 0;
5✔
288

289
    Eigen::MatrixXd Q(4, 4);
1✔
290
    Q.setIdentity();
1✔
291
    Q *= 0.1;
1✔
292

293
    Eigen::MatrixXd R(2, 2);
1✔
294
    R.setIdentity();
1✔
295
    R *= 0.5;  // Reduced measurement noise to make outliers more obvious
1✔
296

297
    auto kalman_filter = std::make_unique<KalmanFilter>(F, H, Q, R);
1✔
298
    auto feature_extractor = std::make_unique<LineCentroidExtractor>();
1✔
299

300
    StateEstimator<TestLine2D> estimator(std::move(kalman_filter), std::move(feature_extractor));
1✔
301

302
    // 2. --- CREATE TEST DATA WITH MULTIPLE OUTLIERS ---
303
    EntityGroupManager group_manager;
1✔
304
    GroupId track1 = group_manager.createGroup("Track1");
5✔
305
    GroupId track2 = group_manager.createGroup("Track2");
5✔
306

307
    auto makeLine = [](int frame, double x, double y, EntityId id) {
1✔
308
        TestLine2D line;
62✔
309
        line.id = id;
62✔
310
        line.p1 = Eigen::Vector2d(x - 1.0, y);
62✔
311
        line.p2 = Eigen::Vector2d(x + 1.0, y);
62✔
312
        return line;
62✔
313
    };
314

315
    std::vector<std::tuple<TestLine2D, EntityId, TimeFrameIndex>> data_source;
1✔
316

317
    // Track 1: Moving smoothly from (0,0) to (30,30) with THREE large error jumps
318
    std::vector<EntityId> track1_outlier_ids;
1✔
319
    for (int i = 0; i <= 30; ++i) {
32✔
320
        EntityId eid = 1000 + i;
31✔
321
        double x = i * 1.0;
31✔
322
        double y = i * 1.0;
31✔
323
        
324
        // Inject VERY large jumps representing calculation errors
325
        if (i == 8) {
31✔
326
            x += 35.0;  // Very large error in x
1✔
327
            y += 30.0;  // Very large error in y
1✔
328
            track1_outlier_ids.push_back(eid);
1✔
329
        } else if (i == 16) {
30✔
330
            x -= 32.0;  // Very large negative error
1✔
331
            y += 28.0;
1✔
332
            track1_outlier_ids.push_back(eid);
1✔
333
        } else if (i == 24) {
29✔
334
            x += 40.0;  // Another very large error
1✔
335
            y -= 25.0;
1✔
336
            track1_outlier_ids.push_back(eid);
1✔
337
        }
338
        
339
        TestLine2D line = makeLine(i, x, y, eid);
31✔
340
        data_source.emplace_back(line, eid, TimeFrameIndex(i));
31✔
341
        group_manager.addEntityToGroup(track1, eid);
31✔
342
    }
343

344
    // Track 2: Moving from (50,10) to (50,40) with TWO outliers
345
    std::vector<EntityId> track2_outlier_ids;
1✔
346
    for (int i = 0; i <= 30; ++i) {
32✔
347
        EntityId eid = 2000 + i;
31✔
348
        double x = 50.0;
31✔
349
        double y = 10.0 + i * 1.0;
31✔
350
        
351
        // Inject very large errors
352
        if (i == 10) {
31✔
353
            x += 45.0;  // Huge horizontal error
1✔
354
            track2_outlier_ids.push_back(eid);
1✔
355
        } else if (i == 22) {
30✔
356
            y += 35.0;  // Huge vertical error
1✔
357
            track2_outlier_ids.push_back(eid);
1✔
358
        }
359
        
360
        TestLine2D line = makeLine(i, x, y, eid);
31✔
361
        data_source.emplace_back(line, eid, TimeFrameIndex(i));
31✔
362
        group_manager.addEntityToGroup(track2, eid);
31✔
363
    }
364

365
    // 3. --- TEST OUTLIER DETECTION WITH 3-SIGMA THRESHOLD ---
366
    INFO("Testing with 3-sigma threshold (should catch major outliers)");
1✔
367
    auto results_3sigma = estimator.detectOutliers(
1✔
368
        data_source, group_manager,
369
        TimeFrameIndex(0), TimeFrameIndex(30),
370
        3.0  // 3-sigma threshold
371
    );
1✔
372

373
    // Print statistics for debugging
374
    INFO("3-sigma results: " << results_3sigma.outliers.size() << " outliers detected");
1✔
375
    if (results_3sigma.mean_innovation.count(track1) > 0) {
1✔
376
        INFO("Track1 mean innovation: " << results_3sigma.mean_innovation[track1]);
1✔
377
        INFO("Track1 std deviation: " << results_3sigma.std_innovation[track1]);
1✔
378
    }
1✔
379
    if (results_3sigma.mean_innovation.count(track2) > 0) {
1✔
380
        INFO("Track2 mean innovation: " << results_3sigma.mean_innovation[track2]);
1✔
381
        INFO("Track2 std deviation: " << results_3sigma.std_innovation[track2]);
1✔
382
    }
1✔
383
    
384
    // Verify statistics were computed for both tracks
385
    REQUIRE(results_3sigma.mean_innovation.count(track1) > 0);
1✔
386
    REQUIRE(results_3sigma.std_innovation.count(track1) > 0);
1✔
387
    REQUIRE(results_3sigma.mean_innovation.count(track2) > 0);
1✔
388
    REQUIRE(results_3sigma.std_innovation.count(track2) > 0);
1✔
389

390
    // Collect detected outlier IDs and print info
391
    std::set<EntityId> detected_3sigma;
1✔
392
    for (auto const & outlier : results_3sigma.outliers) {
13✔
393
        detected_3sigma.insert(outlier.entity_id);
12✔
394
        
395
        // Verify each outlier exceeds its threshold
396
        REQUIRE(outlier.innovation_magnitude > outlier.threshold_used);
12✔
397
        INFO("Outlier EntityID: " << outlier.entity_id 
12✔
398
             << " at frame " << outlier.frame.getValue()
399
             << " with magnitude " << outlier.innovation_magnitude
400
             << " (threshold: " << outlier.threshold_used << ")");
401
    }
12✔
402

403
    // We injected 5 major outliers, but 3-sigma is conservative
404
    // so we require at least 3 to be detected (the largest ones)
405
    REQUIRE(results_3sigma.outliers.size() >= 3);
1✔
406
    
407
    // Count how many of our injected outliers were found
408
    int track1_found = 0;
1✔
409
    for (EntityId eid : track1_outlier_ids) {
4✔
410
        if (detected_3sigma.count(eid) > 0) {
3✔
411
            track1_found++;
3✔
412
            INFO("Track1 outlier EntityID " << eid << " was detected");
3✔
413
        } else {
3✔
UNCOV
414
            INFO("Track1 outlier EntityID " << eid << " was NOT detected");
×
UNCOV
415
        }
×
416
    }
417
    int track2_found = 0;
1✔
418
    for (EntityId eid : track2_outlier_ids) {
3✔
419
        if (detected_3sigma.count(eid) > 0) {
2✔
420
            track2_found++;
2✔
421
            INFO("Track2 outlier EntityID " << eid << " was detected");
2✔
422
        } else {
2✔
UNCOV
423
            INFO("Track2 outlier EntityID " << eid << " was NOT detected");
×
UNCOV
424
        }
×
425
    }
426
    
427
    // With 3-sigma, we should find at least 2 of our 5 injected outliers
428
    REQUIRE((track1_found + track2_found) >= 2);
1✔
429

430
    // 4. --- TEST WITH TIGHTER 2-SIGMA THRESHOLD ---
431
    INFO("Testing with 2-sigma threshold (should catch more outliers)");
1✔
432
    auto results_2sigma = estimator.detectOutliers(
1✔
433
        data_source, group_manager,
434
        TimeFrameIndex(0), TimeFrameIndex(30),
435
        2.0  // Tighter threshold
436
    );
1✔
437

438
    INFO("2-sigma results: " << results_2sigma.outliers.size() << " outliers detected");
1✔
439
    
440
    // Tighter threshold should detect at least as many outliers
441
    REQUIRE(results_2sigma.outliers.size() >= results_3sigma.outliers.size());
1✔
442

443
    std::set<EntityId> detected_2sigma;
1✔
444
    for (auto const & outlier : results_2sigma.outliers) {
16✔
445
        detected_2sigma.insert(outlier.entity_id);
15✔
446
        INFO("2-sigma Outlier EntityID: " << outlier.entity_id 
15✔
447
             << " at frame " << outlier.frame.getValue()
448
             << " magnitude: " << outlier.innovation_magnitude);
449
    }
15✔
450

451
    // All 3-sigma outliers should also be in 2-sigma results
452
    for (EntityId eid : detected_3sigma) {
13✔
453
        INFO("EntityID " << eid << " from 3-sigma should also be in 2-sigma");
12✔
454
        REQUIRE(detected_2sigma.count(eid) > 0);
12✔
455
    }
12✔
456

457
    // Report how many additional outliers were found
458
    size_t additional_outliers = results_2sigma.outliers.size() - results_3sigma.outliers.size();
1✔
459
    INFO("2-sigma threshold found " << additional_outliers << " additional outliers beyond 3-sigma");
1✔
460
    INFO("Total outliers with 2-sigma: " << results_2sigma.outliers.size());
1✔
461
    INFO("Total outliers with 3-sigma: " << results_3sigma.outliers.size());
1✔
462

463
    // Should find at least 4 of our 5 injected outliers with 2-sigma
464
    int total_2sigma_found = 0;
1✔
465
    for (EntityId eid : track1_outlier_ids) {
4✔
466
        if (detected_2sigma.count(eid) > 0) total_2sigma_found++;
3✔
467
    }
468
    for (EntityId eid : track2_outlier_ids) {
3✔
469
        if (detected_2sigma.count(eid) > 0) total_2sigma_found++;
2✔
470
    }
471
    INFO("2-sigma found " << total_2sigma_found << " of 5 injected outliers");
1✔
472
    REQUIRE(total_2sigma_found >= 4);
1✔
473

474
    // 5. --- TEST WITH EVEN TIGHTER 1.5-SIGMA THRESHOLD ---
475
    INFO("Testing with 1.5-sigma threshold (should catch even more)");
1✔
476
    auto results_1_5sigma = estimator.detectOutliers(
1✔
477
        data_source, group_manager,
478
        TimeFrameIndex(0), TimeFrameIndex(30),
479
        1.5  // Even tighter threshold
480
    );
1✔
481

482
    INFO("1.5-sigma results: " << results_1_5sigma.outliers.size() << " outliers detected");
1✔
483
    
484
    // Should find at least as many as 2-sigma
485
    REQUIRE(results_1_5sigma.outliers.size() >= results_2sigma.outliers.size());
1✔
486

487
    std::set<EntityId> detected_1_5sigma;
1✔
488
    for (auto const & outlier : results_1_5sigma.outliers) {
19✔
489
        detected_1_5sigma.insert(outlier.entity_id);
18✔
490
    }
491
    
492
    // Should find ALL 5 of our injected outliers with 1.5-sigma
493
    int total_1_5sigma_found = 0;
1✔
494
    for (EntityId eid : track1_outlier_ids) {
4✔
495
        if (detected_1_5sigma.count(eid) > 0) total_1_5sigma_found++;
3✔
496
    }
497
    for (EntityId eid : track2_outlier_ids) {
3✔
498
        if (detected_1_5sigma.count(eid) > 0) total_1_5sigma_found++;
2✔
499
    }
500
    INFO("1.5-sigma found " << total_1_5sigma_found << " of 5 injected outliers");
1✔
501
    REQUIRE(total_1_5sigma_found == 5);
1✔
502
    
503
    // Verify the hierarchy: 1.5-sigma >= 2-sigma >= 3-sigma
504
    REQUIRE(results_1_5sigma.outliers.size() >= results_2sigma.outliers.size());
1✔
505
    REQUIRE(results_2sigma.outliers.size() >= results_3sigma.outliers.size());
1✔
506
}
2✔
507

508
TEST_CASE("StateEstimator - outlier detection with varying error magnitudes", "[StateEstimator][Outliers][Thresholds]") {
1✔
509
    using namespace StateEstimation;
510

511
    // Setup with lower measurement noise for better sensitivity
512
    double dt = 1.0;
1✔
513
    Eigen::MatrixXd F(4, 4);
1✔
514
    F << 1, 0, dt, 0, 0, 1, 0, dt, 0, 0, 1, 0, 0, 0, 0, 1;
1✔
515
    Eigen::MatrixXd H(2, 4);
1✔
516
    H << 1, 0, 0, 0, 0, 1, 0, 0;
1✔
517
    Eigen::MatrixXd Q = Eigen::MatrixXd::Identity(4, 4) * 0.05;
1✔
518
    Eigen::MatrixXd R = Eigen::MatrixXd::Identity(2, 2) * 0.3;  // Lower noise for better sensitivity
1✔
519

520
    StateEstimator<TestLine2D> estimator(
1✔
521
        std::make_unique<KalmanFilter>(F, H, Q, R),
2✔
522
        std::make_unique<LineCentroidExtractor>()
2✔
523
    );
5✔
524

525
    // Create track with small, medium, and large errors
526
    std::vector<std::tuple<TestLine2D, EntityId, TimeFrameIndex>> data_source;
1✔
527
    EntityGroupManager group_manager;
1✔
528
    GroupId group = group_manager.createGroup("VaryingErrors");
5✔
529

530
    std::map<std::string, std::vector<EntityId>> error_categories = {
1✔
531
        {"small", {}},
532
        {"medium", {}},
533
        {"large", {}}
534
    };
16✔
535

536
    // Create a smooth trajectory with injected errors of varying magnitude
537
    for (int i = 0; i <= 40; ++i) {
42✔
538
        EntityId eid = 3000 + i;
41✔
539
        double x = i * 1.0;
41✔
540
        double y = i * 0.5;
41✔
541
        
542
        // Inject errors of different magnitudes - make them more distinct
543
        if (i == 10) {
41✔
544
            x += 8.0;  // Small-ish error
1✔
545
            error_categories["small"].push_back(eid);
3✔
546
        } else if (i == 20) {
40✔
547
            x += 20.0;  // Medium error
1✔
548
            error_categories["medium"].push_back(eid);
3✔
549
        } else if (i == 30) {
39✔
550
            x += 40.0;  // Large error
1✔
551
            error_categories["large"].push_back(eid);
3✔
552
        }
553
        
554
        TestLine2D line;
41✔
555
        line.id = eid;
41✔
556
        line.p1 = Eigen::Vector2d(x - 0.5, y);
41✔
557
        line.p2 = Eigen::Vector2d(x + 0.5, y);
41✔
558
        
559
        data_source.emplace_back(line, eid, TimeFrameIndex(i));
41✔
560
        group_manager.addEntityToGroup(group, eid);
41✔
561
    }
562

563
    // Test multiple thresholds and verify detection hierarchy
564
    auto results_3sigma = estimator.detectOutliers(data_source, group_manager, 
1✔
565
                                                   TimeFrameIndex(0), TimeFrameIndex(40), 3.0);
1✔
566
    auto results_2sigma = estimator.detectOutliers(data_source, group_manager, 
1✔
567
                                                   TimeFrameIndex(0), TimeFrameIndex(40), 2.0);
1✔
568
    auto results_1sigma = estimator.detectOutliers(data_source, group_manager, 
1✔
569
                                                   TimeFrameIndex(0), TimeFrameIndex(40), 1.0);
1✔
570

571
    INFO("3-sigma found " << results_3sigma.outliers.size() << " outliers");
1✔
572
    INFO("2-sigma found " << results_2sigma.outliers.size() << " outliers");
1✔
573
    INFO("1-sigma found " << results_1sigma.outliers.size() << " outliers");
1✔
574

575
    // Get detected IDs for each threshold
576
    std::set<EntityId> ids_3sigma, ids_2sigma, ids_1sigma;
1✔
577
    for (auto const & o : results_3sigma.outliers) {
9✔
578
        ids_3sigma.insert(o.entity_id);
8✔
579
        INFO("3-sigma detected EntityID " << o.entity_id << " at frame " << o.frame.getValue());
8✔
580
    }
8✔
581
    for (auto const & o : results_2sigma.outliers) {
11✔
582
        ids_2sigma.insert(o.entity_id);
10✔
583
        INFO("2-sigma detected EntityID " << o.entity_id << " at frame " << o.frame.getValue());
10✔
584
    }
10✔
585
    for (auto const & o : results_1sigma.outliers) {
15✔
586
        ids_1sigma.insert(o.entity_id);
14✔
587
    }
588

589
    // 3-sigma should catch at least the large error
590
    REQUIRE(results_3sigma.outliers.size() >= 1);
1✔
591
    
592
    // 2-sigma should catch more (large + medium)
593
    REQUIRE(results_2sigma.outliers.size() >= results_3sigma.outliers.size());
1✔
594
    REQUIRE(results_2sigma.outliers.size() >= 2);
1✔
595
    
596
    // 1-sigma should catch all errors
597
    REQUIRE(results_1sigma.outliers.size() >= results_2sigma.outliers.size());
1✔
598
    REQUIRE(results_1sigma.outliers.size() >= 3);
1✔
599
    
600
    // Large error should be in all results
601
    for (EntityId eid : error_categories["large"]) {
4✔
602
        INFO("Checking large error EntityID " << eid);
1✔
603
        REQUIRE(ids_3sigma.count(eid) > 0);
1✔
604
        REQUIRE(ids_2sigma.count(eid) > 0);
1✔
605
        REQUIRE(ids_1sigma.count(eid) > 0);
1✔
606
    }
1✔
607
    
608
    // Medium error should be in 2-sigma and 1-sigma
609
    for (EntityId eid : error_categories["medium"]) {
4✔
610
        INFO("Checking medium error EntityID " << eid);
1✔
611
        REQUIRE(ids_2sigma.count(eid) > 0);
1✔
612
        REQUIRE(ids_1sigma.count(eid) > 0);
1✔
613
    }
1✔
614
    
615
    // Small error should be in 1-sigma
616
    for (EntityId eid : error_categories["small"]) {
4✔
617
        INFO("Checking small error EntityID " << eid);
1✔
618
        REQUIRE(ids_1sigma.count(eid) > 0);
1✔
619
    }
1✔
620
    
621
    // Verify proper subset relationship: 3σ ⊆ 2σ ⊆ 1σ
622
    for (EntityId eid : ids_3sigma) {
9✔
623
        REQUIRE(ids_2sigma.count(eid) > 0);
8✔
624
        REQUIRE(ids_1sigma.count(eid) > 0);
8✔
625
    }
626
    for (EntityId eid : ids_2sigma) {
11✔
627
        REQUIRE(ids_1sigma.count(eid) > 0);
10✔
628
    }
629
}
11✔
630

631
TEST_CASE("StateEstimation - MinCostFlowTracker - blackout crossing", "[MinCostFlowTracker][FlipBlackout]") {
1✔
632
    using namespace StateEstimation;
633

634
    // 1. --- SETUP ---
635
    // This setup is identical to the failing test for the old tracker.
636
    double dt = 1.0;
1✔
637
    Eigen::MatrixXd F(4, 4);
1✔
638
    F << 1, 0, dt, 0, 0, 1, 0, dt, 0, 0, 1, 0, 0, 0, 0, 1;
1✔
639
    Eigen::MatrixXd H(2, 4);
1✔
640
    H << 1, 0, 0, 0, 0, 1, 0, 0;
1✔
641
    Eigen::MatrixXd Q(4, 4);
1✔
642
    Q.setIdentity();
1✔
643
    Q *= 0.1;
1✔
644
    Eigen::MatrixXd R(2, 2);
1✔
645
    R.setIdentity();
1✔
646
    R *= 5.0;
1✔
647

648
    auto kalman_filter = std::make_unique<KalmanFilter>(F, H, Q, R);
1✔
649
    auto feature_extractor = std::make_unique<LineCentroidExtractor>();
1✔
650

651
    // Instantiate the new MinCostFlowTracker
652
    // No max_gap_frames needed - filter uncertainty naturally handles long gaps
653
    MinCostFlowTracker<TestLine2D> tracker(std::move(kalman_filter), std::move(feature_extractor), H, R);
1✔
654
    tracker.enableDebugLogging("mcf_tracker_blackout_crossing.log");
3✔
655

656
    // --- Generate Data with blackout and crossing ---
657
    std::vector<std::tuple<TestLine2D, EntityId, TimeFrameIndex>> data_source;
1✔
658
    EntityGroupManager group_manager;
1✔
659
    GroupId group1 = group_manager.createGroup("Group 1");
5✔
660
    GroupId group2 = group_manager.createGroup("Group 2");
5✔
661

662
    auto makeA = [](int frame, double x, double y) {
1✔
663
        return TestLine2D{(EntityId)(1000 + frame), {x, y}, {x, y}};
7✔
664
    };
665
    auto makeB = [](int frame, double x, double y) {
1✔
666
        return TestLine2D{(EntityId)(2000 + frame), {x, y}, {x, y}};
7✔
667
    };
668

669
    // Frame 0: Ground truth anchors
670
    data_source.emplace_back(makeA(0, 10.0, 10.0), (EntityId)1000, TimeFrameIndex(0));
1✔
671
    data_source.emplace_back(makeB(0, 90.0, 10.0), (EntityId)2000, TimeFrameIndex(0));
1✔
672

673
    MinCostFlowTracker<TestLine2D>::GroundTruthMap ground_truth;
1✔
674
    ground_truth[TimeFrameIndex(0)] = {{group1, (EntityId)1000}, {group2, (EntityId)2000}};
1✔
675

676
    // Frames 1-2: Lines move toward each other
677
    data_source.emplace_back(makeA(1, 15.0, 10.0), (EntityId)1001, TimeFrameIndex(1));
1✔
678
    data_source.emplace_back(makeB(1, 85.0, 10.0), (EntityId)2001, TimeFrameIndex(1));
1✔
679
    data_source.emplace_back(makeA(2, 20.0, 10.0), (EntityId)1002, TimeFrameIndex(2));
1✔
680
    data_source.emplace_back(makeB(2, 80.0, 10.0), (EntityId)2002, TimeFrameIndex(2));
1✔
681

682
    // Frames 3-7: BLACKOUT
683

684
    // Frame 8: Post-blackout, ambiguous observations
685
    data_source.emplace_back(makeA(8, 52.0, 10.0), (EntityId)1008, TimeFrameIndex(8));
1✔
686
    data_source.emplace_back(makeB(8, 48.0, 10.0), (EntityId)2008, TimeFrameIndex(8));
1✔
687

688
    // Frames 9-10: Lines continue moving
689
    data_source.emplace_back(makeA(9, 54.0, 10.0), (EntityId)1009, TimeFrameIndex(9));
1✔
690
    data_source.emplace_back(makeB(9, 49.0, 10.0), (EntityId)2009, TimeFrameIndex(9));
1✔
691
    data_source.emplace_back(makeA(10, 56.0, 10.0), (EntityId)1010, TimeFrameIndex(10));
1✔
692
    data_source.emplace_back(makeB(10, 50.0, 10.0), (EntityId)2010, TimeFrameIndex(10));
1✔
693

694
    // Frame 11: Final ground truth anchor
695
    data_source.emplace_back(makeA(11, 58.0, 10.0), (EntityId)1011, TimeFrameIndex(11));
1✔
696
    data_source.emplace_back(makeB(11, 51.0, 10.0), (EntityId)2011, TimeFrameIndex(11));
1✔
697
    ground_truth[TimeFrameIndex(11)] = {{group1, (EntityId)1011}, {group2, (EntityId)2011}};
1✔
698

699
    // 2. --- EXECUTION ---
700
    tracker.process(data_source, group_manager, ground_truth, TimeFrameIndex(0), TimeFrameIndex(11));
1✔
701

702
    // 3. --- ASSERTIONS ---
703
    REQUIRE(group_manager.hasGroup(group1));
1✔
704
    REQUIRE(group_manager.hasGroup(group2));
1✔
705

706
    // The MinCostFlowTracker should correctly assign all entities despite the blackout ambiguity.
707
    std::vector<EntityId> expected_g1 = {1000, 1001, 1002, 1008, 1009, 1010, 1011};
3✔
708
    std::vector<EntityId> expected_g2 = {2000, 2001, 2002, 2008, 2009, 2010, 2011};
3✔
709

710
    auto group1_entities = group_manager.getEntitiesInGroup(group1);
1✔
711
    auto group2_entities = group_manager.getEntitiesInGroup(group2);
1✔
712
    std::sort(group1_entities.begin(), group1_entities.end());
1✔
713
    std::sort(group2_entities.begin(), group2_entities.end());
1✔
714

715
    REQUIRE(group1_entities == expected_g1);
1✔
716
    REQUIRE(group2_entities == expected_g2);
1✔
717
}
2✔
718

719
TEST_CASE("StateEstimator - cross-correlated features with MinCostFlow", "[StateEstimator][CrossCovariance][MinCostFlow]") {
1✔
720
    using namespace StateEstimation;
721

722
    // This test checks that cross-feature covariances don't cause numerical issues
723
    // in Mahalanobis distance calculations during MCF tracking
724

725
    // --- SETUP with SIMPLE test first (no cross-correlation yet) ---
726
    double dt = 1.0;
1✔
727
    
728
    // 6D state: [x, y, vx, vy, length, length_vel]
729
    Eigen::MatrixXd F(6, 6);
1✔
730
    F.setIdentity();
1✔
731
    F(0, 2) = dt;  // x += vx * dt
1✔
732
    F(1, 3) = dt;  // y += vy * dt
1✔
733
    F(4, 5) = dt;  // length += length_vel * dt
1✔
734

735
    // Measure position and length
736
    Eigen::MatrixXd H(3, 6);
1✔
737
    H.setZero();
1✔
738
    H(0, 0) = 1;  // measure x
1✔
739
    H(1, 1) = 1;  // measure y
1✔
740
    H(2, 4) = 1;  // measure length
1✔
741

742
    // Process noise WITH moderate cross-correlation
743
    Eigen::MatrixXd Q(6, 6);
1✔
744
    Q.setIdentity();
1✔
745
    Q.block<2, 2>(0, 0) *= 10.0;  // position noise
1✔
746
    Q.block<2, 2>(2, 2) *= 1.0;   // velocity noise
1✔
747
    Q.block<2, 2>(4, 4) *= 0.01;  // static feature noise
1✔
748
    
749
    // Add cross-correlation between position and length (moderate strength)
750
    // This mimics camera clipping scenario where position and length are correlated
751
    double correlation = 0.5;  // Moderate correlation (was 0.7)
1✔
752
    double pos_std = std::sqrt(10.0);
1✔
753
    double len_std = std::sqrt(0.01);
1✔
754
    double cov = correlation * pos_std * len_std;
1✔
755
    Q(0, 4) = cov;  // x-length correlation
1✔
756
    Q(4, 0) = cov;
1✔
757
    Q(1, 4) = cov;  // y-length correlation
1✔
758
    Q(4, 1) = cov;
1✔
759

760
    Eigen::MatrixXd R(3, 3);
1✔
761
    R.setIdentity();
1✔
762
    R.block<2, 2>(0, 0) *= 5.0;   // position measurement noise
1✔
763
    R(2, 2) = 10.0;                // length measurement noise
1✔
764

765
    // Create a feature extractor that returns 3D measurements
766
    class LineWithLengthExtractor : public IFeatureExtractor<TestLine2D> {
767
    public:
768
        Eigen::VectorXd getFilterFeatures(TestLine2D const & data) const override {
57✔
769
            Eigen::Vector2d c = data.centroid();
57✔
770
            double length = (data.p2 - data.p1).norm();
57✔
771
            Eigen::VectorXd features(3);
57✔
772
            features << c.x(), c.y(), length;
57✔
773
            return features;
114✔
NEW
774
        }
×
775

NEW
776
        FeatureCache getAllFeatures(TestLine2D const & data) const override {
×
NEW
777
            FeatureCache cache;
×
NEW
778
            cache[getFilterFeatureName()] = getFilterFeatures(data);
×
NEW
779
            return cache;
×
NEW
780
        }
×
781

NEW
782
        std::string getFilterFeatureName() const override {
×
NEW
783
            return "kalman_features";
×
784
        }
785

786
        FilterState getInitialState(TestLine2D const & data) const override {
2✔
787
            Eigen::VectorXd initialState(6);
2✔
788
            Eigen::Vector2d centroid = data.centroid();
2✔
789
            double length = (data.p2 - data.p1).norm();
2✔
790
            initialState << centroid.x(), centroid.y(), 0, 0, length, 0;
2✔
791

792
            // Initial covariance WITH moderate cross-correlation
793
            Eigen::MatrixXd p(6, 6);
2✔
794
            p.setIdentity();
2✔
795
            p.block<2, 2>(0, 0) *= 50.0;  // position uncertainty
2✔
796
            p.block<2, 2>(2, 2) *= 10.0;   // velocity uncertainty
2✔
797
            p.block<2, 2>(4, 4) *= 25.0;   // length uncertainty
2✔
798
            
799
            // Add moderate cross-correlation in initial state
800
            double init_correlation = 0.6;  // Moderate correlation (was 0.8)
2✔
801
            double init_pos_std = std::sqrt(50.0);
2✔
802
            double init_len_std = std::sqrt(25.0);
2✔
803
            double init_cov = init_correlation * init_pos_std * init_len_std;
2✔
804
            p(0, 4) = init_cov;  // x-length correlation
2✔
805
            p(4, 0) = init_cov;
2✔
806
            p(1, 4) = init_cov;  // y-length correlation
2✔
807
            p(4, 1) = init_cov;
2✔
808

809
            return {initialState, p};
2✔
810
        }
2✔
811

NEW
812
        std::unique_ptr<IFeatureExtractor<TestLine2D>> clone() const override {
×
NEW
813
            return std::make_unique<LineWithLengthExtractor>(*this);
×
814
        }
815

NEW
816
        FeatureMetadata getMetadata() const override {
×
817
            // Return metadata for the complete 6D state: [x, y, vx, vy, length, length_vel]
NEW
818
            return FeatureMetadata{
×
819
                .name = "kalman_features",
820
                .measurement_size = 3,  // measures [x, y, length]
821
                .state_size = 6,        // state [x, y, vx, vy, length, length_vel]
822
                .temporal_type = FeatureTemporalType::CUSTOM
NEW
823
            };
×
824
        }
825
    };
826

827
    auto kalman_filter = std::make_unique<KalmanFilter>(F, H, Q, R);
1✔
828
    auto feature_extractor = std::make_unique<LineWithLengthExtractor>();
1✔
829

830
    MinCostFlowTracker<TestLine2D> tracker(std::move(kalman_filter), std::move(feature_extractor), H, R);
1✔
831
    
832
    // Enable debug logging to see what's happening
833
    tracker.enableDebugLogging("cross_correlated_features_test.log");
3✔
834

835
    // --- Generate Data ---
836
    std::vector<std::tuple<TestLine2D, EntityId, TimeFrameIndex>> data_source;
1✔
837
    EntityGroupManager group_manager;
1✔
838
    GroupId group1 = group_manager.createGroup("Group 1");
5✔
839

840
    // Create lines where length correlates with position (camera clipping scenario)
841
    auto makeLine = [](int frame, double x, double y, double length) {
1✔
842
        Eigen::Vector2d p1(x - length/2, y);
20✔
843
        Eigen::Vector2d p2(x + length/2, y);
20✔
844
        return TestLine2D{(EntityId)(1000 + frame), p1, p2};
40✔
845
    };
846

847
    // Simulate camera edge effect: as line moves right, it gets clipped shorter
848
    for (int i = 0; i < 20; ++i) {
21✔
849
        double x = 10.0 + i * 5.0;
20✔
850
        double y = 50.0;
20✔
851
        double length = 100.0 - i * 2.0;  // Length decreases as x increases
20✔
852
        data_source.emplace_back(makeLine(i, x, y, length), (EntityId)(1000 + i), TimeFrameIndex(i));
20✔
853
    }
854

855
    // Ground truth anchors - add them to the group manager
856
    MinCostFlowTracker<TestLine2D>::GroundTruthMap ground_truth;
1✔
857
    ground_truth[TimeFrameIndex(0)] = {{group1, (EntityId)1000}};
1✔
858
    ground_truth[TimeFrameIndex(19)] = {{group1, (EntityId)1019}};
1✔
859
    
860
    // Pre-add anchor entities to groups (required for MCF)
861
    group_manager.addEntityToGroup(group1, (EntityId)1000);
1✔
862
    group_manager.addEntityToGroup(group1, (EntityId)1019);
1✔
863

864
    // --- EXECUTION ---
865
    // This should not crash or produce NaN/Inf costs
866
    REQUIRE_NOTHROW(tracker.process(data_source, group_manager, ground_truth, TimeFrameIndex(0), TimeFrameIndex(19)));
1✔
867

868
    // --- ASSERTIONS ---
869
    auto group1_entities = group_manager.getEntitiesInGroup(group1);
1✔
870
    
871
    // Check that all entities were successfully tracked
872
    INFO("Successfully tracked " << group1_entities.size() << " entities with cross-correlated features");
1✔
873
    REQUIRE(group1_entities.size() == 20);
1✔
874
    
875
    // Verify continuity - all consecutive entity IDs should be present
876
    std::sort(group1_entities.begin(), group1_entities.end());
1✔
877
    for (size_t i = 0; i < group1_entities.size(); ++i) {
21✔
878
        REQUIRE(group1_entities[i] == (EntityId)(1000 + i));
20✔
879
    }
880
}
2✔
881

882
TEST_CASE("Mahalanobis distance with ill-conditioned covariance", "[StateEstimator][MahalanobisDistance][Numerical]") {
3✔
883
    using namespace StateEstimation;
884

885
    // This test explicitly checks for numerical issues in Mahalanobis distance calculation
886
    // when covariance matrices are ill-conditioned or near-singular
887

888
    SECTION("Well-conditioned covariance produces valid distances") {
3✔
889
        Eigen::MatrixXd H = Eigen::MatrixXd::Identity(3, 6);
1✔
890
        H(0, 0) = 1;  // x
1✔
891
        H(1, 1) = 1;  // y
1✔
892
        H(2, 4) = 1;  // length
1✔
893

894
        Eigen::MatrixXd R(3, 3);
1✔
895
        R.setIdentity();
1✔
896
        R *= 5.0;
1✔
897

898
        FilterState predicted_state;
1✔
899
        predicted_state.state_mean = Eigen::VectorXd::Zero(6);
1✔
900
        predicted_state.state_covariance = Eigen::MatrixXd::Identity(6, 6) * 10.0;
1✔
901

902
        Eigen::VectorXd observation(3);
1✔
903
        observation << 1.0, 2.0, 50.0;
1✔
904

905
        // Compute innovation covariance
906
        Eigen::MatrixXd innovation_cov = H * predicted_state.state_covariance * H.transpose() + R;
1✔
907
        
908
        // Check that matrix is invertible
909
        Eigen::FullPivLU<Eigen::MatrixXd> lu(innovation_cov);
1✔
910
        REQUIRE(lu.isInvertible());
1✔
911
        
912
        // Compute Mahalanobis distance
913
        Eigen::VectorXd innovation = observation - H * predicted_state.state_mean;
1✔
914
        double dist_sq = innovation.transpose() * innovation_cov.inverse() * innovation;
1✔
915
        
916
        REQUIRE(std::isfinite(dist_sq));
1✔
917
        REQUIRE(dist_sq >= 0.0);
1✔
918
    }
4✔
919

920
    SECTION("Highly correlated covariance may produce ill-conditioned matrix") {
3✔
921
        Eigen::MatrixXd H = Eigen::MatrixXd::Identity(3, 6);
1✔
922
        H(0, 0) = 1;  // x
1✔
923
        H(1, 1) = 1;  // y
1✔
924
        H(2, 4) = 1;  // length
1✔
925

926
        Eigen::MatrixXd R(3, 3);
1✔
927
        R.setIdentity();
1✔
928
        R *= 5.0;
1✔
929

930
        FilterState predicted_state;
1✔
931
        predicted_state.state_mean = Eigen::VectorXd::Zero(6);
1✔
932
        
933
        // Create covariance with very strong correlation (near-singular)
934
        Eigen::MatrixXd P(6, 6);
1✔
935
        P.setIdentity();
1✔
936
        P *= 100.0;
1✔
937
        
938
        // Add extremely strong correlation between x and length (0.999)
939
        double correlation = 0.999;
1✔
940
        double pos_std = std::sqrt(100.0);
1✔
941
        double len_std = std::sqrt(100.0);
1✔
942
        double cov = correlation * pos_std * len_std;
1✔
943
        P(0, 4) = cov;
1✔
944
        P(4, 0) = cov;
1✔
945

946
        predicted_state.state_covariance = P;
1✔
947

948
        Eigen::VectorXd observation(3);
1✔
949
        observation << 1.0, 2.0, 50.0;
1✔
950

951
        // Compute innovation covariance
952
        Eigen::MatrixXd innovation_cov = H * predicted_state.state_covariance * H.transpose() + R;
1✔
953
        
954
        // Check condition number
955
        Eigen::JacobiSVD<Eigen::MatrixXd> svd(innovation_cov);
1✔
956
        double condition_number = svd.singularValues()(0) / svd.singularValues()(svd.singularValues().size()-1);
1✔
957
        
958
        INFO("Condition number: " << condition_number);
1✔
959
        
960
        // High correlation can lead to poor conditioning
961
        if (condition_number > 1e10) {
1✔
NEW
962
            WARN("Innovation covariance is ill-conditioned (condition number: " << condition_number << ")");
×
963
        }
964
        
965
        // Try to compute Mahalanobis distance
966
        Eigen::VectorXd innovation = observation - H * predicted_state.state_mean;
1✔
967
        
968
        // Using direct inverse may fail or produce nonsensical results
969
        double dist_sq_direct = innovation.transpose() * innovation_cov.inverse() * innovation;
1✔
970
        
971
        INFO("Mahalanobis distance (direct inverse): " << std::sqrt(dist_sq_direct));
1✔
972
        
973
        // Check if result is valid
974
        bool direct_valid = std::isfinite(dist_sq_direct) && dist_sq_direct >= 0.0;
1✔
975
        
976
        if (!direct_valid) {
1✔
NEW
977
            WARN("Direct matrix inverse produced invalid result with highly correlated features");
×
978
        }
979
        
980
        // Better approach: use pseudo-inverse or LLT decomposition
981
        Eigen::LLT<Eigen::MatrixXd> llt(innovation_cov);
1✔
982
        if (llt.info() == Eigen::Success) {
1✔
983
            Eigen::VectorXd solved = llt.solve(innovation);
1✔
984
            double dist_sq_llt = innovation.transpose() * solved;
1✔
985
            
986
            INFO("Mahalanobis distance (LLT solve): " << std::sqrt(dist_sq_llt));
1✔
987
            REQUIRE(std::isfinite(dist_sq_llt));
1✔
988
            REQUIRE(dist_sq_llt >= 0.0);
1✔
989
        } else {
1✔
NEW
990
            WARN("LLT decomposition failed - matrix is not positive definite");
×
991
        }
992
    }
4✔
993

994
    SECTION("Singular covariance from perfect correlation") {
3✔
995
        // This represents the extreme case where features are perfectly linearly dependent
996
        Eigen::MatrixXd H = Eigen::MatrixXd::Identity(2, 4);
1✔
997
        H(0, 0) = 1;  // x
1✔
998
        H(1, 2) = 1;  // feature perfectly correlated with x
1✔
999

1000
        Eigen::MatrixXd R(2, 2);
1✔
1001
        R.setIdentity();
1✔
1002
        R *= 1e-6;  // Very small measurement noise
1✔
1003

1004
        FilterState predicted_state;
1✔
1005
        predicted_state.state_mean = Eigen::VectorXd::Zero(4);
1✔
1006
        
1007
        // Create covariance where features are perfectly correlated
1008
        Eigen::MatrixXd P(4, 4);
1✔
1009
        P.setIdentity();
1✔
1010
        P *= 100.0;
1✔
1011
        
1012
        // Perfect correlation: cov = std1 * std2
1013
        P(0, 2) = 100.0;  // Perfect correlation
1✔
1014
        P(2, 0) = 100.0;
1✔
1015

1016
        predicted_state.state_covariance = P;
1✔
1017

1018
        Eigen::VectorXd observation(2);
1✔
1019
        observation << 1.0, 1.0;  // Consistent with perfect correlation
1✔
1020

1021
        // Compute innovation covariance
1022
        Eigen::MatrixXd innovation_cov = H * predicted_state.state_covariance * H.transpose() + R;
1✔
1023
        
1024
        // Check if matrix is singular
1025
        Eigen::FullPivLU<Eigen::MatrixXd> lu(innovation_cov);
1✔
1026
        double determinant = innovation_cov.determinant();
1✔
1027
        
1028
        INFO("Determinant: " << determinant);
1✔
1029
        INFO("Is invertible: " << lu.isInvertible());
1✔
1030
        
1031
        if (!lu.isInvertible() || std::abs(determinant) < 1e-10) {
1✔
NEW
1032
            WARN("Innovation covariance is singular or near-singular with perfect correlation");
×
1033
            
1034
            // Direct inverse will fail catastrophically
1035
            // This is what causes MCF to produce "no optimal path" errors
1036
        }
1037
    }
4✔
1038
}
3✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc