Practical 3 — From fixes to trajectories
Session 3 · Data cleaning, bursts, resampling
Goals. Build a trajectory in amt and in adehabitatLT; clean it and count what you remove; quantify and visualise sampling irregularity; resample by selection and by interpolation and see the difference; label day and night; attach NDVI matched to the time of each fix.
Time: ~75 minutes. Packages: amt, adehabitatLT, terra, dplyr, tidyr, lubridate, ggplot2.
Task 1 — Build a track
raw <- read.csv("data/buffalo_kruger.csv.gz") |>
transmute(
id = individual.local.identifier,
t = as.POSIXct(timestamp, tz = "UTC"),
x = location.long,
y = location.lat
)
nrow(raw)
#> [1] 28410Now clean, counting every step. This block is the template for every dataset you will ever handle.
n0 <- nrow(raw)
clean <- raw |> filter(!is.na(x), !is.na(y))
n1 <- nrow(clean)
clean <- clean |> filter(!(x == 0 & y == 0))
n2 <- nrow(clean)
clean <- clean |> distinct(id, t, .keep_all = TRUE)
n3 <- nrow(clean)
data.frame(
step = c("raw", "drop missing coords", "drop (0,0)", "drop duplicate id+time"),
rows = c(n0, n1, n2, n3),
removed = c(NA, n0 - n1, n1 - n2, n2 - n3)
)
#> step rows removed
#> 1 raw 28410 NA
#> 2 drop missing coords 28410 0
#> 3 drop (0,0) 28410 0
#> 4 drop duplicate id+time 24663 3747It goes in your methods section, and it is the first thing a reviewer — or a future you — will want. “We removed 3,747 duplicated records (13.2%)” is a sentence you can defend. “We cleaned the data” is not.
trk <- make_track(clean, x, y, t, id = id, crs = 4326) |>
transform_coords(crs_to = 32736)
trk
#> # A tibble: 24,663 × 4
#> x_ y_ t_ id
#> * <dbl> <dbl> <dttm> <chr>
#> 1 375051. 7285726. 2005-02-17 05:05:00 Queen
#> 2 374851. 7285502. 2005-02-17 06:08:00 Queen
#> 3 374527. 7284538. 2005-02-17 07:05:00 Queen
#> 4 374492. 7284644. 2005-02-17 08:05:00 Queen
#> 5 374392. 7284667. 2005-02-17 09:05:00 Queen
#> 6 374378. 7284668. 2005-02-17 10:05:00 Queen
#> 7 374422. 7284666. 2005-02-17 11:05:00 Queen
#> 8 374430. 7284663. 2005-02-17 12:05:00 Queen
#> 9 374426. 7284662. 2005-02-17 13:05:00 Queen
#> 10 374733. 7284627. 2005-02-17 14:05:00 Queen
#> # ℹ 24,653 more rowsYour turn
1a. What class is trk? What are its columns called? 1b. Confirm the coordinates are now metres.
track_xyt — a tibble with x_, y_, t_ plus the columns you carried along. The coordinates are now six-figure eastings and seven-figure northings: UTM 36S metres.
Task 1b — The same trajectory as an ltraj
adehabitatLT stores a trajectory as a list of bursts, with the step descriptors already computed.
cilla <- trk |> filter(id == "Cilla")
ltr <- as.ltraj(xy = as.data.frame(cilla[, c("x_", "y_")]),
date = cilla$t_, id = "Cilla")
ltr
#>
#> *********** List of class ltraj ***********
#>
#> Type of the traject: Type II (time recorded)
#> * Time zone: UTC *
#> Irregular traject. Variable time lag between two locs
#>
#> Characteristics of the bursts:
#> id burst nb.reloc NAs date.begin date.end
#> 1 Cilla Cilla 3528 0 2005-07-14 04:35:00 2005-12-07 22:16:00
#>
#>
#> infolocs provided. The following variables are available:
#> [1] "pkey"
head(ld(ltr)[, c("date", "dist", "dt", "R2n", "abs.angle", "rel.angle")])
#> date dist dt R2n abs.angle rel.angle
#> 1 2005-07-14 04:35:00 13386.3440 3600 0 0.9006608 NA
#> 2 2005-07-14 05:35:00 3943.3528 7200 179194206 2.3910145 1.4903537
#> 3 2005-07-14 07:35:00 817.9605 3540 203227744 -2.1217678 1.7704030
#> 4 2005-07-14 08:34:00 593.9637 3660 180873970 -1.2480422 0.8737256
#> 5 2005-07-14 09:35:00 814.8749 3600 169047094 1.2209400 2.4689822
#> 6 2005-07-14 10:35:00 2097.6577 3540 190861649 2.3937138 1.1727738dist is step length (m), dt the interval (s), R2n the squared distance from the first fix (we use it in Session 4), and rel.angle the turning angle.
Your turn
1c. Compute step lengths and turning angles with amt and check they agree with the ltraj columns.
st_cilla <- cilla |> steps()
c(amt_median_sl = median(st_cilla$sl_),
ltraj_median_dist = median(ld(ltr)$dist, na.rm = TRUE))
#> amt_median_sl ltraj_median_dist
#> 149.4087 149.4087
# amt's ta_ and adehabitat's rel.angle are the same quantity
a <- st_cilla$ta_[-1]
b <- ld(ltr)$rel.angle[2:nrow(st_cilla)]
both <- !is.na(a) & !is.na(b)
naive <- max(abs(a[both] - b[both])) # plain subtraction
circular <- max(abs(atan2(sin(a[both] - b[both]), # difference on
cos(a[both] - b[both])))) # the circle
c(compared = sum(both), naive_max_diff = naive, circular_max_diff = circular,
NA_in_amt = sum(is.na(a)), NA_in_adehabitat = sum(is.na(b)))
#> compared naive_max_diff circular_max_diff NA_in_amt
#> 3.516000e+03 6.283185e+00 8.881784e-16 1.000000e+01
#> NA_in_adehabitat
#> 5.000000e+00Two lessons in one table.
Angles are circular. Plain subtraction reports a maximum difference of exactly 2π — one package wrote a U-turn as +π, the other as −π, which are the same direction. Measured on the circle the difference is zero. Never compare, average or subtract angles as ordinary numbers.
Undefined angles. The packages disagree on how many turns to leave undefined around zero-length steps: a step of 0 m has no direction, so the turn into or out of it cannot be computed. amt blanks more of them than adehabitatLT does. Worth knowing before you compare turning-angle distributions across packages.
Otherwise: same numbers, different containers. Use whichever the next analysis needs: ltraj for first-passage time and Brownian bridges (Sessions 4 and 6), amt for resampling and step selection.
Task 2 — Sampling rate
trk |>
summarize_sampling_rate_many(cols = "id", time_unit = "hour") |>
mutate(across(where(is.numeric), \(z) round(z, 2)))
#> # A tibble: 6 × 10
#> id min q1 median mean q3 max sd n unit
#> <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <chr>
#> 1 Queen 0.03 1 1 2.45 1 2523. 50.5 4544 hour
#> 2 Gabs 0.03 1 1 1.22 1 723. 12.0 3669 hour
#> 3 Cilla 0.05 1 1 1 1 2 0.05 3527 hour
#> 4 Mvubu 0.03 1 1 0.99 1 9 0.2 2571 hour
#> 5 Toni 0.08 1 1 1.01 1 17 0.26 5776 hour
#> 6 Pepper 0.02 1 1 2.47 1.98 1603. 24.6 4570 hourYour turn
2a. What is the programmed fix interval? 2b. Why is the mean so much larger than the median? 2c. Which individual has the worst gap, and how long is it in days?
The median is 1 hour for every animal — that is the schedule. The mean is inflated by a handful of enormous gaps:
gaps <- trk |>
group_by(id) |>
arrange(t_, .by_group = TRUE) |>
mutate(dt_h = as.numeric(difftime(t_, lag(t_), units = "hours"))) |>
ungroup()
gaps |>
group_by(id) |>
summarise(
median_dt = median(dt_h, na.rm = TRUE),
mean_dt = round(mean(dt_h, na.rm = TRUE), 1),
max_gap_days = round(max(dt_h, na.rm = TRUE) / 24, 1),
gaps_over_1d = sum(dt_h > 24, na.rm = TRUE),
.groups = "drop"
)
#> # A tibble: 6 × 5
#> id median_dt mean_dt max_gap_days gaps_over_1d
#> <chr> <dbl> <dbl> <dbl> <int>
#> 1 Cilla 1 1 0.1 0
#> 2 Gabs 1 1.2 30.1 2
#> 3 Mvubu 1 1 0.4 0
#> 4 Pepper 1 2.5 66.8 40
#> 5 Queen 1 2.4 105. 9
#> 6 Toni 1 1 0.7 0Always report the median. A mean sampling interval is a summary of your collar failures, not of your sampling design.
Task 3 — Hunt the speed outliers
First, look. Then delete.
steps_raw <- trk |>
group_by(id) |>
arrange(t_, .by_group = TRUE) |>
mutate(
dt_h = as.numeric(difftime(t_, lag(t_), units = "hours")),
dist_m = sqrt((x_ - lag(x_))^2 + (y_ - lag(y_))^2),
speed_kmh = (dist_m / 1000) / dt_h
) |>
ungroup() |>
filter(!is.na(speed_kmh))
summary(steps_raw$speed_kmh)
#> Min. 1st Qu. Median Mean 3rd Qu. Max.
#> 0.00000 0.03129 0.15101 0.29545 0.38204 63.10252
sum(steps_raw$speed_kmh > 15)
#> [1] 4Your turn
3a. Look at the fastest steps. Are they long or are they brief?
steps_raw |>
slice_max(speed_kmh, n = 10) |>
transmute(id, t_, dt_min = round(dt_h * 60, 1),
dist_m = round(dist_m), speed_kmh = round(speed_kmh))
#> # A tibble: 10 × 5
#> id t_ dt_min dist_m speed_kmh
#> * <chr> <dttm> <dbl> <dbl> <dbl>
#> 1 Toni 2006-04-20 19:10:00 60 63103 63
#> 2 Toni 2006-04-20 18:10:00 61 63067 62
#> 3 Pepper 2006-05-24 05:06:00 60 17379 17
#> 4 Pepper 2006-05-24 06:07:00 61 16804 17
#> 5 Cilla 2005-07-14 05:35:00 60 13386 13
#> 6 Queen 2005-09-15 07:41:00 70 11783 10
#> 7 Pepper 2006-05-23 15:06:00 60 9898 10
#> 8 Queen 2006-05-26 04:16:00 2 314 9
#> 9 Pepper 2005-10-21 23:02:00 3 255 5
#> 10 Queen 2005-03-26 00:00:00 60 4728 5The worst offenders have tiny time gaps, not large distances — a couple of hundred metres over a minute or two. These are near-duplicate transmissions that survived the exact-duplicate filter.
Confirm it across all the fast steps:
plot(steps_raw$dt_h * 60, steps_raw$speed_kmh, log = "xy",
pch = 16, cex = 0.25, col = adjustcolor("#1f6f78", 0.25),
xlab = "Time gap (minutes, log)", ylab = "Speed km/h (log)",
main = "Fast steps are short-interval steps")
abline(h = 15, col = "firebrick", lty = 2, lwd = 2)
The diagonal edge is the signature: apparent speed is bounded by (GPS error)/dt, so as dt shrinks, apparent speed explodes. This is a time problem masquerading as a speed problem — and deleting the “fast” fixes would remove perfectly good locations.
The correct remedy is to resample onto a regular grid, which is Task 4.
Task 4 — Resample into bursts
This is where almost everyone writes a silent bug. Try the obvious thing first.
# The "obvious" pipeline — grouped by individual
naive <- trk |>
group_by(id) |>
track_resample(rate = hours(1), tolerance = minutes(10)) |>
filter_min_n_burst(min_n = 3) |>
ungroup()
nrow(trk)
#> [1] 24663
nrow(naive)
#> [1] 10395
naive |> count(id, name = "kept")
#> # A tibble: 6 × 2
#> id kept
#> <chr> <int>
#> 1 Cilla 2731
#> 2 Gabs 1921
#> 3 Mvubu 166
#> 4 Pepper 644
#> 5 Queen 1741
#> 6 Toni 3192Look at the per-animal counts. Mvubu goes from 2,572 fixes to a couple of hundred — but Mvubu’s data are fine: 98.8% of that animal’s intervals are within 10 minutes of one hour.
track_resample() walks the rows in time order and builds one grid across everything you hand it. With six animals interleaved, fixes from animal B fall between the grid points of animal A, and most of the track is discarded. No error. No warning. Just a much smaller dataset.
The documented amt idiom is to split, resample each animal separately, and recombine:
trk |> count(id, name = "before") |>
left_join(naive |> count(id, name = "grouped_WRONG"), by = "id") |>
left_join(trk_hr |> count(id, name = "nested_right"), by = "id") |>
mutate(pct_kept = round(100 * nested_right / before, 1))
#> # A tibble: 6 × 5
#> id before grouped_WRONG nested_right pct_kept
#> * <chr> <int> <int> <int> <dbl>
#> 1 Cilla 3528 2731 3518 99.7
#> 2 Gabs 3670 1921 3562 97.1
#> 3 Mvubu 2572 166 2544 98.9
#> 4 Pepper 4571 644 3086 67.5
#> 5 Queen 4545 1741 4354 95.8
#> 6 Toni 5777 3192 5764 99.8Now we keep ~93% of the fixes, which is what an hourly schedule with occasional failures should give.
Your turn
4a. Recompute speeds on the correctly resampled track. Did the problem go away? 4b. What happens if you set tolerance = minutes(1)?
st <- trk_hr |> steps_by_burst()
summary(as.numeric(st$dt_, units = "mins"))
#> Min. 1st Qu. Median Mean 3rd Qu. Max.
#> 54 60 60 60 60 70
sp <- (st$sl_ / 1000) / as.numeric(st$dt_, units = "hours")
round(quantile(sp, c(0.5, 0.9, 0.99, 0.999, 1)), 2)
#> 50% 90% 99% 99.9% 100%
#> 0.15 0.73 1.93 3.05 63.10
sum(sp > 15)
#> [1] 4Every step now spans 50–70 minutes, and the time-artefact speeds are gone: the median is a few hundred metres per hour, which is what a buffalo does.
But a handful of steps remain absurdly fast. These are different animals entirely — look at them:
Sixty-five kilometres in an hour. That is not a short time gap; it is a genuine spatial outlier — a corrupt fix that teleported the animal and back. Now that every step spans the same interval, a fast step really does mean a long distance, and a speed filter finally does what you meant it to do.
plot(as.numeric(st$dt_, units = "mins"), sp, log = "y", pch = 16, cex = 0.3,
col = adjustcolor("#1f6f78", 0.3),
xlab = "Step duration (minutes)", ylab = "Speed km/h (log)",
main = "After resampling: fast = far, not brief")
abline(h = 15, col = "firebrick", lty = 2, lwd = 2)
Compare this with the plot in Task 3: the diagonal artefact edge has gone, and what is left above the line is a small, isolated cluster of real errors.
A tight tolerance discards fixes that arrived a few minutes late — real data you would rather keep. A loose one lets “hourly” steps range from 40 to 80 minutes, weakening every step-length comparison. 5–10% of the interval is a reasonable compromise; state whichever you chose.
Task 5 — Now try the bats
bat <- read.csv("data/bat_safi.csv.gz") |>
transmute(id = individual.local.identifier,
t = as.POSIXct(timestamp, tz = "UTC"),
x = location.long, y = location.lat) |>
filter(!is.na(x), !is.na(y)) |>
distinct(id, t, .keep_all = TRUE)
bat_trk <- make_track(bat, x, y, t, id = id, crs = 4326) |>
transform_coords(crs_to = 32632) # UTM 32N, Switzerland
bat_trk |>
summarize_sampling_rate_many(cols = "id", time_unit = "min") |>
mutate(across(where(is.numeric), \(z) round(z, 1))) |>
head(8)
#> # A tibble: 8 × 10
#> id min q1 median mean q3 max sd n unit
#> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <chr>
#> 1 21 1.7 5.2 9 116. 18.8 1605 354. 126 min
#> 2 289 0.1 6 9 127. 18 1623 372. 161 min
#> 3 127 0.1 7.2 10 114. 21.8 1448. 344. 114 min
#> 4 570 0.1 7 11 150. 26 1445. 394. 95 min
#> 5 146 10 10 10 89.7 12.5 1260 299. 32 min
#> 6 42 10 10 10 110 10 1310 345. 26 min
#> 7 168 8 8 8 88.9 10 2764 372. 164 min
#> 8 495 8 8 8 142. 10 4246 591. 103 minYour turn
5a. Try to resample the bats to a regular 15-minute interval. What survives? 5b. What would you do instead?
bat_15 <- bat_trk |>
nest(data = -id) |>
mutate(data = lapply(data, \(d) d |>
track_resample(rate = minutes(15), tolerance = minutes(5)) |>
filter_min_n_burst(min_n = 3))) |>
unnest(cols = data)
c(before = nrow(bat_trk), after = nrow(bat_15))
#> before after
#> 1740 802
bursts <- bat_15 |> count(id, burst_)
c(n_bursts = nrow(bursts), median_length = median(bursts$n), max_length = max(bursts$n))
#> n_bursts median_length max_length
#> 165 4 13
table(bursts$n)
#>
#> 3 4 5 6 7 8 9 10 11 13
#> 57 34 26 20 8 7 5 4 2 2Fewer than half the fixes survive — but the real problem is the shape of what is left. The 802 surviving fixes shatter into 165 bursts with a median length of 4, i.e. three steps each. There is no programmed schedule to recover: fixes arrived whenever a field worker obtained a bearing.
A hidden Markov model (Session 8) estimates transition probabilities between behavioural states. With bursts of three steps, there is almost nothing to transition — you would be asking the model to learn the dynamics of a process from fragments shorter than the process itself.
lags <- bat_trk |>
group_by(id) |>
arrange(t_, .by_group = TRUE) |>
mutate(dt_min = as.numeric(difftime(t_, lag(t_), units = "mins"))) |>
ungroup() |> filter(!is.na(dt_min), dt_min > 0)
hist(log10(lags$dt_min), breaks = 40, col = "#1f6f78", border = "white",
xlab = "log10 interval (minutes)", main = "Bat sampling intervals span five orders of magnitude")
What to do instead. Not: force a regular grid and pretend. The honest options are
- restrict to the questions these data can answer — which sites were used, broad home-range extent, night-time activity periods;
- use methods that tolerate irregular sampling, i.e. continuous-time models (
ctmm) rather than discrete-step models; - treat each night as a separate short burst and analyse only within-night movement.
Recognising which of your datasets is a “buffalo” and which is a “bat” is one of the more valuable things you will take from this course.
Task 6 — Interpolation is not resampling
adehabitatLT::redisltraj() also produces a regular hourly series — by inventing positions.
cilla_ltr <- as.ltraj(xy = as.data.frame(cilla[, c("x_", "y_")]),
date = cilla$t_, id = "Cilla")
interp <- redisltraj(cilla_ltr, 3600, type = "time")
selected <- trk_hr |> filter(id == "Cilla")
c(original_fixes = nrow(cilla),
selected_by_track_resample = nrow(selected),
produced_by_redisltraj = nrow(ld(interp)))
#> original_fixes selected_by_track_resample
#> 3528 3518
#> produced_by_redisltraj
#> 3522Your turn
6a. How many of the interpolated positions coincide with a real fix? 6b. What would interpolated positions do to a step-length distribution?
Almost none of the “hourly” positions is an observation: redisltraj() walks along the straight lines between fixes and places a point every 3,600 seconds. Every one of those points assumes the animal moved at constant speed in a straight line between fixes.
Consequences: step lengths across a gap become artificially uniform, turning angles on interpolated stretches become exactly zero, and any model fitted to them sees a smooth, persistent animal that never existed. Use interpolation for display, never as data — or use a model that interpolates with uncertainty (ctmm, crawl), which is what Sessions 6 and 8 do.
Task 7 — Day or night?
Many behaviours are diel. time_of_day() labels each fix from the sun’s position at that place and time.
cilla_tod <- selected |>
time_of_day()
cilla_steps <- cilla_tod |>
steps_by_burst(keep_cols = "start")
cilla_steps |>
group_by(tod_) |>
summarise(steps = n(), median_sl_m = round(median(sl_)),
mean_sl_m = round(mean(sl_)))
#> # A tibble: 2 × 4
#> tod_ steps median_sl_m mean_sl_m
#> <fct> <int> <dbl> <dbl>
#> 1 day 1801 221 347
#> 2 night 1714 104 218Buffalo move further per hour by day than by night — a diel pattern you must account for before interpreting anything else about their movement. In Session 8 we let the probability of switching behavioural state depend on the time of day.
Task 8 — NDVI matched to the time of each fix
The NDVI raster from Session 2 has two layers, a wet-season composite (2 Feb 2005) and a dry-season one (14 Sep 2005). Tell terra when each was taken, then let amt give every fix the value of the composite closest in time.
ndvi <- project(rast("data/kruger_ndvi_2005.tif"), "EPSG:32736")
time(ndvi) <- as.POSIXct(c("2005-02-02", "2005-09-14"), tz = "UTC")
ndvi
#> class : SpatRaster
#> size : 504, 445, 2 (nrow, ncol, nlyr)
#> resolution : 258.5037, 258.5037 (x, y)
#> extent : 338541.4, 453575.6, 7215872, 7346158 (xmin, xmax, ymin, ymax)
#> coord. ref. : WGS 84 / UTM zone 36S (EPSG:32736)
#> source(s) : memory
#> names : ndvi_2005_02_02, ndvi_2005_09_14
#> min values : -0.196056, -0.198263
#> max values : 0.804637, 0.583936
#> time : 2005-02-02 00:00:00zUTC to 2005-09-14 00:00:00zUTC (2 steps)
queen <- trk |> filter(id == "Queen", year(t_) == 2005)
queen_ndvi <- queen |>
extract_covariates_var_time(ndvi, when = "any", max_time = days(45),
name_covar = "ndvi")
queen_ndvi |>
mutate(month = month(t_, label = TRUE)) |>
group_by(month) |>
summarise(fixes = n(), with_ndvi = sum(!is.na(ndvi)),
mean_ndvi = round(mean(ndvi, na.rm = TRUE), 3))
#> # A tibble: 9 × 4
#> month fixes with_ndvi mean_ndvi
#> <ord> <int> <int> <dbl>
#> 1 Feb 285 285 0.59
#> 2 Mar 751 438 0.607
#> 3 Apr 646 0 NaN
#> 4 May 73 0 NaN
#> 5 Jun 1 0 NaN
#> 6 Sep 319 319 0.252
#> 7 Oct 676 604 0.252
#> 8 Nov 719 0 NaN
#> 9 Dec 737 0 NaNYour turn
8a. Why are most months NA? 8b. What would you need to annotate the whole year?
With max_time = days(45), a fix is annotated only if a composite exists within 45 days of it. We have two composites, so only February–March and September–October are covered. Everything else is honestly NA — far better than silently assigning February’s greenness to a July fix.
To annotate the whole year you need the full series: MOD13Q1 produces a composite every 16 days, 23 per year. The same MODISTools call that built this file fetches them all — it just takes longer:
library(MODISTools)
ndvi_ts <- mt_subset(product = "MOD13Q1", band = "250m_16_days_NDVI",
lat = -24.58, lon = 31.97, km_lr = 45, km_ab = 70,
start = "2005-01-01", end = "2006-12-31")
ndvi_ts <- mt_to_terra(ndvi_ts, reproject = TRUE) # already scaled to NDVI unitsOr upload the track to Movebank’s Env-DATA, which does the matching and the temporal interpolation for you. Either way, check the result with summary(): NDVI must lie between −1 and 1. If you see values in the thousands, the scale factor was not applied; if you see values a thousand times too small, it was applied twice.
To hand in
For one buffalo: (1) the cleaning table from Task 1; (2) a histogram of sampling intervals on a log scale; (3) two sentences stating which of the analyses in the slides’ “who tolerates irregular sampling” table you could apply to this animal without resampling, and why.
Self-check
Which cleaning order is correct?
Duplicates and near-duplicates create tiny time gaps, and tiny time gaps create enormous apparent speeds. Filter on speed first and you delete good fixes for a fault that belongs to their neighbours.
Remove duplicates, then resample so that every step spans a comparable interval, and then look at what is still implausible. By that point, a genuinely fast step really is a long distance.
You pipe six animals into group_by(id) |> track_resample(rate = hours(1)) and keep 42% of your fixes. The same animals resampled one at a time keep 93%. Why?
track_resample() walks the rows in time order and lays a single regular grid over whatever you give it. Hand it six interleaved animals and most fixes fall between grid points set by some other individual, so they are dropped.
It fails silently — no error, no warning, just a smaller dataset — which is what makes it dangerous. Use the nest() / lapply() / unnest() idiom so each animal gets its own grid.
The general lesson: not every function respects dplyr grouping. When a step drops far more data than you expected, check the per-group counts before you accept the result.
Before Session 4
vignette("p1_basics", package = "amt")- Read Powell & Mitchell (2012), What is a home range? — it is short and it will change how you read the next twenty papers.