Skip to contents
Show setup code
library(stLMM)
library(RhpcBLASctl)
library(sf)
library(tidyverse)
library(knitr)
library(kableExtra)

source("article-utils.R")

set.seed(1)
theme_set(theme_bw(base_size = 12))

article_id <- "wa-unit-two-stage-continuous-space-time-tcc-fiaunit-var-svc"
data_dir <- "wa_data"

1 Purpose

This article carries forward the two-stage unit-response target used in the previous articles and changes the latent process. The binary stage still models agb_live_mg_ha > 0, the positive stage still models square-root positive agb_live_mg_ha, row-level TCC remains the auxiliary covariate, and the county-year target remains the grid-supported mean of agb_live_mg_ha.

The new feature is the space-time process. The earlier unit-response articles used county-year areal effects, first through simple county and year effects and then through car_time(county_fips, year). Here, the latent process is indexed by continuous projected coordinates and measurement time.

A continuous space-time process also makes prediction less tied to the administrative units used for fitting. Once the model is fit, predictions can be made on any user-defined spatial extent or grid with the required covariates, then summarized over counties, ownerships, ecological regions, management areas, or other supports. This is a central advantage of the unit-response space-time approach used in related forest-biomass applications (Finley et al. 2011; May and Finley 2025).

The positive Gaussian stage carries forward the FIA-unit-specific residual variance from the previous article. That variance refinement is paired here with a positive-stage spatially varying coefficient (SVC), so the TCC-biomass relationship can change smoothly over space rather than being forced to use one global positive-biomass slope.

The fitted model is intentionally rich. It combines a two-stage response, continuous space-time NNGP intercept processes, FIA-unit-specific residual variances, and a spatially varying TCC effect in the positive biomass stage. Before using this kind of model for a final analysis, check that the data identify the added parameters and compare predictive performance using WAIC or, preferably, a cross-validation study.

For plot row \(j\) with coordinates \(\mathbf{s}_j\), measurement time \(t_j\), county \(a_j\), FIA unit \(u_j\), and standardized TCC \(x_j\), the model is

\[ z_j \sim \operatorname{bernoulli}(p_j), \qquad \operatorname{logit}(p_j) = \gamma_0 + \gamma_1 x_j + w^z(\mathbf{s}_j, t_j), \]

and, for positive biomass rows,

\[ q_j = \delta_0 + \{\delta_1 + v^+(\mathbf{s}_j)\}x_j + w^+(\mathbf{s}_j, t_j) + r_j,\qquad r_j \sim \operatorname{normal}(0,\tau^2_{u_j}), \]

where \(z_j = \mathbb{1}(y_j > 0)\) and \(q_j = \sqrt{y_j^+}\). The processes \(w^z(\mathbf{s}_j, t_j)\) and \(w^+(\mathbf{s}_j, t_j)\) are occurrence-stage and positive-stage space-time intercept surfaces, respectively, and \(v^+(\mathbf{s}_j)\) is the spatial departure from the global positive-stage TCC slope. Prediction combines draw-matched binary and positive-stage draws on grid row \(g\),

\[ \tilde{y}^{(m)}_g = \tilde{z}^{(m)}_g\{\tilde{q}^{(m)}_g\}^2, \]

then averages over grid rows within county-year.

2 Data

Show data-reading code
wa_counties <- read_rds(file.path(data_dir, "wa_counties.rds"))
unit_plots <- read_csv(file.path(data_dir, "wa_unit_plots.csv"), show_col_types = FALSE)
direct_estimates <- read_csv(file.path(data_dir, "wa_direct_estimates.csv"), show_col_types = FALSE)
prediction_grid <- read_csv(
  file.path(data_dir, "wa_unit_prediction_grid_5km.csv"),
  show_col_types = FALSE
)

wa_counties <- wa_counties |>
  mutate(
    county_fips = as.character(county_fips),
    fia_unit = factor(fia_unit)
  )

