Skip to contents

This vignette is about the path-position check, mt_flag_outliers_bridge(). Use it on its own when you want to find GPS fixes that sit in the wrong place — a single location that jumps far from where its neighbours are, then comes straight back. It is the geometric one of the four checks that mt_clean_track() combines; running it alone lets you look at just that one signal, or build your own pipeline on top of it.

We cover what the check does, the directional variant that also tells you what kind of error you found, and how to read the columns it adds.

If you are new to the package, start with mt_clean_track() instead — it runs this check for you alongside three others and decides what to flag. Come back here when you want fine control over this one signal. The unusual-movement check mt_flag_outliers() is covered in vignette("OUTLIER_1_getting_started", package = "move2utils"). Both checks share the same threshold machinery but target different kinds of error, and can be combined.

The idea

If a GPS fix does not belong, the simplest test is: given where the animal was just before and just after, where would you expect the fix in between to be? If the actual fix is far from that expectation — further than the time gap on either side could account for — it is probably wrong.

That is the bridge. For each fix we draw a line (the “bridge”) between its two neighbours in time, weighted by how much time sits on either side, and measure how far the actual fix lies from that line. A small distance means the fix is about where it should be; a large one means something is off.

The key point is that the width of the bridge — how much wandering is plausible over a given time gap — depends only on the timestamps, never on the fix’s own position. A bad fix therefore cannot inflate the tolerance that would have flagged it. Methods that instead scale by a locally-estimated variance suffer from this “leverage problem”, because a bad fix pollutes its own scale.

The formula (for the curious)

For each fix i with projected coordinates p_i and time t_i, its neighbours i-1 and i+1 define a Brownian-bridge expectation

m_i = α p_{i-1} + (1 − α) p_{i+1}, α = Δt₂ / (Δt₁ + Δt₂)

where Δt₁ = t_i − t_{i-1} and Δt₂ = t_{i+1} − t_i.

The residual is r_i = p_i − m_i. It is scaled by the bridge’s own standard-deviation width,

w_i = √(Δt₁ · Δt₂ / (Δt₁ + Δt₂)).

The bridge score is η_i = ||r_i|| / w_i. As noted above, w_i depends only on timestamps, never on positions, so an outlier cannot inflate its own denominator.

Three methods

  • method = "isotropic" uses the plain residual magnitude. General-purpose; answers “is this fix far from where it should be?”.
  • method = "directional" splits the residual along the local travel direction into a parallel part (η_para, along-track) and a perpendicular part (η_perp, across-track). It flags on η_perp alone, but the value is in the (η_para, η_perp) pair — it tells you what kind of error each flag is.
  • method = "combined" (default) applies the threshold to both scores and flags a fix if either one trips.

The check borrows its bridge-mean construction from the dynamic Brownian bridge (dBBMM) and its perpendicular split from the dynamic bivariate Gaussian bridge (dBGB), but deliberately does not use their variance-estimation step — that is what avoids the leverage problem. See mt_dbbmm_variance() / mt_dbgb_variance() for the full dynamic models.

Requirements

  • The function accepts longitude/latitude or projected input; it projects internally to a local AEQD for the distance math and returns the result in your original CRS.
  • Enough context. With only a handful of locations the bridge neighbours are undefined; in practice you need at least a few dozen locations for the threshold estimator to be stable.

A worked example

inst/extdata/synthetic_tracks.csv.gz contains central-place-forager tracks with ground-truth outliers for validation. CPF_A has 23 injected outliers, CPF_B is clean, and CPF_C has 4.

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

path <- system.file("extdata/synthetic_tracks.csv.gz",
                     package = "move2utils")
tracks <- mt_read(path)
tracks <- tracks[!st_is_empty(tracks), ]

cpfA <- tracks[mt_track_id(tracks) == "CPF_A", ]
cpfA_p <- st_transform(cpfA, mt_aeqd_crs(cpfA))
nrow(cpfA_p)
#> [1] 1748

Default run (combined method, entropy threshold)

