Skip to content

Commit fd935e8

Browse files
chross22claude
andcommitted
Read the FVCOM mesh itself, and draw it
accessFVCOM() returns values at points - nodes for scalars, element centroids for velocities. That is what matchData() needs and it is not the grid: an FVCOM mesh is a triangulation, and which nodes form each triangle lives in a connectivity array a point fetch never reads. Plotting what came back therefore showed a scatter of dots where the model has cells, with no way to draw a boundary, shade a cell, or see where the mesh refines - which is most of why anyone looks at an unstructured model. fvcom_mesh() reads that array and returns the triangles as POLYGONs with a DEPTH per cell, carrying no time dimension because it is the grid rather than anything measured on it. what = "nodes" and "elements" return the two point sets alone. plot_mesh() draws it, bare or shaded, and does the join when given a fetch. That join is the part worth automating: a fetch is subset to the bounding box, so its row order says nothing about the mesh's own element numbering, and joining by position would mislabel every cell without complaining. Two things only came out by trying it. Node-centred values sit at triangle *corners*, shared between the cells meeting there - and st_contains excludes boundaries, so a node join returned nothing whatsoever. It tests intersection instead, which catches corners and centroids alike. And plot.sf wants a palette function it can call with the number of breaks it chose, not a vector of colours; a vector fails with "must have one more break than colour". A triangle is kept when any vertex falls inside the box, so the mesh covers what was asked for rather than stopping short, and the edge is ragged by design - which is also why an element join leaves the outermost triangles NA rather than borrowing a neighbour's value. GOM7 remeshes between months, so date says which month's mesh to read. Also describes the five access functions up front in the README, which had been listed in a table but never actually explained, and adds the mesh plotting to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent de839bf commit fd935e8

12 files changed

Lines changed: 702 additions & 8 deletions

File tree

NAMESPACE

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export(forecast_variables)
3131
export(fvcom_archive)
3232
export(fvcom_archives)
3333
export(fvcom_dictionary)
34+
export(fvcom_mesh)
3435
export(fvcom_variables)
3536
export(grid_resolution)
3637
export(hycom_archives)
@@ -42,6 +43,7 @@ export(matchData)
4243
export(plot_coverage)
4344
export(plot_env)
4445
export(plot_matched)
46+
export(plot_mesh)
4547
export(plot_series)
4648
export(product_url)
4749
export(refresh_climate_index)

NEWS.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,31 @@
22

33
## New features
44

5+
* **The FVCOM mesh itself, through `fvcom_mesh()`.** `accessFVCOM()` returns
6+
values at points — nodes for scalars, element centroids for velocities — which
7+
is what matching needs but is not the grid. Drawing it shows dots where the
8+
model has triangles.
9+
10+
`fvcom_mesh()` reads the connectivity array a point fetch never touches and
11+
returns the triangles as `POLYGON`s, with `DEPTH` per cell. It carries no time
12+
dimension: it is the grid. `what = "nodes"` and `"elements"` return the two
13+
point sets on their own.
14+
15+
**`plot_mesh()`** draws it — bare, or shaded by a covariate. Pass a fetch as
16+
`values` and the spatial join is done for you, which matters more than it
17+
sounds: a fetch is subset to the bounding box, so its row order says nothing
18+
about the mesh's element numbering, and joining by position would silently
19+
mislabel every cell.
20+
21+
Node values sit at triangle *corners* and element values at centroids, so the
22+
join tests intersection rather than containment — a corner lies on the
23+
boundary between cells, and nothing contains it. Joining by containment
24+
returned no node values at all.
25+
26+
A triangle is kept when any vertex is inside the box, so the mesh covers what
27+
was asked for rather than stopping short of it, and the edge is ragged by
28+
design. `GOM7` remeshes between months, so `date` says which month's mesh.
29+
530
* **EML metadata, through `write_eml()`.** Writes an Ecological Metadata
631
Language document for a matched table — the standard EDI, LTER and DataONE
732
expect alongside a deposited dataset.

