Skip to contents

This vignette cleans a real satellite track end to end: Leo, a migratory Turkey Vulture (Cathartes aura) followed from Canada to Venezuela. The data look nothing like high-frequency GPS. Fixes arrive about three times a day but very unevenly, the track spans a continent, and the step lengths swing enormously between migration and the long stationary spells on the wintering and breeding grounds. Those features are exactly what trips up a naive outlier check, so Leo is a good test of cleaning that respects how much time passed between locations.

Load data

Leo’s track (2007–2013) is bundled with the package. We drop empty geometries first, as always.

library(move2)
library(sf)
library(move2utils)

Leo <- mt_read(system.file("extdata", "Leo-65545.csv.gz",
                           package = "move2utils"))
Leo <- Leo[!st_is_empty(Leo), ]

cat(nrow(Leo), "locations over",
    round(as.numeric(diff(range(mt_time(Leo))), units = "days")),
    "days\n")
#> 35256 locations over 2102 days

## time lag distribution
tl <- as.numeric(diff(mt_time(Leo)), units = "hours")
cat("Time lags: median", round(median(tl), 1), "h,",
    "range", round(min(tl), 1), "--", round(max(tl), 1), "h\n")
#> Time lags: median 1 h, range 1 -- 9253 h

The gaps between fixes run from about an hour to months (the tag off, or the bird sitting out the winter). With timing this irregular, the cleaner has to account for elapsed time, or it will mistake a long migratory leg for an error.

Why elapsed time matters

If you ignore how much time passed between two locations, a 500 km migratory step looks impossibly far compared with a day of local foraging, and the unusual-movement check (mt_flag_outliers()) flags it. Turn on time_normalize and the same step becomes a moderate speed — a vulture covering 500 km in three days is entirely normal. The comparison makes the point:

r_tn  <- mt_flag_outliers(Leo, time_normalize = TRUE,  plot = FALSE)
#> Input is in longitude/latitude.  Auto-projecting to a local AEQD for Euclidean prob math; output is returned in the original CRS.
#> Calculating movement metrics...
#> ACF-derived alpha: 0.260 (r_speed=0.822, r_angvel=0.082)
#> Calculating probability distributions...
#> Note: gap-aware scaling is NOT in force for the step auto-difference (gap distribution yields only 2 distinct quantile break(s); need 3 (a strictly two-valued duty cycle can land here depending on its mixing proportion));
#>   the scale is constant, so autodifferences are not gap-normalised.
#> Note: gap-aware scaling is NOT in force for the turning-angle auto-difference (gap distribution yields only 2 distinct quantile break(s); need 3 (a strictly two-valued duty cycle can land here depending on its mixing proportion));
#>   the scale is constant, so autodifferences are not gap-normalised.
#> Calculating joint probabilities...
#> Identifying outliers...
#> 
#> 3 locations (0.0%) have NA probabilities (includes 7001 stationary fixes) --will be kept.
#> === 37 outliers (0.10% of 35256) ===
r_raw <- mt_flag_outliers(Leo, time_normalize = FALSE, plot = FALSE)
#> Input is in longitude/latitude.  Auto-projecting to a local AEQD for Euclidean prob math; output is returned in the original CRS.
#> Calculating movement metrics...
#> ACF-derived alpha: 0.094 (r_speed=0.110, r_angvel=0.081)
#> Calculating probability distributions...
#> Note: gap-aware scaling is NOT in force for the step auto-difference (gap distribution yields only 1 distinct quantile break(s); need 3 (a strictly two-valued duty cycle can land here depending on its mixing proportion));
#>   the scale is constant, so autodifferences are not gap-normalised.
#> Note: gap-aware scaling is NOT in force for the turning-angle auto-difference (gap distribution yields only 1 distinct quantile break(s); need 3 (a strictly two-valued duty cycle can land here depending on its mixing proportion));
#>   the scale is constant, so autodifferences are not gap-normalised.
#> Note: step-length range/IQR = 1740 is extreme;
#>   teleport-class GPS errors are better handled by
#>   mt_filter_gps_quality() (drop fixes with <5 satellites)
#>   and mt_flag_outliers_bridge() (geometric, leverage-immune).
#>   step_transform = "log" is available but can hide
#>   physiologically-plausible joint turn/step outliers.
#> Calculating joint probabilities...
#> Identifying outliers...
#> 
#> 3 locations (0.0%) have NA probabilities (includes 7001 stationary fixes) --will be kept.
#> === 100 outliers (0.28% of 35256) ===