unit_plots <- unit_plots |>
  mutate(
    county_fips = as.character(county_fips),
    county_fips = factor(county_fips),
    fia_unit = factor(fia_unit),
    year = as.integer(year),
    time = as.numeric(time),
    x_km = x / 1000,
    y_km = y / 1000,
    biomass_positive = as.integer(agb_live_mg_ha > 0)
  ) |>
  arrange(county_fips, year, plot_id)

direct_estimates <- direct_estimates |>
  mutate(
    county_fips = as.character(county_fips),
    year = as.integer(year)
  )

plot_years <- sort(unique(unit_plots$year))
tcc_center <- mean(unit_plots$tcc_mean, na.rm = TRUE)
tcc_scale <- sd(unit_plots$tcc_mean, na.rm = TRUE)

unit_plots <- unit_plots |>
  mutate(tcc_mean_scaled = (tcc_mean - tcc_center) / tcc_scale)

county_levels <- levels(unit_plots$county_fips)
fia_unit_levels <- levels(unit_plots$fia_unit)
fia_unit_names <- unit_plots |>
  distinct(fia_unit, fia_unit_name) |>
  arrange(fia_unit)

prediction_grid <- prediction_grid |>
  mutate(
    county_fips = as.character(county_fips),
    county_fips = factor(county_fips, levels = county_levels),
    fia_unit = factor(fia_unit, levels = fia_unit_levels),
    year = as.integer(year),
    time = as.numeric(year),
    tcc_mean_scaled = (tcc_mean - tcc_center) / tcc_scale
  ) |>
  filter(year %in% plot_years) |>
  arrange(county_fips, year, x, y)

positive_plots <- unit_plots |>
  filter(biomass_positive == 1) |>
  mutate(biomass_sqrt = sqrt(agb_live_mg_ha))

The plot rows include time, a decimal measurement year used by the continuous space-time process. The prediction grid is annual, so its prediction time is set to the integer year for the county-year support being summarized. Plot-level and grid-level TCC values are annual point samples from the USDA Forest Service Tree Canopy Cover raster products (USDA Forest Service 2026). The model uses the same plot-based TCC standardization for plot rows and prediction-grid rows.

Show data summary code
data_summary <- tibble(
  quantity = c(
    "plot rows",
    "positive-biomass plot rows",
    "FIA units",
    "5 km prediction-grid rows",
    "measurement years"
  ),
  value = c(
    fmt_int(nrow(unit_plots)),
    fmt_int(nrow(positive_plots)),
    fmt_int(n_distinct(unit_plots$fia_unit)),
    fmt_int(nrow(prediction_grid)),
    paste(range(plot_years), collapse = "-")
  )
)

show_table(data_summary, caption = "Unit-response data for the continuous space-time model with spatial SVC.")
Unit-response data for the continuous space-time model with spatial SVC.
quantity value
plot rows 6,935
positive-biomass plot rows 3,991
FIA units 5
5 km prediction-grid rows 63,306
measurement years 2016-2024
Show plot map code
ggplot() +
  geom_sf(data = wa_counties, fill = "grey96", color = "grey70", linewidth = 0.2) +
  geom_point(
    data = unit_plots,
    aes(x, y, color = time),
    size = 0.35,
    alpha = 0.55
  ) +
  coord_sf(expand = FALSE) +
  scale_color_gradientn(colors = stlmm_palette(), name = "measurement time") +
  labs(x = NULL, y = NULL)

3 Model Settings

The occurrence and positive-biomass intercept processes use multi_res_sep_exp, the two-scale separable exponential space-time covariance proposed by May and Finley (2025). The covariance is a weighted sum of broad and local separable exponential components. The broad component represents large-scale, slowly varying space-time structure, while the local component represents shorter-range departures. Coordinates are modeled in kilometers and time is modeled in years so the spatial and temporal decay parameters have direct range interpretations.

The positive-stage TCC SVC uses a simpler spatial exponential NNGP. That reflects the working assumption that the positive-biomass relationship with TCC can differ among regions but is not expected to change materially over the measurement period.

