---
title: "Spatial models: scope and limitations"
format: html
vignette: >
  %\VignetteIndexEntry{Spatial models: scope and limitations}
  %\VignetteEngine{quarto::html}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment  = "#>",
  eval     = FALSE
)
```

This vignette states clearly what inlaops supports for spatial inlabru
models and what it does not. Understanding this boundary prevents misuse.

## What works

### vetiver bridge — supported with spatial metadata

The `/predict` endpoint serves point-geometry spatial fits — `sf` POINT
data, `sp::SpatialPointsDataFrame` data, and SPDE meshes built from
either. Serialising, versioning, and pinning work for any in-scope `bru`
object. The handler reconstructs the spatial object from the JSON request
body, so two extra fields in `metadata$user` are required:

- `coord_names`: character vector of the coordinate column names that the
  request body will carry. **For sf fits, these are the names returned by
  `sf::st_coordinates(fit_data)` — typically `c("X", "Y")`. sf does not
  preserve the original `lon/lat`-style names you used at construction
  time.** For sp fits, these are whatever names `sp::coordinates(fit_data)`
  returns (which usually do match the originals).
- `crs`: EPSG integer, EPSG-style digit string, or WKT string. The handler
  passes it through to `sf::st_as_sf(..., crs = crs)` or `sp::CRS(...)`.

### LGCP intensity surfaces

LGCP fits (`family = "cp"`) are served the same way as any other spatial
point fit: the user passes query locations, the pred_formula evaluates
the intensity surface (`~ exp(Intercept + field)`), and the handler
returns posterior intensity samples at the query points. The output
columns describe a posterior summary, not specifically Y, so the same
`/predict` contract works — the values are just intensity rather than
counts. See `vignette("lgcp")` for a full worked example.

### Out-of-scope for the bridge (rejected at pin time)

`.vetiver_prepare_model_bru()` errors before pinning if the fit's
likelihood data is incompatible with the point-reconstruction handler:

- **Polygon / areal geometry** (`sf` polygon, `sp::SpatialPolygonsDataFrame`).
  The bridge does not yet support areal-lookup serving (planned: pin the
  polygon set separately, request body carries an area ID, handler joins).

If you hit the rejection and need vetiver versioning anyway, fit the
model and write your own plumber handler that calls `inlabru::predict()`
— vetiver will still pin and version the fit object. The custom-plumber
pattern in `vignette("lgcp")` (section 3) applies just as well to
areal/polygon fits today.

### Areal lookup — planned

When polygon / areal serving lands, the design will be:

- Pin the polygon set (an `sf` polygon column or `SpatialPolygonsDataFrame`)
  separately on the same board, e.g. as `wildlife_polygons`.
- Reference it from the model's `metadata$user`:

  ```r
  v$metadata$user <- list(
    pred_formula = "~ exp(Intercept + field)",
    n_samples    = 200L,
    area_id      = "muni_code",          # request column with the area ID
    polygons_pin = "wildlife_polygons"   # pin name on the same board
  )
  ```

- The handler reads the polygon set once at startup via `pins::pin_read()`,
  joins each request row's `area_id` to the matching polygon, and calls
  `inlabru::predict()` on the assembled `sf` object.
- The wire contract becomes one ID column + the model covariates, instead
  of coordinates. No client-side geometry serialisation.

This pattern handles BYM2 and similar areal models without dragging
polygon WKT through every request body. It is not implemented yet — see
the [areal serving issue](https://github.com/novica/inlaops/issues/5). For now, the
custom-plumber pattern in `vignette("lgcp")` covers the same use case.

```{r}
library(inlabru)
library(INLA)
library(sp)
library(inlaops)
library(vetiver)
library(pins)

# Synthesise some toy point data on a 10x10 domain. In a real application
# obs_sp comes from your sampling effort.
set.seed(2026)
n_obs  <- 60
obs_df <- data.frame(
  x = runif(n_obs, 0, 10),
  y = runif(n_obs, 0, 10)
)
obs_df$value <- 1 + 0.3 * obs_df$x + rnorm(n_obs, sd = 0.5)
obs_sp <- sp::SpatialPointsDataFrame(
  coords      = obs_df[, c("x", "y")],
  # Not `y`: that name is taken by a coordinate, and requests carry both.
  data        = data.frame(value = obs_df$value),
  proj4string = sp::CRS(SRS_string = "EPSG:32632")
)

# Build mesh and SPDE component (fmesher::fm_mesh_2d_inla replaces the
# deprecated INLA::inla.mesh.2d in inlabru >= 2.12).
mesh  <- fmesher::fm_mesh_2d_inla(
  loc.domain = cbind(c(0, 10, 10, 0), c(0, 0, 10, 10)),
  max.edge   = c(1, 3)
)
spde  <- inla.spde2.pcmatern(mesh, prior.range = c(3, 0.5),
                                   prior.sigma  = c(1, 0.01))

# Fit a spatial Gaussian model. inlabru >= 2.12 uses bru_obs() in place
# of the deprecated like().
cmp <- ~ field(coordinates, model = spde) + Intercept(1)
fit <- bru(
  cmp,
  bru_obs(formula = value ~ ., family = "gaussian", data = obs_sp),
  options = bru_options(control.compute = list(config = TRUE))
)