res <- mt_flag_outliers_bridge(cpfA_p, plot = FALSE)
#> Running bridge-residual detection (method = combined) on 1748 locations...
#>   Iter 1: flagged 6 (break at eta = 1591.91 / eta_perp = 406.83).
#>   Iter 2: flagged 4 (break at eta = 1428.24 / eta_perp = 1227.59).
#>   Iter 3: flagged 7 (break at eta = 1255.69 / eta_perp = 1021.41).
#> === 17 outliers (0.97% of 1748) ===
table(res$is_outlier)
#> 
#> FALSE  TRUE 
#>  1731    17
coords <- sf::st_coordinates(res)
par(mar = c(4, 4, 3, 1))
plot(coords, type = "l", col = "grey80", asp = 1,
     xlab = "x (m)", ylab = "y (m)",
     main = sprintf("CPF_A — %d flags (combined, entropy)", sum(res$is_outlier)))
points(coords[!res$is_outlier, ], pch = 16, cex = 0.25, col = "grey50")
points(coords[res$is_outlier, ], pch = 1, cex = 1.6,
       col = "firebrick", lwd = 1.4)
legend("topright", pch = c(16, 1), col = c("grey50", "firebrick"),
       legend = c("kept", "flagged"), bty = "n")
CPF_A synthetic track in projected coordinates. Grey line traces the full track in order; firebrick circles mark the fixes mt_flag_outliers_bridge() flagged under the default combined-entropy configuration. Flagged points sit visibly off the otherwise smooth central-place-forager geometry.

CPF_A synthetic track in projected coordinates. Grey line traces the full track in order; firebrick circles mark the fixes mt_flag_outliers_bridge() flagged under the default combined-entropy configuration. Flagged points sit visibly off the otherwise smooth central-place-forager geometry.

The columns added to the input are

column meaning
bridge_residual residual magnitude
bridge_width bridge-width normalisation w_i
bridge_eta score η_i = residual / width (scalar / isotropic)
bridge_percentile empirical quantile of η within the track
bridge_iteration iteration on which the fix was flagged (0 = not flagged)
is_outlier the flag

The "entropy" threshold (default) is the deepest valley in the kernel-density estimate of log(η). On clean data the density has a single hump, there is no valley, and the function returns zero flags — a no-op guarantee that makes it safe to apply to any track. The "gap" threshold is more sensitive: it uses a broken-stick null model plus tail-decay inflection, and will often pick up borderline points that entropy leaves alone.

res_gap <- mt_flag_outliers_bridge(
  cpfA_p,
  threshold_type = "gap",
  plot = FALSE
)
#> Running bridge-residual detection (method = combined) on 1748 locations...
#>   Iter 1: flagged 17 (break at eta = 14.95 / eta_perp = 17.63).
#>   Iter 2: flagged 12 (break at eta = 14.89 / eta_perp = 12.14).
#>   Iter 3: flagged 8 (break at eta = 14.70 / eta_perp = 14700.18).
#> === 37 outliers (2.12% of 1748) ===
table(res_gap$is_outlier)
#> 
#> FALSE  TRUE 
#>  1711    37

Use "gap" when you want sensitivity and can tolerate a few false positives; use "entropy" (the default) when false positives cost more than missed outliers, or when the track might already be clean.

Directional variant — what kind of error?

res_d <- mt_flag_outliers_bridge(
  cpfA_p,
  method = "directional",
  plot   = FALSE
)
#> Running bridge-residual detection (method = directional) on 1748 locations...
#>   Iter 1: flagged 5 (break at eta_perp = 406.83).
#>   Iter 2: flagged 5 (break at eta_perp = 56.83).
#>   Iter 3: flagged 3 (break at eta_perp = 679.58).
#> === 13 outliers (0.74% of 1748) ===
grep("bridge", names(res_d), value = TRUE)
#>  [1] "bridge_residual"      "bridge_width"         "bridge_eta"          
#>  [4] "bridge_eta_para"      "bridge_eta_perp"      "bridge_obs_inflation"
#>  [7] "bridge_percentile"    "bridge_iteration"     "flagged_by_bridge"   
#> [10] "loglr_bridge"

Two extra columns appear:

  • bridge_eta_para — residual along the local travel direction
  • bridge_eta_perp — residual across it

Flags go on bridge_eta_perp (the across-track signal, usually the clearer mark of genuine measurement error). But the useful thing is the pair of values for each flag.