stLMM parameterizes exponential correlations as exp(-phi * distance). If range_05 is the distance where correlation reaches 0.05, then phi = -log(0.05) / range_05. The broad component uses lower spatial decay and the local component uses higher spatial decay through non-overlapping prior ranges.

In the model settings below, chains are kept intentionally short so the vignette renders quickly. Longer runs with at least 15,000 MCMC samples per chain produced satisfactory convergence diagnostics for all model parameters. The starting values below are based on those longer runs. For an actual analysis, use dispersed starting values and longer runs, with appropriate warmup and convergence checks.

Show model settings code
cor_at_range <- 0.05

decay_from_range <- function(range_05) {
  -log(cor_at_range) / range_05
}

## Broad process: approximately 25-100 km and 1-500 years.
phi_1_bounds <- decay_from_range(rev(c(25, 100)))
lambda_1_bounds <- decay_from_range(rev(c(1, 500)))

## Local process: approximately 1-25 km and 1-500 years.
phi_2_bounds <- decay_from_range(rev(c(1, 25)))
lambda_2_bounds <- decay_from_range(rev(c(1, 500)))

## Spatial SVC process: approximately 10-150 km.
phi_svc_bounds <- decay_from_range(rev(c(10, 150)))

n_samples <- 1000
burnin <- 1
chains <- 3
n_keep <- 25
posterior_thin <- max(1L, floor((n_samples - burnin) / n_keep))
posterior_sub_sample <- list(start = burnin + 1, thin = posterior_thin)
n_report <- 500

## Let stLMM use OpenMP threads while preventing BLAS from nesting threads
## inside the sampler and prediction calculations.
blas_set_num_threads(1)
n_omp_threads <- as.integer(Sys.getenv("STLMM_OMP_THREADS", unset = "25"))

fit_st_scale <- 2
pred_st_scale <- fit_st_scale

sqrt_response_sd <- sd(positive_plots$biomass_sqrt, na.rm = TRUE)

The st_scale value affects NNGP ordering and neighbor search, not the covariance function itself. Here, one year of separation is treated like about two kilometers for ordering and neighbor search. The covariance still evaluates spatial distance in kilometers and temporal distance in years.

Show starting value code
jitter_start <- function(value, amount = 0.01, bounds = NULL) {
  out <- rep(value, chains) * exp(rnorm(chains, 0, amount))

  if (!is.null(bounds)) {
    out <- pmin(pmax(out, bounds[1] + 1e-8), bounds[2] - 1e-8)
  }

  out
}


occurrence_start <- list(
  beta = c(
    `(Intercept)` = -0.18,
    tcc_mean_scaled = 10
  ),
  nngp_1 = list(
    sigma_sq = 80,
    alpha = 0.52,
    phi_1 = 0.031,
    lambda_1 = 0.039,
    phi_2 = 1.7,
    lambda_2 = 1.6
  )
)

biomass_start <- list(
  resid = list(
    tau_sq = c(
      `5` = 28,
      `6` = 25,
      `7` = 26,
      `8` = 12,
      `9` = 11
    )
  ),
  nngp_1 = list(
    sigma_sq = 2.3,
    alpha = 0.74,
    phi_1 = 0.037,
    lambda_1 = 0.059,
    phi_2 = 1.6,
    lambda_2 = 1.5
  ),
  nngp_2 = list(
    sigma_sq = 2.4,
    phi = 0.027
  )
)

occurrence_starting <- list(
  beta = occurrence_start$beta,
  nngp_1 = list(
    sigma_sq = jitter_start(occurrence_start$nngp_1$sigma_sq),
    alpha = jitter_start(occurrence_start$nngp_1$alpha, bounds = c(0, 1)),
    phi_1 = jitter_start(occurrence_start$nngp_1$phi_1, bounds = phi_1_bounds),
    lambda_1 = jitter_start(occurrence_start$nngp_1$lambda_1, bounds = lambda_1_bounds),
    phi_2 = jitter_start(occurrence_start$nngp_1$phi_2, bounds = phi_2_bounds),
    lambda_2 = jitter_start(occurrence_start$nngp_1$lambda_2, bounds = lambda_2_bounds)
  )
)