cat("With elapsed-time normalisation:   ", sum(r_tn$is_outlier), "outliers\n")
#> With elapsed-time normalisation:    37 outliers
cat("Without elapsed-time normalisation:", sum(r_raw$is_outlier), "outliers\n")
#> Without elapsed-time normalisation: 100 outliers

Almost all of the extra “outliers” in the second run are false alarms: ordinary migratory movements that only look extreme because the raw step length ignores the days that elapsed. This is why time_normalize = TRUE is the sensible choice for irregular data.

Default detection

result <- mt_flag_outliers(Leo)
#> Input is in longitude/latitude.  Auto-projecting to a local AEQD for Euclidean prob math; output is returned in the original CRS.
#> Calculating movement metrics...
#> ACF-derived alpha: 0.260 (r_speed=0.822, r_angvel=0.082)
#> Calculating probability distributions...
#> Note: gap-aware scaling is NOT in force for the step auto-difference (gap distribution yields only 2 distinct quantile break(s); need 3 (a strictly two-valued duty cycle can land here depending on its mixing proportion));
#>   the scale is constant, so autodifferences are not gap-normalised.
#> Note: gap-aware scaling is NOT in force for the turning-angle auto-difference (gap distribution yields only 2 distinct quantile break(s); need 3 (a strictly two-valued duty cycle can land here depending on its mixing proportion));
#>   the scale is constant, so autodifferences are not gap-normalised.
#> Calculating joint probabilities...
#> Identifying outliers...
#> 
#> 3 locations (0.0%) have NA probabilities (includes 7001 stationary fixes) --will be kept.
#> === 37 outliers (0.10% of 35256) ===
#> Creating diagnostic plot...

With elapsed-time normalisation and the default gap-based threshold, Leo’s track shows very few or no outliers. A soaring bird tracked by satellite is naturally more variable than a mammal tracked by high-frequency GPS, and the gap threshold reads that wide spread as the normal distribution rather than chopping off its tails.

The one-call workflow — mt_clean_track()

For routine cleaning, mt_clean_track() runs all four checks — path position (mt_flag_outliers_bridge), there-and-back spikes (mt_flag_outliers_detour), unusual movement (mt_flag_outliers), and speed (mt_flag_speed_cap) — under one iterative call and combines them into a single decision. By default it weighs how strongly each check distrusts a location on a common scale and flags the location when the combined evidence is positive and either two checks agree or the there-and-back check alone is overwhelmingly sure (consensus = "evidence_corroborated"). You can switch rules via consensus =: the earlier behaviour is "class_aware", and "strict", "majority", "speed_trusted", "weighted_evidence", "any" and "custom" are also available (see ?mt_flag_consensus).

Turkey vultures sustain flight speeds around 25 m/s. Supplying that as a physiological cap (v_max = 25) lets the speed check peel any spoof- or teleport-type clusters at their edges before the other checks run on what survives.