# Wrap and pin. coord_names and crs are required for the /predict endpoint.
# Pass metadata at construction so it is in place before vetiver_api() runs.
v <- vetiver_model(
  fit, "spatial_gaussian",
  metadata = list(
    pred_formula = "~ Intercept + field",
    n_samples    = 500L,
    coord_names  = c("x", "y"),   # column names in obs_sp that hold coordinates
    crs          = 32632           # EPSG code; use NA if data has no CRS
  )
)
board <- board_temp()
vetiver_pin_write(board, v)
```

The `/predict` endpoint reconstructs an `sp` or `sf` object from the incoming
JSON using `coord_names` and `crs`, then passes it to `predict.bru()`, which
handles the SPDE mesh projection internally. The prediction formula controls
what is returned — linear predictor, response scale, or any derived quantity.

If `coord_names` is not supplied and a spatial model is detected, the handler
will error with a message explaining what to add to `metadata$user`.

### broom tidiers — supported for the fixed part

`tidy()` (provided by inlabru >= 2.15.0) extracts fixed effects and
hyperparameters. For spatial models,
the hyperparameters include SPDE range and marginal variance:

```{r}
tidy(fit, effects = "fixed")

tidy(fit, effects = "hyperpar")

glance(fit)
```

`augment()` adds fitted values to the training locations. The user must
supply the prediction formula.

## What does NOT work

### `bru_reg()` parsnip constructor

`bru_reg()` and `bru_cv_predict()` do **not** support spatial models.
The parsnip fit interface passes a standard data frame to `bru()`, which does
not include the mesh, SPDE object, or integration stack that spatial models
require. Attempting to use `bru_reg()` for spatial fits will either error or
silently produce a non-spatial model.

**Do not do this for spatial models:**

```{r}
# WRONG for spatial models — this drops the spatial component
bru_reg(family = "gaussian") |>
  set_engine("inlabru") |>
  fit(y ~ field + Intercept(1), data = obs_sp)
```

### `bru_cv_predict()` cross-validation helper

Standard k-fold cross-validation is not appropriate for spatial models because:

1. Training folds preserve spatial autocorrelation — holdout scores are
   optimistic relative to prediction at new locations.
2. The SPDE random effect is indexed to a fixed mesh. Re-fitting on a subset
   of data does not change the mesh or reproject the field — it just conditions
   on fewer observations, which is not the same as predicting at unobserved
   locations.
3. For point-process models (LGCP), the integration stack is built from the
   full domain, not the training fold.

For spatial cross-validation, use spatial blocking (e.g., the `blockCV`
package) and evaluate with proper scoring rules via `bru_score()`. See
`vignette("scoring")`.

## Recommended workflow for spatial models

The block below is an **illustrative skeleton** — substitute your own
`components`, `likelihood`, and `holdout_sp` to make it runnable. It is
not chunk-evaluated:

```r
library(inlabru)
library(inlaops)

# 1. Fit the full model with config = TRUE
fit <- bru(
  components,
  likelihood,
  options = bru_options(control.compute = list(config = TRUE,
                                               dic    = TRUE,
                                               waic   = TRUE))
)

# 2. Inspect with tidy/glance
tidy(fit, effects = "hyperpar")
glance(fit)

# 3. Evaluate against held-out locations with proper scoring rules
holdout_scores <- bru_score(
  fit,
  newdata      = holdout_sp,
  # Samples of Y, here for a count model; see vignette("scoring").
  pred_formula = ~ rpois(length(field), exp(Intercept + field)),
  y_col        = "counts",
  n_samples    = 1000L
)
summary(holdout_scores)

# 4. Version and serve with vetiver
library(vetiver); library(pins)
v <- vetiver_model(
  fit, "spatial_model_v1",
  metadata = list(
    pred_formula = "~ exp(Intercept + field)",
    n_samples    = 500L,
    coord_names  = c("x", "y"),  # required for /predict endpoint
    crs          = 32632
  )
)
vetiver_pin_write(board_folder("./model-store"), v)
```

## Request body contract

JSON requests to `/predict` carry one row per query point with **two
groups of columns**:

1. **Coord columns** matching `metadata$user$coord_names`, holding numeric
   coordinates in the CRS declared in `metadata$user$crs`.
2. **Covariate columns** matching the non-geometry columns of the fit's
   likelihood data — e.g., temperature, elevation, treatment indicator —
   used inside the prediction formula.

The ptype the handler builds at pin time encodes both groups, so vetiver's
JSON validator does not strip either before reconstruction. If you need a
different request schema (custom coord names, additional columns), pass a
custom prototype via `save_prototype` to `vetiver_model()`:

```{r}
v <- vetiver_model(
  fit,
  "custom_schema",
  save_prototype = tibble::tibble(lon = 0.0, lat = 0.0, x1 = 0.0)
)
v$metadata$user$coord_names <- c("lon", "lat")  # match what you sent above
```

## Docker image for spatial fits

inlabru imports sf, so every inlaops image needs GDAL/PROJ/GEOS at
runtime, spatial model or not. `vignette("docker")` has a Dockerfile on
`rocker/geospatial` that serves spatial and non-spatial fits alike.

## Multi-likelihood models

inlabru supports fitting multiple likelihoods simultaneously (e.g., combining
point observations with areal aggregates). The vetiver bridge handles these
correctly as long as you pass a custom prototype via `save_prototype` to
`vetiver_model()`, because `vetiver_ptype.bru()` only reads from the first
likelihood:

```{r}
v <- vetiver_model(
  fit,
  "multi_lhood_spatial",
  save_prototype = tibble::tibble(x = 0, y = 0)
)
```