biomass_starting <- list(
  resid = biomass_start$resid,
  nngp_1 = list(
    sigma_sq = jitter_start(biomass_start$nngp_1$sigma_sq),
    alpha = jitter_start(biomass_start$nngp_1$alpha, bounds = c(0, 1)),
    phi_1 = jitter_start(biomass_start$nngp_1$phi_1, bounds = phi_1_bounds),
    lambda_1 = jitter_start(biomass_start$nngp_1$lambda_1, bounds = lambda_1_bounds),
    phi_2 = jitter_start(biomass_start$nngp_1$phi_2, bounds = phi_2_bounds),
    lambda_2 = jitter_start(biomass_start$nngp_1$lambda_2, bounds = lambda_2_bounds)
  ),
  nngp_2 = list(
    sigma_sq = jitter_start(biomass_start$nngp_2$sigma_sq),
    phi = jitter_start(biomass_start$nngp_2$phi, bounds = phi_svc_bounds)
  )
)

4 Fit

The binary model uses all plot rows and models whether agb_live_mg_ha is positive.

occurrence_fit <- stLMM(
  biomass_positive ~
    tcc_mean_scaled +
    nngp(x_km, y_km, time,
         m = 15,
         cov_model = "multi_res_sep_exp",
         ordering = "maxmin",
         st_scale = fit_st_scale),
  data = unit_plots,
  family = "binomial",
  starting = occurrence_starting,
  priors = list(
    beta = normal(mean = 0, sd = 2.5),
    nngp_1 = list(
      sigma_sq = log_normal(meanlog = log(100), sdlog = 0.15),
      alpha = uniform(0, 1),
      phi_1 = uniform(phi_1_bounds[1], phi_1_bounds[2]),
      lambda_1 = uniform(lambda_1_bounds[1], lambda_1_bounds[2]),
      phi_2 = uniform(phi_2_bounds[1], phi_2_bounds[2]),
      lambda_2 = uniform(lambda_2_bounds[1], lambda_2_bounds[2])
    )
  ),
  n_samples = n_samples,
  chains = chains,
  save_process = posterior_sub_sample,
  n_omp_threads = n_omp_threads,
  verbose = TRUE,
  n_report = n_report
)

The positive stage models square-root positive biomass, carries forward FIA-unit-specific residual variances, and adds a positive-stage spatial TCC SVC. The term tcc_mean_scaled:nngp(x_km, y_km, ...) is a spatial process multiplied by the standardized TCC value, so it represents regional departures from the global positive-biomass TCC slope.

biomass_fit <- stLMM(
  biomass_sqrt ~
    tcc_mean_scaled +
    nngp(x_km, y_km, time,
         m = 15,
         cov_model = "multi_res_sep_exp",
         ordering = "maxmin",
         st_scale = fit_st_scale) +
    tcc_mean_scaled:nngp(x_km, y_km,
         m = 15,
         cov_model = "exp",
         ordering = "maxmin") +
    resid(model = "group", group = fia_unit),
  data = positive_plots,
  starting = biomass_starting,
  priors = list(
    beta = normal(mean = 0, sd = 2 * sqrt_response_sd),
    resid = list(tau_sq = half_t(df = 3, scale = sqrt_response_sd)),
    nngp_1 = list(
      sigma_sq = half_t(df = 3, scale = sqrt_response_sd),
      alpha = uniform(0, 1),
      phi_1 = uniform(phi_1_bounds[1], phi_1_bounds[2]),
      lambda_1 = uniform(lambda_1_bounds[1], lambda_1_bounds[2]),
      phi_2 = uniform(phi_2_bounds[1], phi_2_bounds[2]),
      lambda_2 = uniform(lambda_2_bounds[1], lambda_2_bounds[2])
    ),
    nngp_2 = list(
      sigma_sq = half_t(df = 3, scale = 0.5 * sqrt_response_sd),
      phi = uniform(phi_svc_bounds[1], phi_svc_bounds[2])
    )
  ),
  n_samples = n_samples,
  chains = chains,
  n_omp_threads = n_omp_threads,
  verbose = TRUE,
  n_report = n_report
)
Show fit summaries
occurrence_summary$parameters
                       mean          sd         q2.5       q50.0       q97.5
