For global-scale species distribution models or climate fields, you
need a mesh on the sphere. tulpa_mesh_sphere() generates
one by recursive subdivision of an icosahedron.
globe <- tulpa_mesh_sphere(subdivisions = 3)
globe
#> tulpa_mesh (sphere, radius = 1 ):
#> Vertices: 642
#> Triangles: 1280
#> Edges: 1920Each subdivision level quadruples the triangle count:
| Level | Vertices | Triangles |
|---|---|---|
| 0 | 12 | 20 |
| 1 | 42 | 80 |
| 2 | 162 | 320 |
| 3 | 642 | 1280 |
| 4 | 2562 | 5120 |
All vertices lie exactly on the sphere surface:
The 3D FEM assembly uses the metric tensor on each triangle’s tangent plane:
Pass observation coordinates as (longitude, latitude) in degrees:
For spatio-temporal SPDE models, the temporal dimension needs its own
mesh. tulpa_mesh_1d() produces knots with tridiagonal FEM
matrices that combine with the spatial mesh via Kronecker products.
# Monthly observations over 5 years
times <- seq(2020, 2025, by = 1/12)
m1d <- tulpa_mesh_1d(times)
m1d
#> tulpa_mesh_1d:
#> Knots: 67
#> Range: [2019.75, 2025.25]
#> Intervals: 66The extension knots (controlled by n_extend) prevent
boundary effects:
range(times) # data range
#> [1] 2020 2025
range(m1d$knots) # mesh range (extended)
#> [1] 2019.75 2025.25Mass and stiffness matrices are tridiagonal and sparse:
# No extension for cleaner demonstration
m <- tulpa_mesh_1d(seq(0, 1, by = 0.1), n_extend = 0)
# Symmetric, positive definite mass
Matrix::isSymmetric(m$C)
#> [1] TRUE
all(Matrix::diag(m$C) > 0)
#> [1] TRUE
# Stiffness row sums = 0
max(abs(Matrix::rowSums(m$G)))
#> [1] 1.776357e-15
# Total mass = domain length
sum(m$C)
#> [1] 1The 1D mesh handles irregular intervals naturally:
# Denser sampling in summer, sparser in winter
times_irr <- c(
seq(2020, 2020.25, by = 1/52), # weekly Jan-Mar
seq(2020.25, 2020.75, by = 1/365), # daily Apr-Sep
seq(2020.75, 2021, by = 1/52) # weekly Oct-Dec
)
m_irr <- tulpa_mesh_1d(times_irr, n_extend = 0)
cat(m_irr$n, "knots from", length(times_irr), "unique time points\n")
#> 210 knots from 211 unique time pointsFor SPDE models along roads, rivers, or coastlines,
tulpa_mesh_graph() builds a 1D FEM mesh along network
edges.
# Simple river network: main channel + tributary
edges <- list(
cbind(x = seq(0, 10, by = 0.5), y = rep(0, 21)), # main channel
cbind(x = c(5, 5, 5, 5), y = c(0, 2, 4, 6)) # tributary
)
g <- tulpa_mesh_graph(edges)
g
#> tulpa_mesh_graph:
#> Vertices: 24
#> Segments: 23
#> Junctions: 1 Endpoints: 3Nodes where edges meet are automatically detected:
The graph FEM matrices are structurally identical to the 1D case but assembled across the full network:
library(sf)
#> Linking to GEOS 3.13.1, GDAL 3.11.4, PROJ 9.7.0; sf_use_s2() is TRUE
line1 <- st_linestring(cbind(c(0, 5, 10), c(0, 3, 0)))
line2 <- st_linestring(cbind(c(5, 5), c(3, 8)))
g_sf <- tulpa_mesh_graph(st_sfc(line1, line2), max_edge = 1)
g_sf
#> tulpa_mesh_graph:
#> Vertices: 18
#> Segments: 17
#> Junctions: 1 Endpoints: 3For fields where the range or variance changes across space,
fem_matrices_nonstationary() computes weighted FEM matrices
in C++:
set.seed(42)
mesh <- tulpa_mesh(cbind(runif(50), runif(50)), max_edge = 0.15)
n <- mesh$n_vertices
# Range decreases from left to right
kappa <- sqrt(8) / (2 - mesh$vertices[, 1]) # shorter range on the right
tau <- rep(1, n)
ns <- fem_matrices_nonstationary(mesh, kappa, tau)
names(ns)
#> [1] "Ck" "Gk" "Ct" "C" "G" "C0" "n_mesh"With constant kappa/tau, the weighted matrices are simply scaled versions of the standard ones:
For higher accuracy with fewer mesh nodes, use 6-node quadratic elements:
set.seed(42)
mesh <- tulpa_mesh(cbind(runif(30), runif(30)))
p2 <- fem_matrices_p2(mesh)
cat("P1 nodes:", mesh$n_vertices, "\n")
#> P1 nodes: 40
cat("P2 nodes:", p2$n_mesh, "(", p2$n_vertices, "vertices +",
p2$n_midpoints, "midpoints)\n")
#> P2 nodes: 147 ( 40 vertices + 107 midpoints)The total area is preserved:
For large meshes (>50K triangles), enable parallel assembly:
set.seed(42)
mesh_large <- tulpa_mesh(cbind(runif(500), runif(500)), max_edge = 0.03)
cat(mesh_large$n_triangles, "triangles\n")
#> 3670 triangles
fem_seq <- fem_matrices(mesh_large, parallel = FALSE)
fem_par <- fem_matrices(mesh_large, parallel = TRUE)
# Results are identical
max(abs(fem_seq$C - fem_par$C))
#> [1] 2.168404e-19