R/fvcom.R

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,3 +911,174 @@ fvcom_dictionary <- function(mesh = c("all", "node", "element")) {
911911
class(dictionary) <- c("datamatch_dictionary", "data.frame")
912912
dictionary
913913
}
914+
915+
#' Read an FVCOM mesh on its own, with no data on it
916+
#'
917+
#' [accessFVCOM()] returns values at points — nodes for scalars, element
918+
#' centroids for velocities. That is what [matchData()] needs, but it is not the
919+
#' mesh: it is a scatter of the mesh's points, and drawing it shows dots where
920+
#' the grid is triangles. This returns the triangles themselves, so the grid can
921+
#' actually be plotted.
922+
#'
923+
#' @section Why the points are not enough:
924+
#' An FVCOM mesh is a triangulation, and which nodes form each triangle lives in
925+
#' a connectivity array (`nv`) that a point fetch never reads. Without it there
926+
#' is no way to draw a cell boundary, shade a cell by its value, or show how the
927+
#' resolution changes across a shelf — all of which are the usual reasons for
928+
#' looking at an unstructured model in the first place.
929+
#'
930+
#' The result carries no time dimension and no covariates. It is the grid.
931+
#'
932+
#' @section What comes back:
933+
#' \itemize{
934+
#' \item `"polygons"` (the default) — one `POLYGON` per element, with the
935+
#' element index and `DEPTH`, the mean of its three nodes' bathymetry. This
936+
#' is what to plot.
937+
#' \item `"nodes"` — the mesh nodes as `POINT`s, with `DEPTH`. Scalars such as
938+
#' `SST` and `BOTS` live here.
939+
#' \item `"elements"` — the element centroids as `POINT`s. Velocities and
940+
#' stresses live here.
941+
#' }
942+
#'
943+
#' @section Bounding box:
944+
#' A triangle is kept when **any** of its three vertices falls inside the box, so
945+
#' the returned mesh covers the box rather than stopping short of it. The edge is
946+
#' therefore ragged, and a triangle may extend a little beyond what was asked
947+
#' for. Clipping to the box exactly would cut triangles into shapes the model
948+
#' does not have.
949+
#'
950+
#' @section Meshes that move:
951+
#' `GOM3` has one mesh for its whole record. `GOM7` does not — it is operational
952+
#' output and remeshes between files, carrying 198,594 nodes in January 2025 and
953+
#' 207,081 from March. For an archive like that the mesh belongs to a particular
954+
#' month, so `date` says which; it defaults to the start of the record.
955+
#'
956+
#' @param archive which archive's mesh: a name from [fvcom_archives()], or a spec
957+
#' from [fvcom_archive()]
958+
#' @param bounding_box <list> optional named list with `xmin`, `xmax`, `ymin`,
959+
#' `ymax`, or an `sf`/`sfc` object. `NULL` returns the whole mesh, which for
960+
#' GOM3 is 90,415 triangles.
961+
#' @param what <char> `"polygons"`, `"nodes"`, or `"elements"`
962+
#' @param date the month whose mesh to read, for an archive that remeshes.
963+
#' Ignored for a single-mesh archive.
964+
#' @return an `sf` object: `POLYGON` for `"polygons"`, `POINT` otherwise, in
965+
#' EPSG:4326
966+
#' @examples
967+
#' \dontrun{
968+
#' bb <- list(xmin = -70, xmax = -66, ymin = 41, ymax = 44)
969+
#'
970+
#' mesh <- fvcom_mesh(bounding_box = bb)
971+
#' plot(sf::st_geometry(mesh)) # the grid itself
972+
#' plot(mesh["DEPTH"], border = NA) # shaded by bathymetry
973+
#'
974+
#' # Shade the triangles by a fetched value. Join spatially rather than by
975+
#' # position: accessFVCOM() returns only the points inside the box, so its row
976+
#' # order does not correspond to the mesh's element numbering.
977+
#' currents <- accessFVCOM(vars = "UBAR", years = 2010, months = 6,
978+
#' bounding_box = bb)
979+
#' shaded <- sf::st_join(mesh, currents["UBAR"], join = sf::st_contains)
980+
#' plot(shaded["UBAR"], border = NA)
981+
#' }
982+
#' @seealso [accessFVCOM()] for values on the mesh, [fvcom_archives()]
983+
#' @export
984+
fvcom_mesh <- function(archive = "GOM3", bounding_box = NULL,
985+
what = c("polygons", "nodes", "elements"), date = NULL) {
986+
what <- match.arg(what)
987+
988+
if (is.list(archive)) {
989+
spec <- archive
990+
if (is.null(spec$url)) {
991+
stop("An archive given as a list must carry a `url`. Build one with ",
992+
"fvcom_archive().", call. = FALSE)
993+
}
994+
} else {
995+
archives <- fvcom_archives()
996+
if (!archive %in% names(archives)) {
997+
stop("Unknown archive '", archive, "'. Built in: ",
998+
paste(names(archives), collapse = ", "),
999+
"\nTo read any other, describe it with fvcom_archive(url).",
1000+
call. = FALSE)
1001+
}
1002+
spec <- archives[[archive]]
1003+
}
1004+
1005+
# A per-month archive's url is a template, and its mesh can differ between
1006+
# months, so some month has to be named. See the Meshes that move section.
1007+
if (identical(spec$layout, "per_month")) {
1008+
when <- if (is.null(date)) spec$start else parse_dates(date)[1]
1009+
spec$url <- sprintf(spec$url, as.integer(format(when, "%Y")),
1010+
as.integer(format(when, "%Y")),
1011+
as.integer(format(when, "%m")))
1012+
}
1013+
1014+
handle <- fvcom_open(spec)
1015+
on.exit(ncdf4::nc_close(handle), add = TRUE)
1016+
1017+
if (identical(what, "elements")) {
1018+
coords <- fvcom_coordinates(handle, "element")
1019+
coords$element <- seq_len(nrow(coords))
1020+
keep <- if (is.null(bounding_box)) {
1021+
seq_len(nrow(coords))
1022+
} else {
1023+
fvcom_in_box(coords, bounding_box)
1024+
}
1025+
return(sf::st_as_sf(coords[keep, , drop = FALSE], coords = c("x", "y"),
1026+
crs = sf::st_crs(4326)))
1027+
}
1028+
1029+
nodes <- fvcom_coordinates(handle, "node")
1030+
nodes$node <- seq_len(nrow(nodes))
1031+
depth <- if (!is.null(handle$var[["h"]])) {
1032+
as.numeric(ncdf4::ncvar_get(handle, "h"))
1033+
} else {
1034+
rep(NA_real_, nrow(nodes))
1035+
}
1036+
nodes$DEPTH <- depth
1037+
1038+
if (identical(what, "nodes")) {
1039+
keep <- if (is.null(bounding_box)) {
1040+
seq_len(nrow(nodes))
1041+
} else {
1042+
fvcom_in_box(nodes, bounding_box)
1043+
}
1044+
return(sf::st_as_sf(nodes[keep, , drop = FALSE], coords = c("x", "y"),
1045+
crs = sf::st_crs(4326)))
1046+
}
1047+
1048+
if (is.null(handle$var[["nv"]])) {
1049+
stop("This archive has no `nv` connectivity array, so its triangles cannot ",
1050+
"be built.\nWithout it the mesh is only a set of points; use ",
1051+
"what = \"nodes\".", call. = FALSE)
1052+
}
1053+
# [nele, 3], one-based node indices - FVCOM is written in Fortran.
1054+
nv <- ncdf4::ncvar_get(handle, "nv")
1055+
1056+
# A triangle is kept when any vertex is inside, so the mesh covers the box
1057+
# rather than stopping short of it.
1058+
keep <- if (is.null(bounding_box)) {
1059+
seq_len(nrow(nv))
1060+
} else {
1061+
inside <- rep(FALSE, nrow(nodes))
1062+
inside[fvcom_in_box(nodes, bounding_box)] <- TRUE
1063+
which(inside[nv[, 1]] | inside[nv[, 2]] | inside[nv[, 3]])
1064+
}
1065+
if (length(keep) == 0) {
1066+
stop("No mesh triangles fall inside the bounding box.", call. = FALSE)
1067+
}
1068+
1069+
x <- nodes$x
1070+
y <- nodes$y
1071+
triangles <- lapply(keep, function(i) {
1072+
vertices <- nv[i, ]
1073+
# Closed ring: the first vertex repeated at the end, as sf requires.
1074+
ring <- cbind(x[c(vertices, vertices[1])], y[c(vertices, vertices[1])])
1075+
sf::st_polygon(list(ring))
1076+
})
1077+
1078+
out <- sf::st_sf(
1079+
element = keep,
1080+
DEPTH = rowMeans(matrix(depth[nv[keep, ]], ncol = 3)),
1081+
geometry = sf::st_sfc(triangles, crs = sf::st_crs(4326)))
1082+
attr(out, "datamatch_mesh") <- spec$mesh %||% "unstructured"
1083+
out
1084+
}