(Intercept)     -0.17055020 0.882000281 -1.914944668 -0.18556097  1.57967114
tcc_mean_scaled  9.97743523 0.729200872  8.733449938  9.89474529 11.70783308
nngp_1_sigma_sq 71.12837827 9.252177112 56.289621707 69.75632869 93.07003511
nngp_1_alpha     0.52992684 0.041377140  0.447766600  0.52827890  0.60576509
nngp_1_phi_1     0.03134155 0.001178761  0.030031384  0.03099035  0.03456605
nngp_1_lambda_1  0.03236267 0.015633883  0.007458989  0.03055200  0.06561046
nngp_1_phi_2     1.58063559 0.539949956  0.546795700  1.62226334  2.53350014
nngp_1_lambda_2  1.66649602 0.521713654  0.614287012  1.67943371  2.73000616
Show fit summaries
biomass_summary$parameters
                       mean          sd        q2.5       q50.0       q97.5
(Intercept)      9.54918956 0.246692937  9.08310735  9.54364644 10.05411771
tcc_mean_scaled  3.62081888 0.356951667  2.90709046  3.62419376  4.36743932
tau_sq_9        11.84230557 0.724981234 10.39456006 11.86705530 13.19913858
tau_sq_8        12.29822863 0.648828682 10.86757417 12.28929358 13.54536036
tau_sq_6        25.87493631 1.780531575 22.36287940 25.78260202 29.37465666
tau_sq_7        26.14674794 1.461023582 23.47157442 26.13997757 28.96430282
tau_sq_5        28.29600766 1.609589200 25.26230040 28.24182688 31.68922943
nngp_1_sigma_sq  2.35952160 0.549300942  1.34889812  2.32130432  3.61902163
nngp_2_sigma_sq  2.29800898 0.612406953  1.19645012  2.27327011  3.62914340
nngp_1_alpha     0.74743325 0.086115895  0.53867702  0.76325790  0.86367456
nngp_1_phi_1     0.03595209 0.004233696  0.03106535  0.03483039  0.04701873
nngp_1_lambda_1  0.05860021 0.022332765  0.02313445  0.05768881  0.10647616
nngp_1_phi_2     1.60795735 0.232620835  1.10678198  1.62256793  2.01399166
nngp_1_lambda_2  1.50377836 0.462077198  0.74134724  1.46819562  2.75140240
nngp_2_phi       0.02856079 0.005194730  0.02287582  0.02691175  0.04341740

The tcc_mean_scaled row in the positive-stage summary is the global TCC slope on the square-root positive-biomass scale. The nngp_2 rows describe the spatial process that modifies that slope. Thus, at a given location, the positive-stage TCC coefficient is the global slope plus the local draw from the SVC process. This SVC is used only in the positive biomass stage; the occurrence stage keeps a single global TCC slope.

5 Fitted Space-Time Ranges

The table below reports posterior median practical ranges, defined as the spatial or temporal lag where correlation is approximately 0.05. The positive-stage SVC has its own spatial covariance parameter because it is a second NNGP process.

Show fitted covariance summary code
posterior_medians <- function(fit) {
  draws <- as_samples(fit, burn = burnin, metadata = FALSE)
  apply(draws, 2, stats::median)
}

param_value <- function(x, name) {
  unname(x[name])
}

occurrence_medians <- posterior_medians(occurrence_fit)
biomass_medians <- posterior_medians(biomass_fit)

