--- title: "Extracting a raster time series at points in space and time" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Extracting a raster time series at points in space and time} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) ``` `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. ```{r} library(xyt) ``` ## 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. ```{r} read <- synthetic_reader() head(read(returnfiles = TRUE)) ``` ## Extracting 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. ```{r} 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) ``` The 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: ```{r} 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 extract_xyt(read, mid, ctstime = TRUE) # interpolated in time ``` `method = "bilinear"` interpolates in space as well, using a distance-weighted average of the four nearest cell centres rather than snapping to one cell: ```{r} extract_xyt(read, mid, method = "bilinear") extract_xyt(read, mid, ctstime = TRUE, method = "bilinear") ``` Because 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: ```{r} off <- data.frame(x = 0.5, y = 0.5, t = as.POSIXct("2001-06-01", tz = "UTC")) extract_xyt(read, off) ``` ## The reader contract A reader is any function supporting two calls: ```r read(returnfiles = TRUE, ...) # -> data.frame with a `date` column read(date, inputfiles = files, ...) # -> single-layer SpatRaster ``` The 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: ```{r} invisible(xyt_reader_check(synthetic_reader())) ``` ## Writing 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} 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"))) ``` A 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](https://CRAN.R-project.org/package=geodata) package and wrapped as an `xyt_source()`. ```{r, eval = FALSE} 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](https://CRAN.R-project.org/package=mirai) daemons, and `extract_xyt()` will use them without being told to: ```{r, eval = FALSE} 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.