clean <- mt_clean_track(Leo, v_max = 25, plot = FALSE)
#> Speed peel (pre-step) at v_max = 25 m/s: 4 fix(es) removed in 1 iteration(s).
#> Iter 1: bridge=13438 prob=11 speed=0 detour=3127 (v_max=25.0) | conjunction=435 | new=435 cumulative=439
#> Iter 2: bridge=10186 prob=35 speed=0 detour=2690 (v_max=25.0) | conjunction=61 | new=61 cumulative=500
#> Iter 3: bridge=10178 prob=24 speed=0 detour=2662 (v_max=25.0) | conjunction=23 | new=23 cumulative=523
#> Iter 4: bridge=10173 prob=22 speed=0 detour=2658 (v_max=25.0) | conjunction=16 | new=16 cumulative=539
#> Iter 5: bridge=10193 prob=9 speed=0 detour=2656 (v_max=25.0) | conjunction=7 | new=7 cumulative=546
#> Iter 6: bridge=10171 prob=12 speed=0 detour=2656 (v_max=25.0) | conjunction=9 | new=9 cumulative=555
#> Iter 7: bridge=10183 prob=8 speed=0 detour=2656 (v_max=25.0) | conjunction=5 | new=5 cumulative=560
#> Iter 8: bridge=10202 prob=13 speed=0 detour=2656 (v_max=25.0) | conjunction=8 | new=8 cumulative=568
#> Iter 9: bridge=10190 prob=22 speed=0 detour=2655 (v_max=25.0) | conjunction=20 | new=20 cumulative=588
#> Iter 10: bridge=10138 prob=20 speed=0 detour=2655 (v_max=25.0) | conjunction=14 | new=14 cumulative=602
#> Iter 11: bridge=10142 prob=22 speed=0 detour=2655 (v_max=25.0) | conjunction=13 | new=13 cumulative=615
#> Iter 12: bridge=10117 prob=35 speed=0 detour=2655 (v_max=25.0) | conjunction=32 | new=32 cumulative=647
#> Iter 13: bridge=10093 prob=34 speed=0 detour=2654 (v_max=25.0) | conjunction=27 | new=27 cumulative=674
#> Iter 14: bridge=10100 prob=18 speed=0 detour=2654 (v_max=25.0) | conjunction=16 | new=16 cumulative=690
#> Iter 15: bridge=10048 prob=18 speed=0 detour=2653 (v_max=25.0) | conjunction=14 | new=14 cumulative=704
#> Iter 16: bridge=10042 prob=14 speed=0 detour=2653 (v_max=25.0) | conjunction=12 | new=12 cumulative=716
#> Iter 17: bridge=10079 prob=10 speed=0 detour=2652 (v_max=25.0) | conjunction=7 | new=7 cumulative=723
#> Iter 18: bridge=10077 prob=12 speed=0 detour=2652 (v_max=25.0) | conjunction=9 | new=9 cumulative=732
#> Iter 19: bridge=10073 prob=11 speed=0 detour=2652 (v_max=25.0) | conjunction=9 | new=9 cumulative=741
#> Iter 20: bridge=10021 prob=16 speed=0 detour=2652 (v_max=25.0) | conjunction=15 | new=15 cumulative=756
#> Iter 21: bridge=10007 prob=36 speed=0 detour=2651 (v_max=25.0) | conjunction=30 | new=30 cumulative=786
#> Iter 22: bridge=10019 prob=22 speed=0 detour=2651 (v_max=25.0) | conjunction=20 | new=20 cumulative=806
#> Iter 23: bridge=9990 prob=28 speed=0 detour=2648 (v_max=25.0) | conjunction=21 | new=21 cumulative=827
#> Iter 24: bridge=10002 prob=3 speed=0 detour=2646 (v_max=25.0) | conjunction=4 | new=4 cumulative=831
#> Iter 25: bridge=10000 prob=5 speed=0 detour=2644 (v_max=25.0) | conjunction=3 | new=3 cumulative=834
#> Iter 26: bridge=10006 prob=9 speed=0 detour=2644 (v_max=25.0) | conjunction=7 | new=7 cumulative=841
#> Iter 27: bridge=9997 prob=32 speed=0 detour=2644 (v_max=25.0) | conjunction=24 | new=24 cumulative=865
#> Iter 28: bridge=10013 prob=6 speed=0 detour=2644 (v_max=25.0) | conjunction=6 | new=6 cumulative=871
#> Iter 29: bridge=10016 prob=19 speed=0 detour=2642 (v_max=25.0) | conjunction=13 | new=13 cumulative=884
#> Iter 30: bridge=10001 prob=16 speed=0 detour=2642 (v_max=25.0) | conjunction=10 | new=10 cumulative=894
#> Iter 31: bridge=9995 prob=7 speed=0 detour=2642 (v_max=25.0) | conjunction=6 | new=6 cumulative=900
#> Iter 32: bridge=9994 prob=29 speed=0 detour=2642 (v_max=25.0) | conjunction=27 | new=27 cumulative=927
#> Iter 33: bridge=9981 prob=7 speed=0 detour=2642 (v_max=25.0) | conjunction=7 | new=7 cumulative=934
#> Iter 34: bridge=9972 prob=19 speed=0 detour=2641 (v_max=25.0) | conjunction=14 | new=14 cumulative=948
#> Iter 35: bridge=9956 prob=6 speed=0 detour=2641 (v_max=25.0) | conjunction=5 | new=5 cumulative=953
#> Iter 36: bridge=9947 prob=42 speed=0 detour=2641 (v_max=25.0) | conjunction=37 | new=37 cumulative=990
#> Iter 37: bridge=9924 prob=15 speed=0 detour=2641 (v_max=25.0) | conjunction=15 | new=15 cumulative=1005
#> Iter 38: bridge=9932 prob=22 speed=0 detour=2638 (v_max=25.0) | conjunction=18 | new=18 cumulative=1023
#> Iter 39: bridge=9909 prob=4 speed=0 detour=2638 (v_max=25.0) | conjunction=4 | new=4 cumulative=1027
#> Iter 40: bridge=9912 prob=5 speed=0 detour=2636 (v_max=25.0) | conjunction=4 | new=4 cumulative=1031
#> Iter 41: bridge=9909 prob=11 speed=0 detour=2636 (v_max=25.0) | conjunction=7 | new=7 cumulative=1038
#> Iter 42: bridge=9908 prob=17 speed=0 detour=2635 (v_max=25.0) | conjunction=13 | new=13 cumulative=1051
#> Iter 43: bridge=9902 prob=22 speed=0 detour=2635 (v_max=25.0) | conjunction=19 | new=19 cumulative=1070
#> Iter 44: bridge=9887 prob=20 speed=0 detour=2635 (v_max=25.0) | conjunction=18 | new=18 cumulative=1088
#> Iter 45: bridge=9878 prob=10 speed=0 detour=2633 (v_max=25.0) | conjunction=8 | new=8 cumulative=1096
#> Iter 46: bridge=9879 prob=9 speed=0 detour=2632 (v_max=25.0) | conjunction=7 | new=7 cumulative=1103
#> Iter 47: bridge=9878 prob=18 speed=0 detour=2632 (v_max=25.0) | conjunction=15 | new=15 cumulative=1118
#> Iter 48: bridge=11132 prob=28 speed=0 detour=2632 (v_max=25.0) | conjunction=22 | new=22 cumulative=1140
#> Iter 49: bridge=11100 prob=8 speed=0 detour=2632 (v_max=25.0) | conjunction=9 | new=9 cumulative=1149
#> Iter 50: bridge=11096 prob=17 speed=0 detour=2630 (v_max=25.0) | conjunction=14 | new=14 cumulative=1163
#> Iter 51: bridge=11094 prob=16 speed=0 detour=2630 (v_max=25.0) | conjunction=12 | new=12 cumulative=1175
#> Iter 52: bridge=11096 prob=14 speed=0 detour=2630 (v_max=25.0) | conjunction=12 | new=12 cumulative=1187
#> Iter 53: bridge=11093 prob=16 speed=0 detour=2630 (v_max=25.0) | conjunction=17 | new=17 cumulative=1204
#> Iter 54: bridge=11082 prob=4 speed=0 detour=2626 (v_max=25.0) | conjunction=6 | new=6 cumulative=1210
#> Iter 55: bridge=11085 prob=3 speed=0 detour=2622 (v_max=25.0) | conjunction=3 | new=3 cumulative=1213
#> Iter 56: bridge=11085 prob=28 speed=0 detour=2620 (v_max=25.0) | conjunction=24 | new=24 cumulative=1237
#> Iter 57: bridge=11068 prob=12 speed=0 detour=2619 (v_max=25.0) | conjunction=13 | new=13 cumulative=1250
#> Iter 58: bridge=9793 prob=13 speed=0 detour=2615 (v_max=25.0) | conjunction=9 | new=9 cumulative=1259
#> Iter 59: bridge=9809 prob=7 speed=0 detour=2615 (v_max=25.0) | conjunction=5 | new=5 cumulative=1264
#> Iter 60: bridge=9808 prob=5 speed=0 detour=2615 (v_max=25.0) | conjunction=3 | new=3 cumulative=1267
#> Iter 61: bridge=9805 prob=16 speed=0 detour=2615 (v_max=25.0) | conjunction=11 | new=11 cumulative=1278
#> Iter 62: bridge=9793 prob=5 speed=0 detour=2615 (v_max=25.0) | conjunction=2 | new=2 cumulative=1280
#> Iter 63: bridge=9783 prob=3 speed=0 detour=2615 (v_max=25.0) | conjunction=0 | new=0 cumulative=1280
#> Block-expansion gate: DECLINED -- largest component holds only 9.9% of kept fixes (< 80% required) -- cut severs continuous trajectory.
#> === mt_clean_track: 1280 flagged (3.631% of 35256); stopped: no_new_flags ===
#>     Returning the cleaned track (33976 rows). To inspect what was flagged, re-run with remove = FALSE.
cat("kept", nrow(clean), "of", nrow(Leo), "fixes\n")
#> kept 33976 of 35256 fixes