practical_ranges <- function(p, model, process) {
  tibble(
    model = model,
    component = c("broad", "local"),
    spatial_range_km = -log(cor_at_range) / c(
      param_value(p, paste0(process, "_phi_1")),
      param_value(p, paste0(process, "_phi_2"))
    ),
    temporal_range_years = -log(cor_at_range) / c(
      param_value(p, paste0(process, "_lambda_1")),
      param_value(p, paste0(process, "_lambda_2"))
    ),
    variance_weight = c(
      param_value(p, paste0(process, "_alpha")),
      1 - param_value(p, paste0(process, "_alpha"))
    )
  )
}

range_summary <- bind_rows(
  practical_ranges(occurrence_medians, "positive-biomass occurrence", "nngp_1"),
  practical_ranges(biomass_medians, "sqrt positive biomass intercept", "nngp_1"),
  tibble(
    model = "sqrt positive biomass TCC SVC",
    component = "spatial",
    spatial_range_km = -log(cor_at_range) / param_value(biomass_medians, "nngp_2_phi"),
    temporal_range_years = NA_real_,
    variance_weight = NA_real_
  )
) |>
  mutate(across(where(is.numeric), ~ round(.x, 2)))

show_table(
  range_summary,
  caption = "Posterior median practical ranges for the intercept and spatial SVC covariance components."
)
Posterior median practical ranges for the intercept and spatial SVC covariance components.
model component spatial_range_km temporal_range_years variance_weight
positive-biomass occurrence broad 96.67 98.05 0.53
positive-biomass occurrence local 1.85 1.78 0.47
sqrt positive biomass intercept broad 86.01 51.93 0.76
sqrt positive biomass intercept local 1.85 2.04 0.24
sqrt positive biomass TCC SVC spatial 111.32 NA NA
Show FIA-unit residual SD table code
biomass_draws <- as_samples(biomass_fit, burn = burnin, metadata = FALSE)
resid_var_names <- paste0("tau_sq_", fia_unit_levels)
if (!all(resid_var_names %in% colnames(biomass_draws))) {
  resid_var_names <- fia_unit_levels
}
resid_sd_draws <- sqrt(biomass_draws[, resid_var_names, drop = FALSE])

fia_resid_sd <- tibble(
  fia_unit = factor(fia_unit_levels, levels = fia_unit_levels),
  sd_mean = colMeans(resid_sd_draws),
  sd_lower = apply(resid_sd_draws, 2, quantile, probs = 0.025),
  sd_upper = apply(resid_sd_draws, 2, quantile, probs = 0.975)
) |>
  left_join(fia_unit_names, by = "fia_unit")

show_table(
  fia_resid_sd |>
    transmute(
      `FIA unit` = fia_unit_name,
      `posterior mean SD` = fmt_num(sd_mean, 2),
      `lower` = fmt_num(sd_lower, 2),
      `upper` = fmt_num(sd_upper, 2)
    ),
  caption = "FIA-unit residual standard deviations for square-root positive biomass."
)
FIA-unit residual standard deviations for square-root positive biomass.
FIA unit posterior mean SD lower upper
Puget Sound 5.32 5.03 5.63
Olympic Peninsula 5.08 4.73 5.42
Southwest 5.11 4.84 5.38
Central 3.51 3.30 3.68
Inland Empire 3.44 3.22 3.63

6 Prediction and Aggregation

The model predicts at all annual 5 km grid rows. For the full grid aggregation, this article uses independent NNGP prediction for computational tractability. Joint Vecchia prediction is the heavier alternative when the primary focus is aggregate posterior uncertainty over a dense prediction lattice. The Gaussian positive-stage predictions use posterior predictive draws on the square-root scale before squaring, so the recombined biomass draws include positive-stage residual uncertainty. A separate one-year prediction keeps draws of the positive-stage SVC process so the grid-level TCC coefficient can be summarized without storing process draws for every annual grid row.

Show aggregation helper code
prediction_sample_matrix <- function(pred, sample, include_w = FALSE) {
  as.matrix(as_samples(
    pred,
    sample = sample,
    include_w = include_w,
    metadata = FALSE
  ))
}

