Migratory satellite tracking: Leo the Turkey Vulture
Source:vignettes/OUTLIER_example_leo_migration.Rmd
OUTLIER_example_leo_migration.RmdThis 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 hThe 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 outliersAlmost 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 fixesFor 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: 25Comparison 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 fixesThe 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
#> 37See
vignette("OUTLIER_5_persistence_score", package = "move2utils")
for the empirical class-conditional analysis on synthetic CPF data.
Further reading
-
vignette("OUTLIER_1_getting_started", package = "move2utils")— the unifiedmt_clean_track()workflow and a brief tour of all four checks. -
vignette("OUTLIER_2_diagnose_clean_track", package = "move2utils")— the after-cleaning health check. -
vignette("OUTLIER_3_state_conditional", package = "move2utils")— cleaning different behaviours separately, the recipe to reach for when the diagnostic flags two behavioural states. -
vignette("OUTLIER_4_outlier_bridge", package = "move2utils")— the path-position check and the kinds of error its directional variant distinguishes; for fine control over a single check. -
vignette("OUTLIER_5_persistence_score", package = "move2utils")— scoring how confident each flag is; useful as a post-cleaning filter. -
vignette("OUTLIER_heterogeneous_error_regimes", package = "move2utils")— tracks mixing sensors with different error, cleaned one sensor at a time. -
vignette("OUTLIER_example_outlier_whitestork", package = "move2utils")— a full narrated cleaning on a real high-frequency stork track.