For long, irregular satellite tracks where you have no known top speed, leave the cap at its data-driven default (v_max = NULL) — that is the safe call.

Whichever consensus rule you use, mt_clean_track() still records why each location was flagged. The error_class column names the rule that caught it (a clear there-and-back spike, an impossible speed, membership of a removed block, and so on); it is computed regardless of the consensus rule. To see the full evidence weighed on a common scale — the combined_evidence column — run the standalone mt_flag_consensus(mode = "evidence_corroborated") or mt_flag_consensus(mode = "weighted_evidence") on a flagged track.

Auto-optimised alpha

The unusual-movement check blends each step with the change in step from one move to the next; alpha sets how much that change counts. For Leo’s irregular data, how much should it count? Let the optimiser decide:

r_auto <- mt_flag_outliers(Leo, autodiff_alpha = "auto", plot = FALSE)
#> Input is in longitude/latitude.  Auto-projecting to a local AEQD for Euclidean prob math; output is returned in the original CRS.
#> Calculating movement metrics...
#> Calculating probability distributions...
#> Note: gap-aware scaling is NOT in force for the step auto-difference (gap distribution yields only 2 distinct quantile break(s); need 3 (a strictly two-valued duty cycle can land here depending on its mixing proportion));
#>   the scale is constant, so autodifferences are not gap-normalised.
#> Note: gap-aware scaling is NOT in force for the turning-angle auto-difference (gap distribution yields only 2 distinct quantile break(s); need 3 (a strictly two-valued duty cycle can land here depending on its mixing proportion));
#>   the scale is constant, so autodifferences are not gap-normalised.
#> Auto-optimised alpha: 2.0000
#> Calculating joint probabilities...
#> Identifying outliers...
#> 
#> 3 locations (0.0%) have NA probabilities (includes 7001 stationary fixes) --will be kept.
#> === 25 outliers (0.07% of 35256) ===
cat("Outliers with auto-alpha:", sum(r_auto$is_outlier), "\n")
#> Outliers with auto-alpha: 25