prediction_w_matrix <- function(pred, term) {
  draws <- prediction_sample_matrix(pred, "mu", include_w = TRUE)
  w_cols <- grep(paste0("^w_", term, "_"), colnames(draws), value = TRUE)

  if (!length(w_cols)) {
    stop("No predicted process samples found for ", term, call. = FALSE)
  }

  draws[, w_cols, drop = FALSE]
}

aggregate_grid_draws <- function(draws, grid, prefix = "") {
  county_year <- grid |>
    distinct(county_fips, county, year) |>
    arrange(county_fips, year)

  county_samples <- matrix(
    NA_real_,
    nrow = nrow(draws),
    ncol = nrow(county_year)
  )

  for (j in seq_len(nrow(county_year))) {
    ii <- which(
      grid$county_fips == county_year$county_fips[j] &
        grid$year == county_year$year[j]
    )
    county_samples[, j] <- rowMeans(draws[, ii, drop = FALSE])
  }

  county_year |>
    bind_cols(
      summarize_draw_matrix(county_samples, prefix = prefix) |>
        select(-prediction_row)
    )
}
occurrence_rec <- recover(
  occurrence_fit,
  sub_sample = posterior_sub_sample
)

biomass_rec <- recover(
  biomass_fit,
  sub_sample = posterior_sub_sample
)
occurrence_pred <- predict(
  occurrence_rec,
  newdata = prediction_grid,
  y_samples = TRUE,
  st_scale = pred_st_scale,
  return_w_samples = FALSE
)

biomass_pred <- predict(
  biomass_rec,
  newdata = prediction_grid,
  y_samples = TRUE,
  return_w_samples = FALSE
)

svc_map_year <- 2024
svc_prediction_grid <- prediction_grid |>
  filter(year == svc_map_year)

biomass_svc_pred <- predict(
  biomass_rec,
  newdata = svc_prediction_grid,
  y_samples = FALSE,
  return_w_samples = TRUE
)
Show direct-estimate join code
direct_for_compare <- direct_estimates |>
  filter(year %in% plot_years) |>
  select(
    county_fips, county, year, n,
    direct_biomass_model, direct_biomass_se_model
  )

county_summary <- county_summary |>
  mutate(county_fips = as.character(county_fips)) |>
  left_join(
    direct_for_compare,
    by = c("county_fips", "county", "year")
  )
Show prediction map code
ggplot() +
  geom_raster(
    data = prediction_map,
    aes(x, y, fill = theta_median)
  ) +
  geom_sf(data = wa_counties, fill = NA, color = "grey35", linewidth = 0.15) +
  facet_wrap(~ year, nrow = 1) +
  coord_sf(expand = FALSE) +
  scale_fill_gradientn(
    colors = stlmm_palette(),
    name = "Mg/ha",
    na.value = "grey92"
  ) +
  labs(
    title = "posterior predictive median biomass",
    x = NULL,
    y = NULL
  ) +
  theme(
    plot.title = element_text(size = 10, face = "bold", margin = margin(0, 0, 2, 0)),
    plot.margin = margin(0, 0, 0, 0)
  )

The SVC term can also be viewed directly on the prediction grid. The map below summarizes the positive-stage TCC coefficient, which is the global tcc_mean_scaled slope plus the predicted local SVC draw. The coefficient is on the square-root positive-biomass scale and is measured per one standard deviation increase in TCC. Because this SVC is spatial-only, the coefficient surface does not change by year; the displayed year is just the grid slice used for mapping.

Show TCC SVC grid map code
ggplot() +
  geom_raster(
    data = tcc_coef_map,
    aes(x, y, fill = tcc_coef_mean)
  ) +
  geom_sf(data = wa_counties, fill = NA, color = "grey35", linewidth = 0.15) +
  coord_sf(expand = FALSE) +
  scale_fill_gradientn(
    colors = stlmm_continuous_palette(palette = "navia"),
    name = "sqrt(Mg/ha)\nper SD TCC",
    na.value = "grey92"
  ) +
  labs(
    title = "posterior mean positive-stage TCC coefficient",
    subtitle = paste("prediction grid,", svc_map_year),
    x = NULL,
    y = NULL
  ) +
  theme(
    plot.title = element_text(size = 10, face = "bold", margin = margin(0, 0, 2, 0)),
    plot.subtitle = element_text(size = 9, margin = margin(0, 0, 2, 0)),
    plot.margin = margin(0, 0, 0, 0)
  )