Reading the (η_para, η_perp) plane

flags <- res_d[res_d$is_outlier, ]
n_flag <- nrow(flags)

plot(log10(flags$bridge_eta_para + 1),
     log10(flags$bridge_eta_perp + 1),
     xlab = expression(log[10](eta["para"] + 1)),
     ylab = expression(log[10](eta["perp"] + 1)),
     pch = 16, col = "firebrick", cex = 1.2,
     main = sprintf("error morphology on %d flagged points", n_flag))
abline(0, 1, lty = 2, col = "grey50")

How to read the plane:

  • Near the diagonal — η_para ≈ η_perp. Scatter in all directions; classic jitter-type error.
  • Below the diagonal (high η_para, low η_perp) — along-track jumps. Ghost reports, clock glitches, GNSS fold-back.
  • Above the diagonal (low η_para, high η_perp) — across-track drift. Typical of multipath, reflective environments, or constellation changes that bias one axis.

This is why the isotropic and directional flag sets can look quite different on real data even though both come from the same residual: the isotropic score answers how far, the directional split answers which way. Together they separate error types that one check would lump.

Combining bridge with the other checks

mt_flag_outliers_bridge() is one of four checks in move2utils; the others are the unusual-movement check mt_flag_outliers() (probability-based), the there-and-back check mt_flag_outliers_detour() (geometric, time-insensitive path-vs-displacement ratio), and the speed check mt_flag_speed_cap() (step-level physiological cap). They are complementary:

  • mt_flag_outliers() is probability-based; it does best on multi-state, behaviourally mixed tracks where the gap-aware auto-difference carries the signal.
  • mt_flag_outliers_bridge() is geometric and leverage-immune; it does best on long clean tracks and on drift or spoofing.
  • mt_flag_outliers_detour() is time-insensitive and scale-invariant; it catches there-and-back spikes at sparse sampling, where the bridge check’s width-scaling loses sensitivity.
  • mt_flag_speed_cap() is step-level; it catches the edges of coherent multi-fix error blocks that per-fix checks cannot reach.

The unified mt_clean_track() runs all four for you. A simple standalone pipeline can instead flag the union (or the intersection, depending on your cost function) of two of them; the example below pairs bridge with the unusual-movement check, the most common combination for moderately-sampled tracks:

prob   <- mt_flag_outliers(cpfA_p)
bridge <- mt_flag_outliers_bridge(cpfA_p, method = "directional", plot = FALSE)

both_flags <- prob$is_outlier | bridge$is_outlier
cat("probability:", sum(prob$is_outlier), "\n")
cat("bridge     :", sum(bridge$is_outlier), "\n")
cat("union      :", sum(both_flags), "\n")

For routine use, mt_clean_track() does this composition for you and adds the speed cap, a rule that combines the four checks into one decision, and a block-expansion pass that catches coherent multi-fix clusters that per-fix scoring cannot resolve. By default the combine rule is consensus = "evidence_corroborated": each check’s evidence is weighed on a common scale, and a location is flagged when the combined evidence is positive and either at least two checks agree or the there-and-back check alone is overwhelmingly sure. You can pick another rule via consensus ="class_aware" (the earlier default), "strict", "majority", "speed_trusted", "weighted_evidence", "any" and "custom" (see ?mt_flag_consensus). Supply v_max when species biology gives you a physiological cap — that lets the pipeline peel spoof- or teleport-class clusters at their edges before the bridge and unusual-movement checks run on what survives.

clean <- mt_clean_track(cpfA_p)
clean <- mt_clean_track(eagle_track, v_max = 30)  # eagle ~30 m/s ceiling

Iterative refinement

A single pass can miss outliers that were themselves used as neighbours by other outliers. iterations = n re-runs the check on the currently-clean subset up to n times (default 3), stopping early once nothing new is flagged. Raise it when your tracks have long runs of consecutive bad fixes.

Disabling neighbour smearing

Next to a real outlier, its two immediate neighbours can look anomalous too, because one of their bridge expectations rests on a corrupted point. dedup_neighbours = TRUE (default) suppresses this neighbour-smearing with a small peak-picking pass. Turn it off only if you suspect it is masking real clustered errors.

Further reading