Comparison with a regular-sampling track

The contrast with regular, high-frequency sampling is instructive. The bundled CPF_A synthetic track (also used in vignettes 1, 3, 4 and 5) has regular sampling and 23 errors planted at known positions. There the check finds genuine errors that stand out clearly against an otherwise tight distribution:

path <- system.file("extdata/synthetic_tracks.csv.gz",
                    package = "move2utils")
tracks <- mt_read(path)
cpf_a  <- filter_track_data(tracks, .track_id = "CPF_A")

r_cpf <- mt_flag_outliers(cpf_a, plot = FALSE)
#> Input is in longitude/latitude.  Auto-projecting to a local AEQD for Euclidean prob math; output is returned in the original CRS.
#> Calculating movement metrics...
#> ACF-derived alpha: 0.139 (r_speed=0.817, r_angvel=0.024)
#> Calculating probability distributions...
#> Note: step-length range/IQR = 8233 is extreme;
#>   teleport-class GPS errors are better handled by
#>   mt_filter_gps_quality() (drop fixes with <5 satellites)
#>   and mt_flag_outliers_bridge() (geometric, leverage-immune).
#>   step_transform = "log" is available but can hide
#>   physiologically-plausible joint turn/step outliers.
#> Calculating joint probabilities...
#> Identifying outliers...
#> 
#> 3 locations (0.2%) have NA probabilities --will be kept.
#> === 5 outliers (0.29% of 1748) ===
cat("CPF_A (regular sampling, 23 planted outliers):",
    sum(r_cpf$is_outlier), "flagged of", nrow(cpf_a), "fixes\n")
#> CPF_A (regular sampling, 23 planted outliers): 5 flagged of 1748 fixes
cat("Leo (vulture, satellite):",
    sum(result$is_outlier), "flagged of", nrow(Leo), "fixes\n")
#> Leo (vulture, satellite): 37 flagged of 35256 fixes

The check adapts to the data. Regular high-frequency sampling gives a tight distribution where planted errors jump out; irregular satellite data has wide natural variability that the gap threshold respects rather than punishes.

Multi-scale persistence annotation

mt_persistence_score() takes any flagged output and adds a confidence score: at each of scales = c(2, 4, 8) (the validation scales) it asks whether a flagged location still looks anomalous when viewed over a wider time window. Scores run from 1 (flagged only at the native resolution) to 4 (flagged at every scale). It does not touch is_outlier — it only annotates.

annotated <- mt_persistence_score(result, silent = TRUE)
flagged <- which(annotated$is_outlier)
cat("Persistence score distribution among flagged fixes:\n")
#> Persistence score distribution among flagged fixes:
print(table(annotated$persistence_count[flagged]))
#> 
#>  4 
#> 37

See vignette("OUTLIER_5_persistence_score", package = "move2utils") for the empirical class-conditional analysis on synthetic CPF data.

Further reading