Show selected county-year summary code
show_table(
  county_summary |>
    filter(year == 2024) |>
    arrange(desc(theta_mean)) |>
    select(county, n, direct_biomass_model, theta_mean, theta_lower, theta_upper) |>
    slice_head(n = 10) |>
    mutate(
      across(
        c(direct_biomass_model, theta_mean, theta_lower, theta_upper),
        ~ fmt_num(.x, 1)
      )
    ),
  caption = "Highest 2024 county-year posterior predictive means from the continuous space-time model with spatial SVC."
)
Highest 2024 county-year posterior predictive means from the continuous space-time model with spatial SVC.
county n direct_biomass_model theta_mean theta_lower theta_upper
Skamania 0 NA 264.0 235.7 292.2
Clallam 3 194.2 230.0 199.6 266.3
Jefferson 2 139.0 204.8 167.7 235.3
Lewis 3 80.3 185.8 165.6 212.3
Skagit 5 183.8 160.4 138.2 182.2
Grays Harbor 3 145.9 158.6 139.3 179.9
Mason 1 NA 157.4 127.2 191.1
Cowlitz 2 0.0 145.5 120.4 170.4
Snohomish 4 133.6 133.2 112.2 157.4
King 7 140.7 130.0 110.4 155.6

7 County Time Series

Show county time-series code
profile_counties <- c("Clallam", "King", "Okanogan", "Yakima")

profile_dat <- county_summary |>
  filter(county %in% profile_counties) |>
  mutate(county = factor(county, levels = profile_counties))

ggplot(profile_dat, aes(year, theta_mean)) +
  geom_ribbon(
    aes(ymin = theta_lower, ymax = theta_upper),
    fill = stlmm_color("primary"),
    alpha = 0.15,
    color = NA
  ) +
  geom_line(color = stlmm_color("primary"), linewidth = 0.75) +
  geom_point(
    aes(y = direct_biomass_model),
    color = "grey20",
    size = 1.4,
    alpha = 0.75,
    na.rm = TRUE
  ) +
  facet_wrap(~ county, scales = "free_y", ncol = 2) +
  labs(
    x = "year",
    y = "mean agb_live_mg_ha (Mg/ha)"
  )

The direct estimates remain points of comparison rather than the model target. The main comparison with the previous unit-response articles is how the continuous space-time process changes the fitted county-year trajectories after prediction and aggregation over the same grid-supported response.

8 Takeaways

This article replaces county-year areal smoothing with continuous space-time NNGP intercept processes. The response definition, TCC covariate, two-stage product, FIA-unit residual variance in the positive stage, and county-year aggregation target are carried forward from the previous unit-response articles. The added SVC makes the positive-biomass TCC effect vary over continuous space.

The continuous process is useful when the model should borrow over distance and time without forcing all variation through county-year areal effects. The tradeoff is computational and inferential: dense grid prediction is more expensive than the areal CAR-time workflow, and the richer positive-stage mean model should be checked carefully before treating it as the preferred analysis model.

Finley, Andrew O., Sudipto Banerjee, and David W. MacFarlane. 2011. “A Hierarchical Model for Quantifying Forest Variables over Large Heterogeneous Landscapes with Uncertain Forest Areas.” Journal of the American Statistical Association 106 (493): 31–48. https://doi.org/10.1198/jasa.2011.ap09653.
May, Paul B., and Andrew O. Finley. 2025. “Spatial-Temporal Prediction of Forest Attributes Using Latent Gaussian Models and Inventory Data.” Spatial Statistics 69: 100917. https://doi.org/10.1016/j.spasta.2025.100917.
USDA Forest Service. 2026. Tree Canopy Cover Datasets. https://data.fs.usda.gov/geodata/rastergateway/treecanopycover/.