R/plot.R

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,3 +370,125 @@ pretty_steps <- function(n) {
370370
numeric_for_plot <- function(x) {
371371
if (is.numeric(x)) x else as.integer(as.factor(x))
372372
}
373+
374+
#' Map covariates on an unstructured mesh, or anywhere else
375+
#'
376+
#' [plot_env()] rasterises, which is right for a regular grid and wrong for an
377+
#' FVCOM mesh: a triangulation has no rows and columns to rasterise onto, so the
378+
#' result is either blocky or interpolated over cells the model does not have.
379+
#' This draws the geometry it is given.
380+
#'
381+
#' @section What it is for:
382+
#' Two jobs. With a mesh from [fvcom_mesh()] and nothing else, it draws the grid
383+
#' itself — which is how you see where a model resolves a shelf finely and where
384+
#' it does not. With `var` naming a column, it shades each cell by that value.
385+
#'
386+
#' @section Shading a mesh with fetched values:
387+
#' [accessFVCOM()] returns points and [fvcom_mesh()] returns triangles, so the
388+
#' two are joined spatially rather than by position — a fetch is subset to the
389+
#' bounding box, so its row order says nothing about the mesh's own numbering.
390+
#' Pass `values` and that join is done for you:
391+
#'
392+
#' ```
393+
#' mesh <- fvcom_mesh(bounding_box = bb)
394+
#' sst <- accessFVCOM(vars = "SST", years = 2010, months = 6, bounding_box = bb)
395+
#' plot_mesh(mesh, "SST", values = sst)
396+
#' ```
397+
#'
398+
#' Element-centred values (`UO`, `UBAR`, `TAUX`) sit at the centroid, one per
399+
#' triangle. Node-centred ones (`SST`, `BOTS`) sit at the *corners*, shared
400+
#' between the triangles meeting there, so each triangle takes the mean of its
401+
#' three — which is why the join tests intersection rather than containment: a
402+
#' corner is on the boundary, and nothing contains it.
403+
#'
404+
#' @param x an `sf` object with polygon or point geometry — typically from
405+
#' [fvcom_mesh()], but any `sf` object works
406+
#' @param var which column to shade by; `NULL` draws the bare geometry
407+
#' @param values optional `sf` object holding the values, from [accessFVCOM()]
408+
#' or any access function. Joined to `x` spatially.
409+
#' @param time which time step of `values` to use, as an index or a named vector
410+
#' of `YEAR`/`MONTH`/`DAY`. Only needed when `values` carries more than one.
411+
#' @param palette a [grDevices::hcl.colors()] palette name
412+
#' @param border colour for cell edges. `NA` hides them, which is what you want
413+
#' on a fine mesh where the edges would otherwise be all you see.
414+
#' @param main plot title
415+
#' @param ... passed to [plot()]
416+
#' @return the `sf` object that was drawn, invisibly, with the joined column if
417+
#' `values` was given
418+
#' @examples
419+
#' \dontrun{
420+
#' bb <- list(xmin = -70, xmax = -66, ymin = 41, ymax = 44)
421+
#' mesh <- fvcom_mesh(bounding_box = bb)
422+
#'
423+
#' plot_mesh(mesh) # the grid itself
424+
#' plot_mesh(mesh, "DEPTH") # shaded by bathymetry
425+
#'
426+
#' sst <- accessFVCOM(vars = "SST", years = 2010, months = 6, bounding_box = bb)
427+
#' plot_mesh(mesh, "SST", values = sst)
428+
#' }
429+
#' @seealso [fvcom_mesh()] for the grid, [plot_env()] for regular grids
430+
#' @export
431+
plot_mesh <- function(x, var = NULL, values = NULL, time = 1,
432+
palette = "viridis", border = NA, main = NULL, ...) {
433+
if (!inherits(x, "sf")) {
434+
stop("`x` must be an sf object, such as fvcom_mesh() returns.",
435+
call. = FALSE)
436+
}
437+
438+
label <- NULL
439+
if (!is.null(values)) {
440+
if (is.null(var)) {
441+
stop("`values` was given but `var` was not, so there is nothing to take ",
442+
"from it.", call. = FALSE)
443+
}
444+
if (!var %in% names(values)) {
445+
stop("`values` has no column '", var, "'.\nIt has: ",
446+
paste(covariate_columns(values), collapse = ", "), call. = FALSE)
447+
}
448+
449+
# One time step: a mesh cell can hold one value, so which step must be
450+
# settled before the join rather than averaged across silently.
451+
steps <- time_steps(values)
452+
if (nrow(steps$table) > 1) {
453+
step <- select_time_step(values, time)
454+
values <- values[step$rows, ]
455+
label <- step$label
456+
}
457+
458+
# st_join returns one row per (cell, point) pair, so a cell containing
459+
# several points appears several times. The row is marked first so those can
460+
# be collapsed back onto the cell they came from.
461+
marked <- x
462+
marked$.cell <- seq_len(nrow(x))
463+
# st_intersects rather than st_contains: node-centred values sit at triangle
464+
# *corners*, on the boundary between cells, and st_contains excludes
465+
# boundaries - so a node join returned nothing at all. Intersects catches
466+
# both, since an element centroid strictly inside a triangle intersects it
467+
# too.
468+
joined <- sf::st_drop_geometry(
469+
sf::st_join(marked, values[var], join = sf::st_intersects))
470+
471+
# A node-centred variable puts several points in one triangle; an
472+
# element-centred one puts exactly its centroid in it. Averaging handles
473+
# both, and a triangle containing no point stays NA.
474+
averaged <- tapply(joined[[var]], joined$.cell,
475+
function(v) if (all(is.na(v))) NA_real_ else mean(v, na.rm = TRUE))
476+
x[[var]] <- as.numeric(averaged[as.character(seq_len(nrow(x)))])
477+
}
478+
479+
if (is.null(var)) {
480+
plot(sf::st_geometry(x), border = if (is.na(border)) "grey40" else border,
481+
main = main %||% "FVCOM mesh", ...)
482+
return(invisible(x))
483+
}
484+
485+
check_columns(x, var)
486+
# plot.sf wants a palette *function* it can call with the number of breaks it
487+
# chose, not a fixed vector of colours - passing a vector gives "must have one
488+
# more break than colour".
489+
plot(x[var], pal = function(n) grDevices::hcl.colors(n, palette),
490+
border = border,
491+
main = main %||% paste0(var, if (!is.null(label)) paste0(" ", label)),
492+
...)
493+
invisible(x)
494+
}

R/provenance.R

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
#' most: which model the value came from at all.
2020
#'
2121
#' @param x an object from [accessEnvDat()], [accessFVCOM()], [accessHYCOM()],
22-
#' [accessCCMP()]
22+
#' [accessCCMP()] or [accessERDDAP()]
2323
#' @return <char> the source tag, or `NA` if the object carries none — which is
2424
#' the case for anything built by hand or produced before this was recorded
2525
#' @examples

0 commit comments

Comments
 (0)