xyt answers one question: given a series of rasters that
changes through time, and a set of points that each carry their own
date-time, what is the value of the series at each point?
The series is not held in memory. It is described by a reader function, so it can be far larger than memory and can live anywhere the reader can reach. Only the time slices some point actually needs are read, and each of those is read exactly once.
A series with no files behind it
Everything in this vignette that runs uses
synthetic_reader(), a reader over a small in-memory series
whose values are known in closed form. It exists so the machinery can be
exercised, and its answers checked against arithmetic, without any
files, network or credentials.
Each slice holds the value 100 * x + y + i, where
x and y are the coordinates of the cell centre
and i is the one-based position of the slice in the series.
The default series is ten daily slices starting 2000-01-01.
read <- synthetic_reader()
head(read(returnfiles = TRUE))
#> date fullname
#> 1 2000-01-01 synthetic-001
#> 2 2000-01-02 synthetic-002
#> 3 2000-01-03 synthetic-003
#> 4 2000-01-04 synthetic-004
#> 5 2000-01-05 synthetic-005
#> 6 2000-01-06 synthetic-006Extracting values
xyt is a data.frame (or matrix) of three columns: x, y
and a time. The time column may be POSIXct,
Date or character.
xyt <- data.frame(
x = c(0.5, 2.5),
y = c(0.5, 3.5),
t = as.POSIXct(c("2000-01-01", "2000-01-05"), tz = "UTC")
)
extract_xyt(read, xyt)
#> [1] 51.5 258.5The first point sits on slice 1, the second on slice 5, so by the
closed form the answers are 100 * 0.5 + 0.5 + 1 and
100 * 2.5 + 3.5 + 5. simple extraction snaps
each point to the centre of the cell it falls in, which is why
fractional coordinates come back on the half-cell.
Interpolating in time and in space
By default a point takes its value from a single slice. With
ctstime = TRUE the value is interpolated linearly in time
between the two slices that bracket the point:
mid <- data.frame(x = 0.5, y = 0.5,
t = as.POSIXct("2000-01-03 12:00:00", tz = "UTC"))
extract_xyt(read, mid) # nearest slice
#> [1] 53.5
extract_xyt(read, mid, ctstime = TRUE) # interpolated in time
#> [1] 54method = "bilinear" interpolates in space as well, using
a distance-weighted average of the four nearest cell centres rather than
snapping to one cell:
extract_xyt(read, mid, method = "bilinear")
#> [1] 53.5
extract_xyt(read, mid, ctstime = TRUE, method = "bilinear")
#> [1] 54Because the synthetic field is linear in x, y and time, the fully
interpolated answer is exactly 100 * x + y + i + p, where
p is the fraction of the way between slices.
Points outside the series
A point further in time from any slice than tolerance
(by default 1.5 times the spacing of the series) is returned as
NA, with a warning, rather than stopping the whole
call:
off <- data.frame(x = 0.5, y = 0.5,
t = as.POSIXct("2001-06-01", tz = "UTC"))
extract_xyt(read, off)
#> Warning: 1 of 1 points are more than 1.5 days from any slice; returning NA for
#> those
#> [1] NAThe reader contract
A reader is any function supporting two calls:
read(returnfiles = TRUE, ...) # -> data.frame with a `date` column
read(date, inputfiles = files, ...) # -> single-layer SpatRasterThe first call is the catalogue: one row per available slice, in ascending date order. The second reads one slice. Nothing in the contract is specific to any collection, which is why the tests run against a synthetic series and why readers written before this package satisfy it unchanged.
xyt_reader_check() runs those calls in the order
extract_xyt() will make them and reports what came back.
Use it when wiring up a new source:
invisible(xyt_reader_check(synthetic_reader()))
#> PASS is a function
#> PASS read(returnfiles = TRUE) succeeds
#> PASS the catalogue is a data.frame: data.frame
#> PASS the catalogue has a 'date' column: date, fullname
#> PASS the dates are date-times: POSIXct/POSIXt
#> PASS the dates have no missing values
#> PASS the dates are in ascending order
#> PASS the catalogue has at least one row: 10 rows
#> PASS the series has a resolution: 1 days
#> PASS read(date, inputfiles = files) succeeds
#> PASS it returns a SpatRaster: SpatRaster
#> PASS it returns one layer: 1 layers
#> PASS it declares a coordinate reference system
#> PASS a second date reads too
#> PASS the two slices have the same geometry
#> PASS the two slices differ in value: identical slices usually mean the date argument was ignoredWriting a reader without the flag
The returnfiles switch is awkward to write by hand,
because the return type depends on the value of an argument.
xyt_source() writes it for you from two ordinary functions:
one that returns the catalogue, and one that reads a slice.
r <- lapply(1:2, function(i) {
x <- terra::rast(terra::ext(0, 4, 0, 4), resolution = 1, crs = "EPSG:4326")
terra::values(x) <- i
x
})
src <- xyt_source(
slice = function(date, files, ...) r[[which(files$date == date)]],
catalogue = data.frame(
date = as.POSIXct(c("2000-01-01", "2000-01-02"), tz = "UTC")
)
)
extract_xyt(src, data.frame(x = 1, y = 1,
t = as.POSIXct("2000-01-02", tz = "UTC")))
#> [1] 2A catalogue supplied as a function is called once, the first time it is needed, and reused for the life of the source. That matters when building it costs a directory listing or a network round-trip.
A real collection
The example below is not evaluated, because building a vignette must
not reach the network, but it shows the shape of a reader over a genuine
time series: the twelve monthly mean-temperature layers of WorldClim,
downloaded once with the geodata package
and wrapped as an xyt_source().
library(geodata)
## twelve monthly layers: wc2.1_10m_tavg_01 ... _12
tavg <- worldclim_global(var = "tavg", res = 10, path = tempdir())
## a catalogue of one row per month, dated to a representative year
catalogue <- data.frame(
date = as.POSIXct(sprintf("2020-%02i-15", 1:12), tz = "UTC")
)
worldclim_src <- xyt_source(
slice = function(date, files, ...) {
i <- which(files$date == date)
tavg[[i]]
},
catalogue = catalogue
)
## a couple of points, each in a different month
pts <- data.frame(
lon = c(147.3, 2.35),
lat = c(-42.9, 48.85),
time = as.POSIXct(c("2020-01-15", "2020-07-15"), tz = "UTC")
)
## check the reader against the contract, then extract
xyt_reader_check(worldclim_src)
extract_xyt(worldclim_src, pts)
## interpolate between monthly slices for points mid-month
extract_xyt(worldclim_src, pts, ctstime = TRUE, method = "bilinear")The same pattern covers any collection a reader can reach: local
files, a /vsicurl/ path, a database, an in-memory list.
extract_xyt() only ever asks for the catalogue and, one at
a time, the slices it needs.
Reading slices in parallel
Each slice is read independently and returns a plain numeric vector;
no SpatRaster crosses a process boundary. So the reads can
run on mirai
daemons, and extract_xyt() will use them without being told
to:
mirai::daemons(6)
mirai::everywhere({ library(terra) }) # load whatever the reader needs
extract_xyt(worldclim_src, pts)
mirai::daemons(0)mirai::everywhere() matters: a daemon runs the reader in
a fresh session, so any package the reader reaches for has to be loaded
there. Pass map = lapply to force serial reads. Whether
daemons are faster is a question about where the bytes come from, not
about the code — a hundred slices off a remote store parallelises well;
ten off a saturated local disk does not.