AquaMaps EOV share: extinction-risk weighted habitat share by Essential Ocean Variable

Author

Ben Best

Published

2026-10-16 15:11:00

1 Overview

Where on the ocean surface do the animals and plants that we monitor as Essential Ocean Variables (EOVs) (Miloslavich et al. 2018) concentrate, and where do those concentrations fall on species that are at risk of extinction? This notebook answers that with a gridded product, aquamaps_eov, on the 0.05° global grid. It is both the pipeline that computes the product and the documentation of how it is made, so the method and the data cannot drift apart (Figure 1).

Figure 1: Framework: the WoRMS AphiaID joins the EOV seeds, the AquaMaps distributions, the IUCN ranges and the Red List; the share, weighted by extinction risk, is aggregated per EOV and scored; the STAC product feeds the atlas and other consumers.

The framework in five moves:

  1. WoRMS AphiaID is the join key. The EOVs are defined, exactly as in obisindicators and obis-hex, by 33 seed taxa of the IOOS Marine Life Data Network (IOOS Marine Life Data Network, n.d.) that are expanded to all descendants in WoRMS (WoRMS Editorial Board 2026). Every taxon here carries the same AphiaID that the OBIS-based indicators use, so the two products are comparable taxon by taxon.
  2. AquaMaps suitability, not occurrences. For each species the relative environmental suitability (1-100) of AquaMaps (Kaschner et al. 2019; Ready et al. 2010), as delivered in MarineSensitivity release v9 on the 0.05° grid, is clipped to the IUCN range of the species where one exists, so that suitable but unoccupied habitat does not count.
  3. Share, the MarineSensitivity v10 way. A species’ distribution is turned into a share: the fraction of its area-weighted habitat that falls in each cell (MarineSensitivity 2026). Every species weighs the same in an EOV, however wide its range.
  4. Extinction-risk weights. Weighting each share by the equal-step IUCN Red List weights of Butchart et al. (2004, 2007) and the Ocean Health Index (Halpern et al. 2012) gives a risk metric beside the unweighted share.
  5. Petals and a composite. Per cell and EOV the shares are summed, converted to a density and scored 0-100. The EOV scores are the petals of a flower plot and their mean is the composite all. The result is published as a STAC collection that the OceanMetrics atlas reads, and that anyone can read with rstac, pystac or DuckDB.

AquaMaps models neither birds nor seagrasses, so the seabirds and seagrasses petals are absent by construction; the product has 5 populated EOVs out of 7, and the composite averages only the petals that exist in a cell.

2 Inputs and authority

Table 1 lists everything the product is built from. Nothing is downloaded by the notebook: the MarineSensitivity files are the v9 release on this machine (MSENS_DERIVED), and the same arrays that the published AquaMaps COGs were painted from.

Code
inputs <- tribble(
  ~name,                          ~version,                  ~id_or_path,                                                       ~license,       ~role,
  "AquaMaps suitability",         "v10/2019 via MST v9",     "STAC msens-v9-am; dist/dataset=am/*.parquet",                    "CC BY-NC 3.0", "habitat suitability 1-100 per model and 0.05 degree cell",
  "IUCN range polygons (gridded)","MST v9",                  "STAC msens-v9-rng_iucn; dist/dataset=rng_iucn/*.parquet",         "IUCN terms",   "range footprint that clips the suitability",
  "Model to taxon registry",      "MST v9",                  "registry/taxon_model.parquet",                                    "CC BY 4.0",    "AquaMaps and range model to WoRMS AphiaID",
  "WoRMS",                        "download 2026-07-01",     "spp.duckdb table worms (STAC worms-2026-07-01)",                  "CC BY 4.0",    "taxonomy, EOV subtrees, accepted names",
  "IUCN Red List",                "2025 snapshot (2025-2)",  "spp.duckdb table iucn_redlist (STAC iucn-redlist-2025)",         "IUCN terms",   "category per species, weights",
  "MST taxon table (fallback)",   "MST v9",                  "v9/sdm.duckdb table taxon, column iucn_code",                     "CC BY 4.0",    "Red List category when the snapshot has none",
  "Grid cells and areas",         "MST v9",                  "v9/sdm.duckdb table cell (cell_id, lon, lat, area_km2)",          "CC BY 4.0",    "cell area A and centroids",
  "Grid template",                "global05",                "r_cellid_global.tif (pixel value = cell_id)",                     "CC BY 4.0",    "cell_id to raster pixel",
  "EOV seeds",                    "obisindicators 0.8.0",    "R/eov.R OBIS_EOV; IOOS MLDN eov_taxonomy/IdentifierList.csv (STAC ioos-mldn-eov-taxonomy)", "see source", "33 seed AphiaIDs of 7 EOVs")
kable(inputs, col.names = c("Input", "Version / snapshot", "STAC id or path", "License", "Role"))
Table 1: Inputs of the product: name, version or snapshot, STAC id or path, license and role.
Input Version / snapshot STAC id or path License Role
AquaMaps suitability v10/2019 via MST v9 STAC msens-v9-am; dist/dataset=am/*.parquet CC BY-NC 3.0 habitat suitability 1-100 per model and 0.05 degree cell
IUCN range polygons (gridded) MST v9 STAC msens-v9-rng_iucn; dist/dataset=rng_iucn/*.parquet IUCN terms range footprint that clips the suitability
Model to taxon registry MST v9 registry/taxon_model.parquet CC BY 4.0 AquaMaps and range model to WoRMS AphiaID
WoRMS download 2026-07-01 spp.duckdb table worms (STAC worms-2026-07-01) CC BY 4.0 taxonomy, EOV subtrees, accepted names
IUCN Red List 2025 snapshot (2025-2) spp.duckdb table iucn_redlist (STAC iucn-redlist-2025) IUCN terms category per species, weights
MST taxon table (fallback) MST v9 v9/sdm.duckdb table taxon, column iucn_code CC BY 4.0 Red List category when the snapshot has none
Grid cells and areas MST v9 v9/sdm.duckdb table cell (cell_id, lon, lat, area_km2) CC BY 4.0 cell area A and centroids
Grid template global05 r_cellid_global.tif (pixel value = cell_id) CC BY 4.0 cell_id to raster pixel
EOV seeds obisindicators 0.8.0 R/eov.R OBIS_EOV; IOOS MLDN eov_taxonomy/IdentifierList.csv (STAC ioos-mldn-eov-taxonomy) see source 33 seed AphiaIDs of 7 EOVs

Authority and reuse. The aim is that other projects can treat this product the way they treat the places gazetteer (oceanmetrics/places): as a citable, versioned reference they read instead of recompute. Four things make that possible. First, stable STAC ids and a semantic version, with the upstream collections named in sdm:derived_from, so the lineage is machine-readable. Second, method.json and provenance.json record every parameter, the weight table, the seeds and the size and hash of every input. Third, every bulk file carries a file:checksum (multihash sha256) and is read straight from the object store. Fourth, the collection’s describedby link points at this rendered notebook, so the human method and the data have one home. The AphiaID crosswalk eov_taxa.parquet (AphiaID by EOV by Red List category by weight, WoRMS version pinned) is itself a reusable asset: obis-hex, obisindicators and MarineSensitivity can join to it. The license is inherited from AquaMaps (CC BY-NC 3.0, non-commercial) and is stated on the collection. Table 2 lists what is published.

Code
bands_txt <- paste(seq_along(eov_bands()), eov_bands(), collapse = ", ")
outputs <- tribble(
  ~file,                              ~format,              ~description,
  "score_risk.tif, score_share.tif",  "COG Byte, nodata 255", glue("9 bands ({bands_txt}); scores 0-100, band 9 counts the reportable EOVs"),
  "density_risk.tif, density_share.tif", "COG Float32, nodata NaN", glue("{length(eov_levels())} bands (all and the EOVs); value per km2 before scoring"),
  "cells/eov=<g>/data.parquet",       "Parquet (zstd)",     "cell_id, share, risk, score_share, score_risk per cell, partitioned by EOV",
  "h3_r5.parquet",                    "Parquet (zstd)",     "area-weighted mean scores and densities per H3 cell, resolution 5",
  "eov_taxa.parquet",                 "Parquet (zstd)",     "crosswalk: AphiaID, EOV, models, range, Red List code and source, weight, cells, excluded",
  "species.parquet",                  "Parquet (zstd)",     "eov_taxa plus common_name, sp_key, sis_taxon_id and the WoRMS, IUCN and AquaMaps links: the lookup of the cell to species table",
  "cell_species/tile=<t>/data_0.parquet", "Parquet (zstd), 5 degree tiles", "cell_id, aphia_id, suit (1-100) of every species present in a 0.05 degree cell, sorted by cell_id then suit; full runs, or EOV_CELL_SPECIES=1",
  "eov_summary.csv",                  "CSV",                "per EOV and metric: n taxa, n with range, n weighted, q99",
  "method.json, provenance.json",     "JSON",               "every parameter, weights, seeds, input versions, hashes, software versions",
  "README.md, AGENTS.md",             "Markdown",           "human and agent guides, generated from the same R lists as this table",
  "thumbnail.png",                    "PNG",                "global map of the composite risk score",
  "collection.json, items/<eov>_<metric>.json", "STAC 1.1", "collection and 16 items (8 EOV values by 2 metrics)")
kable(outputs, col.names = c("File", "Format", "Description"))
Table 2: Files of the product, as staged and mirrored to s3://oceanmetrics.io-public/atlas/aquamaps_eov/.
File Format Description
score_risk.tif, score_share.tif COG Byte, nodata 255 9 bands (1 all, 2 fish, 3 hardCorals, 4 mangroves, 5 marineMammals, 6 seabirds, 7 seagrasses, 8 seaTurtles, 9 n_eov); scores 0-100, band 9 counts the reportable EOVs
density_risk.tif, density_share.tif COG Float32, nodata NaN 8 bands (all and the EOVs); value per km2 before scoring
cells/eov=/data.parquet Parquet (zstd) cell_id, share, risk, score_share, score_risk per cell, partitioned by EOV
h3_r5.parquet Parquet (zstd) area-weighted mean scores and densities per H3 cell, resolution 5
eov_taxa.parquet Parquet (zstd) crosswalk: AphiaID, EOV, models, range, Red List code and source, weight, cells, excluded
species.parquet Parquet (zstd) eov_taxa plus common_name, sp_key, sis_taxon_id and the WoRMS, IUCN and AquaMaps links: the lookup of the cell to species table
cell_species/tile=/data_0.parquet Parquet (zstd), 5 degree tiles cell_id, aphia_id, suit (1-100) of every species present in a 0.05 degree cell, sorted by cell_id then suit; full runs, or EOV_CELL_SPECIES=1
eov_summary.csv CSV per EOV and metric: n taxa, n with range, n weighted, q99
method.json, provenance.json JSON every parameter, weights, seeds, input versions, hashes, software versions
README.md, AGENTS.md Markdown human and agent guides, generated from the same R lists as this table
thumbnail.png PNG global map of the composite risk score
collection.json, items/_.json STAC 1.1 collection and 16 items (8 EOV values by 2 metrics)

3 Method

Notation: \(s\) is a WoRMS species (AphiaID), \(m\) an AquaMaps model of the species, \(c\) a 0.05° cell with area \(A_c\) (km²), and \(g\) an EOV.

1. Suitability. AquaMaps gives each model \(m\) a relative environmental suitability \(v_{m,c}\in[1,100]\) (Kaschner et al. 2019; Ready et al. 2010). A species with several models takes the cell maximum, the MarineSensitivity rule:

\[ v_{s,c} = \max_{m \in M_s} v_{m,c} \tag{1}\]

2. Range clip. Let \(R_s\) be the union of the cells of the IUCN range polygons of \(s\) (IUCN 2025). When \(R_s\) is not empty only suitable cells inside the range count; with no range the full AquaMaps distribution is used. A species whose suitable cells never meet its range has no habitat left and is excluded and counted.

\[ u_{s,c} = \begin{cases} v_{s,c}\,\mathbf{1}[c \in R_s] & R_s \neq \varnothing \\ v_{s,c} & R_s = \varnothing \end{cases} \tag{2}\]

3. Share. The share is the cell’s fraction of the species’ area-weighted habitat over its whole (global) distribution, so that \(\sum_c \text{share}_{s,c}=1\) (MarineSensitivity 2026):

\[ \text{share}_{s,c} = \frac{u_{s,c}\,A_c}{\sum_{c'} u_{s,c'}\,A_{c'}} \tag{3}\]

4. Weight. The Red List category of the species gives a weight \(w_s\) (Table 3): the equal-step weights of Butchart et al. (2004, 2007), adopted by the Ocean Health Index (Halpern et al. 2012). Data Deficient (DD), Not Evaluated and species without a category carry no weight and are left out of the weighted sum (they still count in share). The category comes from the Red List snapshot by accepted scientific name, and otherwise from the MST v9 taxon table.

\[ w_s = w(\text{category}_s),\qquad w_s \text{ undefined for DD, NE, none} \tag{4}\]

5. Per EOV. An EOV \(g\) is the union of the WoRMS subtrees of its seeds (Table 4). Per cell the two metrics are

\[ \text{share}_{c,g} = \sum_{s \in g} \text{share}_{s,c}, \qquad \text{risk}_{c,g} = \sum_{s \in g} w_s\,\text{share}_{s,c} \tag{5}\]

6. Score. Values are densities \(D = X / A_c\) (per km²), so that cell size does not drive the map. The score caps at the \(q\) quantile (default 0.99) of the densities over the cells where the EOV is present:

\[ \text{score}_{c,g} = 100\,\min\!\left(1,\ \frac{D_{c,g}}{q_{0.99}(D_g)}\right) \tag{6}\]

A cell is reportable for \(g\) when any taxon of \(g\) is present there; otherwise it is nodata. The risk score can be 0 in a reportable cell (no weighted taxon).

7. Composite. The equal-weight mean of the reportable EOV scores, so that the mean of the petals drawn in a flower plot equals the composite band:

\[ \text{score}_{c,\text{all}} = \frac{1}{|G_c|}\sum_{g \in G_c} \text{score}_{c,g}, \qquad G_c = \{g : \text{reportable}\} \tag{7}\]

The count \(|G_c|\) is stored as n_eov. An H3 twin at resolution 5 averages scores and densities with the cell area as weight. Two weighting schemes exist; ohi is the default and mst_v1 is the shifted variant of MarineSensitivity v1 (set with EOV_WEIGHTS).

Code
w_tbl <- eov_weights_table("ohi") |>
  rename(ohi = weight) |>
  left_join(rename(eov_weights_table("mst_v1"), mst_v1 = weight), by = "code")
kable(w_tbl, col.names = c("Category", "ohi (Butchart / OHI)", "mst_v1"), digits = 1)
Table 3: Extinction-risk weights by IUCN Red List category, for both schemes (NA: no weight, excluded from risk). Legacy LR/lc maps to LC; LR/nt and LR/cd map to NT.
Category ohi (Butchart / OHI) mst_v1
LC 0.0 0.2
NT 0.2 0.4
VU 0.4 0.6
EN 0.6 0.8
CR 0.8 1.0
EX 1.0 1.0
EW 1.0 1.0
DD NA NA
NE NA NA
Code
seeds |>
  group_by(eov, label) |>
  summarize(
    n_seeds = n(),
    seeds   = paste0(taxon, " (", aphiaid, ")", collapse = ", "),
    .groups = "drop") |>
  arrange(match(eov, eov_keys())) |>
  select(EOV = label, `n seeds` = n_seeds, `Seeds (AphiaID)` = seeds) |>
  kable()
Table 4: EOV seed taxa (WoRMS AphiaIDs) from the IOOS Marine Life Data Network eov_taxonomy, via obisindicators 0.8.0.
EOV n seeds Seeds (AphiaID)
Fish 3 Agnatha (1829), Chondrichthyes (1517375), Osteichthyes (152352)
Hard corals 1 Scleractinia (1363)
Mangroves 19 Combretaceae (235048), Avicennia (235033), Nypa (234450), Bruguiera (234495), Ceriops (235086), Kandelia (235089), Rhizophora (235091), Sonneratia (235106), Excoecaria (235056), Pemphis (235060), Camptostemon (235045), Heritiera (235116), Xylocarpus (235063), Osbornia (235072), Pelliciera (235075), Aegialitis (235077), Aegiceras (235068), Acrostichum (234488), Scyphiphora (235103)
Marine mammals 7 Pinnipedia (148736), Cetacea (2688), Sirenia (159502), Enhydra lutris (242598), Lutra felina (477316), Lontra canadensis (159017), Ursus maritimus (137085)
Seabirds 1 Aves (1836)
Seagrasses 1 Alismatales (153491)
Sea turtles 1 Chelonioidea (987094)

4 Pipeline

The chunks below read the inputs, run the rules of R/eov_share.R in DuckDB, write the product to a staging directory and, only when asked, upload it (Figure 2). The long DuckDB pass over the AquaMaps rows runs in batches of species (EOV_BATCH_ROWS, default 80 million rows per batch); each batch writes small partial files (partials/totals and partials/cell_eov), which are the checkpoint: a re-render skips finished batches and EOV_REDO=1 recomputes them. Full runs also build the cell to species table (EOV_CELL_SPECIES, Section 4.6): the species present in every cell, as sorted Parquet tiles with a lookup of names and links, which the atlas reads when a cell is clicked.

Figure 2: Pipeline: the chunks of this notebook, the tables and files they make, and the environment flags.

4.1 Setup

Code
# paths: inputs from the MarineSensitivity v9 release, outputs to a staging dir ----
dir_derived <- path.expand(Sys.getenv("MSENS_DERIVED", "~/_big/msens/derived"))
p <- list(
  taxon_model = file.path(dir_derived, "v9/marine-atlas/registry/taxon_model.parquet"),
  dist_am     = file.path(dir_derived, "v9/marine-atlas/dist/dataset=am"),
  dist_rng    = file.path(dir_derived, "v9/marine-atlas/dist/dataset=rng_iucn"),
  spp_db      = file.path(dir_derived, "spp.duckdb"),
  sdm_db      = file.path(dir_derived, "v9/sdm.duckdb"),
  cell_tif    = file.path(dir_derived, "r_cellid_global.tif"))
stopifnot(all(file.exists(unlist(p)) | dir.exists(unlist(p))))

dir_root <- path.expand("~/_big/oceanmetrics/aquamaps_eov")
dir_out  <- file.path(dir_root, if (is_test) "test" else ver)     # the staged product
dir_work <- file.path(dir_root, paste0("work_", basename(dir_out))) # DuckDB file, never published
for (d in c(dir_out, dir_work)) dir.create(d, showWarnings = F, recursive = T)

# DuckDB with a memory cap; inputs attached read-only ----
con <- dbConnect(duckdb(dbdir = file.path(dir_work, "eov.duckdb")))
run <- \(sql) invisible(dbExecute(con, sql))      # run a statement, print nothing
invisible(run(glue("SET memory_limit = '{mem_gb}GB'")))
invisible(run(glue("SET threads = {n_threads}")))
invisible(run(glue("SET temp_directory = '{file.path(dir_work, 'tmp')}'")))
invisible(run("SET preserve_insertion_order = false"))
invisible(run(glue("SET max_temp_directory_size = '{tmp_gb}GB'")))   # a runaway spill must not fill the disk
invisible(run(glue("ATTACH '{p$spp_db}' AS spp (READ_ONLY)")))
invisible(run(glue("ATTACH '{p$sdm_db}' AS sdm (READ_ONLY)")))

tbl_exists <- \(t) t %in% dbListTables(con)
drop_all   <- \(prefix = "eov_") {
  for (v in grep(paste0("^", prefix, "(sp_cell|sp_clip|sp_share)$"), dbListTables(con), value = T))
    run(glue("DROP VIEW IF EXISTS {v}"))
  for (t in grep(paste0("^", prefix), dbListTables(con), value = T))
    run(glue("DROP TABLE IF EXISTS {t}"))
}

# checkpoint tables are valid only for the parameters that made them ----
prm <- toJSON(list(test_n = test_n, scheme = scheme, derived = dir_derived), auto_unbox = T) |> as.character()
old <- if (tbl_exists("eov_params")) dbGetQuery(con, "SELECT p FROM eov_params")$p else ""
if (redo || !identical(old, prm)) {
  drop_all()
  dbWriteTable(con, "eov_params", data.frame(p = prm), overwrite = T)
}

tibble(
  setting = c("version", "mode", "weights", "score quantile", "verify species", "upload",
              "memory", "threads", "rows per batch", "cell to species", "staging dir", "inputs"),
  value   = c(ver, if (is_test) glue("test: first {test_n} species per EOV") else "full run",
              scheme, q_score, verify_n, if (do_s3) "EOV_S3=1" else "off",
              glue("{mem_gb} GB"), n_threads, format(batch_rows, big.mark = ",", scientific = F),
              if (do_cs) "on (EOV_CELL_SPECIES)" else "off", dir_out, dir_derived)) |>
  kable()
setting value
version 1.0.0
mode full run
weights ohi
score quantile 0.99
verify species 20
upload EOV_S3=1
memory 12 GB
threads 8
rows per batch 80,000,000
cell to species on (EOV_CELL_SPECIES)
staging dir /Users/bbest/_big/oceanmetrics/aquamaps_eov/1.0.0
inputs /Users/bbest/_big/msens/derived

4.2 Taxa

The EOV membership is the union of the WoRMS subtrees of the seeds (eov_tree_sql()), restricted to species that have an AquaMaps model. The Red List category comes from the Red List snapshot by accepted name, then from the MST v9 taxon table, and is turned into a weight.

Code
# EOV membership by WoRMS subtree ----
dbWriteTable(con, "eov_seed", seeds, overwrite = T)
run(glue("CREATE OR REPLACE TABLE eov_tree AS {eov_tree_sql('eov_seed', 'spp.worms')}"))

# AquaMaps models of WoRMS species ----
run(glue("
  CREATE OR REPLACE TABLE eov_am_map0 AS
  SELECT mdl_key, CAST(taxon_id AS BIGINT) AS aphia_id
  FROM read_parquet('{p$taxon_model}')
  WHERE ds_key = 'am' AND taxon_authority = 'worms'"))

# test mode keeps the first N species per EOV ----
rn_where <- if (is_test) glue("WHERE rn <= {test_n}") else ""
run(glue("
  CREATE OR REPLACE TABLE eov_member AS
  SELECT eov, aphia_id FROM (
    SELECT eov, aphia_id, row_number() OVER (PARTITION BY eov ORDER BY aphia_id) AS rn
    FROM (SELECT DISTINCT t.eov, m.aphia_id
          FROM eov_tree t JOIN eov_am_map0 m ON m.aphia_id = t.taxonID))
  {rn_where}"))

# IUCN range models of the same species ----
rng_map <- dbGetQuery(con, glue("
  SELECT mdl_key, CAST(taxon_id AS BIGINT) AS aphia_id
  FROM read_parquet('{p$taxon_model}')
  WHERE ds_key = 'rng_iucn' AND taxon_authority = 'worms'
    AND CAST(taxon_id AS BIGINT) IN (SELECT aphia_id FROM eov_member)")) |>
  mutate(aphia_id = as.integer(aphia_id),
         file = file.path(p$dist_rng, paste0(sub("^rng_iucn\\|", "", mdl_key), ".parquet"))) |>
  filter(file.exists(file))
dbWriteTable(con, "eov_rng_map", select(rng_map, mdl_key, aphia_id), overwrite = T)

# AquaMaps models with a partition file on disk, with n_models and has_range ----
am_map <- dbGetQuery(con, "
  SELECT mdl_key, aphia_id FROM eov_am_map0
  WHERE aphia_id IN (SELECT aphia_id FROM eov_member)") |>
  mutate(aphia_id = as.integer(aphia_id),
         file = file.path(p$dist_am, paste0(sub("^am\\|", "", mdl_key), ".parquet"))) |>
  filter(file.exists(file)) |>
  group_by(aphia_id) |>
  mutate(n_models = n()) |>
  ungroup() |>
  mutate(has_range = aphia_id %in% rng_map$aphia_id)
dbWriteTable(con, "eov_am_map", select(am_map, mdl_key, aphia_id, n_models, has_range), overwrite = T)
run("DROP TABLE IF EXISTS eov_rng")      # the global range footprint of earlier versions

# rows per file from the parquet footers (seconds, no data read) ----
n_rows_of <- function(files) {
  if (length(files) == 0) return(tibble(file = character(), n = numeric()))
  dbGetQuery(con, glue("
    SELECT file_name AS file, sum(num_rows)::DOUBLE AS n
    FROM parquet_file_metadata([{paste0(\"'\", files, \"'\", collapse = ', ')}])
    GROUP BY 1"))
}
am_map  <- am_map  |> left_join(n_rows_of(am_map$file),  by = "file") |> rename(n_rows = n)
rng_map <- rng_map |> left_join(n_rows_of(rng_map$file), by = "file") |> rename(n_rows = n)

# batches: species by EOV x row cap, never split, never mixing EOVs ----
sp_rows <- bind_rows(am_map, rng_map) |>
  group_by(aphia_id) |>
  summarize(n_rows = sum(n_rows), .groups = "drop")
members <- dbGetQuery(con, "SELECT eov, aphia_id FROM eov_member") |>
  mutate(aphia_id = as.integer(aphia_id)) |>
  left_join(sp_rows, by = "aphia_id") |>
  mutate(n_rows = coalesce(n_rows, 0))
sp_batch <- eov_make_batches(members, batch_rows)
batches <- sp_batch |>
  group_by(batch_id, eov) |>
  summarize(
    n_species = n(),
    n_rows    = sum(n_rows),
    has_multi = any(aphia_id %in% am_map$aphia_id[am_map$n_models > 1]),
    am_files  = list(am_map$file[am_map$aphia_id %in% aphia_id]),
    rng_files = list(rng_map$file[rng_map$aphia_id %in% aphia_id]),
    .groups = "drop")

# Red List category: snapshot by accepted name, then MST v9 taxon table ----
run("
  CREATE OR REPLACE TABLE eov_redlist AS
  WITH sp AS (
    SELECT DISTINCT m.aphia_id, w.scientificName AS scientific_name
    FROM eov_member m LEFT JOIN spp.worms w ON w.taxonID = m.aphia_id),
  iu AS (
    SELECT lower(taxon_scientific_name) AS k,
           CAST(red_list_category_code AS VARCHAR) AS code
    FROM spp.iucn_redlist
    WHERE latest
    QUALIFY row_number() OVER (
      PARTITION BY lower(taxon_scientific_name) ORDER BY year_published DESC) = 1),
  fb AS (
    SELECT TRY_CAST(taxon_id AS BIGINT) AS aphia_id, any_value(iucn_code) AS iucn_code
    FROM sdm.taxon
    WHERE taxon_authority = 'worms' AND iucn_code IS NOT NULL
    GROUP BY 1)
  SELECT sp.aphia_id, sp.scientific_name,
         coalesce(iu.code, fb.iucn_code) AS redlist_code,
         CASE WHEN iu.code IS NOT NULL THEN 'iucn_redlist'
              WHEN fb.iucn_code IS NOT NULL THEN 'msens_v9_taxon' END AS redlist_source
  FROM sp
  LEFT JOIN iu ON iu.k = lower(sp.scientific_name)
  LEFT JOIN fb ON fb.aphia_id = sp.aphia_id")

# weights (NULL = no weight) ----
rl <- dbGetQuery(con, "SELECT * FROM eov_redlist")
rl$weight <- redlist_weight(rl$redlist_code, scheme)
dbWriteTable(con, "eov_weight", rl[c("aphia_id", "weight")], overwrite = T)

# cell areas and centroids ----
if (!tbl_exists("eov_cell")) {
  run("CREATE TABLE eov_cell AS SELECT cell_id, lon, lat, area_km2 FROM sdm.cell")
  run("CHECKPOINT")
}

n_member <- dbGetQuery(con, "SELECT eov, count(*) AS n FROM eov_member GROUP BY 1") |>
  arrange(match(eov, eov_keys()))
n_tree   <- dbGetQuery(con, "SELECT eov, count(DISTINCT taxonID) AS n FROM eov_tree GROUP BY 1")
n_cells_total <- dbGetQuery(con, "SELECT count(*) AS n FROM eov_cell")$n
n_worms  <- dbGetQuery(con, "SELECT count(*) AS n FROM spp.worms")$n
n_rl     <- dbGetQuery(con, "SELECT count(*) AS n FROM spp.iucn_redlist WHERE latest")$n
rl_year  <- dbGetQuery(con, "SELECT max(year_published) AS y FROM spp.iucn_redlist")$y

The WoRMS snapshot has 596,103 taxa and the Red List snapshot 19,357 latest assessments (to 2025). Of the taxa under the seeds, 1.075^{4} species (fish 9898, hardCorals 730, mangroves 2, marineMammals 113, seaTurtles 7) have an AquaMaps model and enter this run; 10807 models and 3135 IUCN range files back them. The grid has 17,072,105 ocean cells.

4.3 Distributions

The AquaMaps rows are read once, in batches of species (eov_make_batches()): species are ordered by EOV and AphiaID and filled up to EOV_BATCH_ROWS rows (AquaMaps plus range rows), a batch never splits a species and never mixes EOVs, and a species above the cap gets a batch of its own (Table 5). For each batch eov_batch_sql() builds the range footprint of the batch’s species, takes the maximum over a species’ models, clips to the footprint, and writes two small partial files: the species totals \(\sum_c u_{s,c}A_c\) and the per cell and EOV share and risk. A batch whose two partial files exist is skipped, which makes the run restartable (EOV_REDO=1 deletes the partials). When the cell to species table is on (EOV_CELL_SPECIES, Section 4.6), the same pass also writes its stage-1 partials from the batch’s clipped distribution, so the AquaMaps rows are not read twice. eov_merge_sql() then sums the partials into eov_sp_total and eov_cell_eov. Memory and disk use are bounded by the batch size, not by the number of species or the size of the ranges.

Code
kable(tibble(
  `EOV batches` = nrow(batches),
  species       = sum(batches$n_species),
  `rows cap`    = format(batch_rows, big.mark = ",", scientific = F),
  `rows min`    = format(min(batches$n_rows), big.mark = ",", scientific = F),
  `rows median` = format(median(batches$n_rows), big.mark = ",", scientific = F),
  `rows q90`    = format(quantile(batches$n_rows, 0.9), big.mark = ",", scientific = F),
  `rows max`    = format(max(batches$n_rows), big.mark = ",", scientific = F)))
Table 5: Batches of species (EOV x row cap): number of batches and the distribution of rows (AquaMaps plus range) per batch.
EOV batches species rows cap rows min rows median rows q90 rows max
111 10750 80,000,000 693,106 79,157,138 79,902,549 79,977,610
Code
# partial files are valid only for the parameters that made them ----
key_n    <- glue("n{test_n}_b{format(batch_rows, scientific = F)}")
dir_part <- file.path(dir_work, glue("partials_{scheme}_{key_n}"))
dir_cs   <- file.path(dir_work, glue("cs_partials_{key_n}"))        # cell -> species, stage 1
dir_tile <- file.path(dir_out, "cell_species")                      # cell -> species, stage 2
f_cs_fin <- file.path(dir_work, glue("cell_species_{key_n}.done"))  # both stages finished
if (redo) unlink(c(dir_part, dir_cs, dir_tile, f_cs_fin), recursive = T)
for (d in file.path(dir_part, c("totals", "cell_eov"))) dir.create(d, showWarnings = F, recursive = T)
if (do_cs && !file.exists(f_cs_fin)) {
  if (!dir.exists(dir_cs)) unlink(dir_tile, recursive = T)           # tiles of other parameters
  dir.create(dir_cs, showWarnings = F, recursive = T)
}
f_log <- file.path(dir_work, "batch_log.csv")

# one pass over the batches: the share partials and, when asked, the stage-1 cell -> species partials ----
blog <- list()
t_all <- Sys.time()
for (i in seq_len(nrow(batches))) {
  b   <- batches[i, ]
  fs  <- file.path(dir_part, c("totals", "cell_eov"), sprintf("batch_%05d.parquet", b$batch_id))
  f1  <- file.path(dir_cs, sprintf("batch_%05d.done", b$batch_id))
  need_share <- !all(file.exists(fs))
  need_cs    <- do_cs && !file.exists(f_cs_fin) && !file.exists(f1)
  if (!need_share && !need_cs) { blog[[i]] <- tibble(batch_id = b$batch_id, seconds = NA_real_); next }
  if (need_cs) unlink(file.path(dir_cs, sprintf("batch_%05d", b$batch_id)), recursive = T)
  t0  <- Sys.time()
  st  <- eov_batch_sql(
    batch_id = b$batch_id, am_files = b$am_files[[1]], rng_files = b$rng_files[[1]],
    has_multi = b$has_multi, map_tbl = "eov_am_map", area_tbl = "eov_cell",
    weight_tbl = "eov_weight", eov_tbl = "eov_member", dir_partials = dir_part,
    rng_map_tbl = "eov_rng_map", eov = b$eov, share = need_share,
    dir_cs = if (need_cs) dir_cs else NULL)
  for (s_ in st) run(s_)
  if (need_cs) file.create(f1)
  blog[[i]] <- tibble(batch_id = b$batch_id, seconds = as.numeric(difftime(Sys.time(), t0, units = "secs")))
  if (i %% 10 == 0 || i == nrow(batches)) {
    cat(glue("batch {i}/{nrow(batches)}: {round(blog[[i]]$seconds, 1)} s, ",
             "{round(as.numeric(difftime(Sys.time(), t_all, units = 'mins')), 1)} min elapsed\n"),
        file = stderr())
  }
  write_csv(bind_rows(blog), f_log)
}
batch_log <- bind_rows(blog)
write_csv(batch_log, f_log)

# merge the partials ----
for (s_ in eov_merge_sql(dir_part, "eov_am_map")) run(s_)
n_sp_total <- dbGetQuery(con, "SELECT count(*) AS n, sum(excluded::INT) AS n_excl FROM eov_sp_total")
kable(tibble(
  batches = nrow(batch_log), computed = sum(!is.na(batch_log$seconds)),
  `seconds total` = sum(batch_log$seconds, na.rm = T),
  `seconds per batch` = mean(batch_log$seconds, na.rm = T)), digits = 1,
  caption = "Batches computed in this render (the others reused their partial files).")
Batches computed in this render (the others reused their partial files).
batches computed seconds total seconds per batch
111 0 0 NaN

The species table has 1.075^{4} species, of which 3 are excluded because no suitable cell falls inside their IUCN range.

4.4 Aggregate

Code
# cells that have any value, with area ----
cells <- dbGetQuery(con, "
  SELECT cell_id, lon, lat, area_km2 FROM eov_cell
  WHERE cell_id IN (SELECT DISTINCT cell_id FROM eov_cell_eov)
  ORDER BY cell_id")
n_cell <- nrow(cells)
keys   <- eov_keys()
mk_mat <- \(x) matrix(x, n_cell, length(keys), dimnames = list(NULL, keys))

# densities per km2 (NA = EOV not reportable in the cell) ----
dens <- list(share = mk_mat(NA_real_), risk = mk_mat(NA_real_))
for (g in keys) {
  d <- dbGetQuery(con, glue("SELECT cell_id, share, risk FROM eov_cell_eov WHERE eov = '{g}'"))
  if (nrow(d) == 0) next
  i <- match(d$cell_id, cells$cell_id)
  dens$share[i, g] <- d$share / cells$area_km2[i]
  dens$risk[i, g]  <- d$risk  / cells$area_km2[i]
}
populated <- keys[colSums(!is.na(dens$share)) > 0]

# scores: q99 over the reportable cells of each EOV and metric ----
sc <- list(); q_rows <- list()
for (m in c("share", "risk")) {
  smat <- matrix(NA_integer_, n_cell, length(keys), dimnames = list(NULL, keys))
  for (g in populated) {
    present <- !is.na(dens$share[, g])
    s <- eov_score(dens[[m]][, g], q = q_score, ref = dens[[m]][present, g])
    smat[, g] <- s
    q_rows[[paste(g, m)]] <- tibble(eov = g, metric = m, q99 = attr(s, "q"))
  }
  sc[[m]] <- list(
    eov     = smat,
    all     = eov_composite(smat),
    n_eov   = eov_n(smat),
    dens_all = eov_density_all(dens[[m]]))
}
q_tab <- bind_rows(q_rows)

# band matrices in the published order ----
score_bands <- \(m) cbind(all = sc[[m]]$all, sc[[m]]$eov, n_eov = sc[[m]]$n_eov)
dens_bands  <- \(m) cbind(all = sc[[m]]$dens_all, dens[[m]])
stopifnot(identical(colnames(score_bands("risk")), eov_bands()),
          identical(colnames(dens_bands("risk")), eov_levels()))

n_report <- tibble(eov = populated,
                   n_cells = colSums(!is.na(dens$share[, populated, drop = F])))

16,248,828 cells hold at least one EOV; the populated EOVs are fish, hardCorals, mangroves, marineMammals, seaTurtles.

4.5 Outputs

Code
# COGs ----
cog_opts <- c("COMPRESS=DEFLATE", "BLOCKSIZE=512", "OVERVIEWS=AUTO", "RESAMPLING=AVERAGE")
for (m in c("risk", "share")) {
  r_s <- cells_to_raster(cells$cell_id, score_bands(m), p$cell_tif, nodata = 255)
  writeRaster(r_s, file.path(dir_out, glue("score_{m}.tif")), filetype = "COG",
              datatype = "INT1U", NAflag = 255, gdal = cog_opts, overwrite = T)
  r_d <- cells_to_raster(cells$cell_id, dens_bands(m), p$cell_tif, nodata = NaN)
  writeRaster(r_d, file.path(dir_out, glue("density_{m}.tif")), filetype = "COG",
              datatype = "FLT4S", NAflag = NaN, gdal = cog_opts, overwrite = T)
  rm(r_s, r_d); invisible(gc())
}

# cells/eov=<g>/data.parquet: share, risk and scores per cell ----
to_parquet <- function(df, path) {
  dir.create(dirname(path), showWarnings = F, recursive = T)
  duckdb_register(con, "tmp_df", df)
  run(glue("COPY (SELECT * FROM tmp_df) TO '{path}' (FORMAT parquet, COMPRESSION zstd)"))
  duckdb_unregister(con, "tmp_df")
  invisible(path)
}
unlink(file.path(dir_out, "cells"), recursive = T)
for (g in populated) {
  ok <- !is.na(dens$share[, g])
  to_parquet(tibble(
    cell_id     = cells$cell_id[ok],
    share       = dens$share[ok, g] * cells$area_km2[ok],
    risk        = dens$risk[ok, g]  * cells$area_km2[ok],
    score_share = sc$share$eov[ok, g],
    score_risk  = sc$risk$eov[ok, g]),
    file.path(dir_out, "cells", glue("eov={g}"), "data.parquet"))
}
# the composite: share and risk are the sums over EOVs, scores are the composite
ok <- sc$share$n_eov > 0
to_parquet(tibble(
  cell_id     = cells$cell_id[ok],
  share       = sc$share$dens_all[ok] * cells$area_km2[ok],
  risk        = sc$risk$dens_all[ok]  * cells$area_km2[ok],
  score_share = sc$share$all[ok],
  score_risk  = sc$risk$all[ok]),
  file.path(dir_out, "cells", "eov=all", "data.parquet"))

# h3 twin, resolution 5: area-weighted mean of scores and densities ----
invisible(run("INSTALL h3 FROM community"))
invisible(run("LOAD h3"))
h3_wide <- bind_cols(
  select(cells, cell_id, lon, lat, area_km2),
  as_tibble(score_bands("risk")[, c("all", populated, "n_eov")]) |> rename_with(\(x) paste0("score_risk_", x)),
  as_tibble(score_bands("share")[, c("all", populated)]) |> rename_with(\(x) paste0("score_share_", x)),
  as_tibble(dens_bands("risk")[, c("all", populated)]) |> rename_with(\(x) paste0("density_risk_", x)),
  as_tibble(dens_bands("share")[, c("all", populated)]) |> rename_with(\(x) paste0("density_share_", x)))
h3_cols <- setdiff(names(h3_wide), c("cell_id", "lon", "lat", "area_km2"))
duckdb_register(con, "h3_wide", h3_wide)
f_h3 <- file.path(dir_out, "h3_r5.parquet")
run(glue("COPY ({eov_h3_sql('h3_wide', h3_cols, 5)}) TO '{f_h3}' (FORMAT parquet, COMPRESSION zstd)"))
duckdb_unregister(con, "h3_wide"); rm(h3_wide); invisible(gc())
n_h3 <- dbGetQuery(con, glue("SELECT count(*) AS n FROM read_parquet('{f_h3}')"))$n

# eov_taxa.parquet: the AphiaID crosswalk ----
run("
  CREATE OR REPLACE TABLE eov_taxa AS
  SELECT m.aphia_id, rl.scientific_name, m.eov,
         (SELECT string_agg(replace(mm.mdl_key, 'am|', ''), ';' ORDER BY mm.mdl_key)
          FROM eov_am_map mm WHERE mm.aphia_id = m.aphia_id) AS sp_keys,
         coalesce(t.n_models, 0)::INTEGER AS n_models,
         coalesce(t.has_range, false) AS has_range,
         (SELECT count(*) FROM eov_rng_map rm WHERE rm.aphia_id = m.aphia_id)::INTEGER AS n_range_ids,
         rl.redlist_code, rl.redlist_source, w.weight,
         coalesce(t.n_cells, 0) AS n_cells,
         coalesce(t.total_suit_area, 0.0) AS total_suit_area,
         coalesce(t.excluded, true) AS excluded
  FROM eov_member m
  LEFT JOIN eov_sp_total t ON t.aphia_id = m.aphia_id
  LEFT JOIN eov_redlist rl ON rl.aphia_id = m.aphia_id
  LEFT JOIN eov_weight w ON w.aphia_id = m.aphia_id
  ORDER BY m.eov, m.aphia_id")
f_taxa <- file.path(dir_out, "eov_taxa.parquet")
run(glue("COPY eov_taxa TO '{f_taxa}' (FORMAT parquet, COMPRESSION zstd)"))
taxa <- dbGetQuery(con, "SELECT * FROM eov_taxa")

# eov_summary.csv: per EOV and metric ----
tx_sum <- eov_summarize_taxa(taxa)
eov_summary <- tx_sum |>
  cross_join(tibble(metric = c("risk", "share"))) |>
  left_join(q_tab, by = c("eov", "metric")) |>
  bind_rows(
    tibble(eov = "all", n_taxa = n_distinct(taxa$aphia_id),
           n_with_range = n_distinct(taxa$aphia_id[taxa$has_range]),
           n_weighted   = n_distinct(taxa$aphia_id[!is.na(taxa$weight) & !taxa$excluded]),
           n_excluded   = n_distinct(taxa$aphia_id[taxa$excluded])) |>
      cross_join(tibble(metric = c("risk", "share")))) |>
  mutate(eov = factor(eov, eov_levels())) |>
  arrange(eov, metric) |>
  mutate(eov = as.character(eov))
write_csv(eov_summary, file.path(dir_out, "eov_summary.csv"))

# provenance: size, mtime and hash of every input ----
file_info <- function(path, hash_max = 2e8) {
  fi <- file.info(path)
  sha <- if (fi$size <= hash_max) file_multihash(path) else NA_character_
  tibble(path = sub(paste0("^", dir_derived, "/"), "", path),
         size = fi$size, mtime = format(fi$mtime, "%Y-%m-%dT%H:%M:%SZ", tz = "UTC"),
         sha256 = sha)
}
am_files_all <- list.files(p$dist_am, full.names = T)
manifest <- paste(basename(am_map$file), file.size(am_map$file), sep = ":") |> sort()
prov <- list(
  product = "aquamaps_eov", version = ver, test = is_test,
  generated = format(Sys.time(), "%Y-%m-%dT%H:%M:%SZ", tz = "UTC"),
  software = list(R = as.character(getRversion()), duckdb = as.character(packageVersion("duckdb")),
                  terra = as.character(packageVersion("terra")),
                  gdal = terra::gdal(), platform = R.version$platform),
  inputs = list(
    files = purrr::map(c(p$taxon_model, p$cell_tif, p$spp_db, p$sdm_db), file_info) |> bind_rows(),
    am_partitions = list(
      dir = "v9/marine-atlas/dist/dataset=am", n_files_dir = length(am_files_all),
      n_files_used = nrow(am_map), bytes_used = sum(file.size(am_map$file)),
      manifest_sha256 = digest::digest(paste(manifest, collapse = "\n"), algo = "sha256", serialize = F)),
    rng_partitions = list(
      dir = "v9/marine-atlas/dist/dataset=rng_iucn", n_files_used = nrow(rng_map),
      bytes_used = sum(file.size(rng_map$file))),
    worms = list(table = "spp.duckdb:worms", download = "2026-07-01", n_taxa = n_worms),
    iucn_redlist = list(table = "spp.duckdb:iucn_redlist", n_latest = n_rl, max_year = rl_year),
    seeds = "obisindicators 0.8.0 R/eov.R OBIS_EOV; IOOS MLDN eov_taxonomy/IdentifierList.csv"))
write_json(prov, file.path(dir_out, "provenance.json"), auto_unbox = T, pretty = T, digits = NA, null = "null")

# method.json: every parameter, weights, seeds, input versions ----
method <- list(
  product = "aquamaps_eov", version = ver,
  parameters = list(weights = scheme, score_quantile = q_score, test_n = test_n,
                    grid = "global05 0.05 degree, 7200 x 3600, cell_id row-major from top-left",
                    suitability = "AquaMaps 1-100, max over the models of a species",
                    clip = "IUCN range footprint when present, else full distribution",
                    share = "u * area_km2 / sum(u * area_km2) over the global distribution",
                    density = "value / area_km2", reportable = "any taxon of the EOV present in the cell",
                    composite = "equal-weight mean of the reportable EOV scores",
                    h3_resolution = 5L,
                    batch_rows = batch_rows,
                    batching = "species by EOV x row cap; partial files totals/ and cell_eov/ per batch, summed at the end"),
  method_detail = eov_method_detail(scheme, q_score),
  weights = list(ohi = eov_weights_table("ohi"), mst_v1 = eov_weights_table("mst_v1")),
  redlist_precedence = c("spp.duckdb:iucn_redlist (latest, by lower(accepted scientificName))",
                         "v9/sdm.duckdb:taxon.iucn_code", "none"),
  eov_bands = eov_bands(), eov_labels = as.list(eov_labels()), absent_eovs = eov_absent(),
  seeds = seeds, q99 = q_tab, summary = eov_summary,
  inputs = prov$inputs, software = prov$software)
write_json(method, file.path(dir_out, "method.json"), auto_unbox = T, pretty = T, digits = NA, null = "null", na = "null")
Code
# README.md and AGENTS.md, from the same lists as the tables of this notebook ----
files_md <- paste0("| `", outputs$file, "` | ", outputs$format, " | ", outputs$description, " |", collapse = "\n")
w_md     <- paste0("| ", w_tbl$code, " | ", w_tbl$ohi, " | ", w_tbl$mst_v1, " |", collapse = "\n")
inp_md   <- paste0("- ", inputs$name, " (", inputs$version, "; ", inputs$id_or_path, "; ", inputs$license, ")", collapse = "\n")
readme <- glue("
# AquaMaps EOV share, version {ver}{if (is_test) ' (TEST RUN, subset)' else ''}

Extinction-risk weighted share of AquaMaps habitat by Essential Ocean Variable (EOV), on the 0.05
degree global grid of MarineSensitivity v9. Method and code: https://oceanmetrics.io/workflows/aquamaps_eov.html

{eov_method_detail(scheme, q_score)}

## Files

| file | format | description |
|---|---|---|
{files_md}

## Weights (scheme `{scheme}` is used here)

| category | ohi | mst_v1 |
|---|---|---|
{w_md}

## Inputs

{inp_md}

## License and citation

CC BY-NC 3.0 (non-commercial), inherited from AquaMaps. Cite OceanMetrics (2026) AquaMaps EOV share
v{ver}, and Kaschner et al. (2019) AquaMaps: Predicted range maps for aquatic species, www.aquamaps.org,
Version 10/2019. Seabirds and seagrasses are absent: AquaMaps models neither.
")
write_lines(readme, file.path(dir_out, "README.md"))

agents <- glue("
# AGENTS.md: using the aquamaps_eov collection

- STAC collection id `aquamaps_eov`, version `{ver}`: {url_prod}/collection.json
- 16 items `<eov>_<metric>`; eov in ({paste(eov_levels(), collapse = ', ')}); metric in (risk, share).
- The cell value of an item is in its `cog` asset (`../score_<metric>.tif`), band `atlas:bidx`
  (bands: {bands_txt}); value 0-100, nodata 255. Densities (per km2) are in the `density` asset.
- `atlas:flower` = dimension eov, composite all: petals are the sibling items across `eov` for one
  metric, the centre is `all`. Seabirds and seagrasses items are empty (`atlas:absent`).
- Cells: `cells/eov=<g>/data.parquet` (cell_id is the global05 row-major 1-based cell number).
- Species in a cell (full runs): collection field `atlas:cell_species`; tile of a cell =
  `(row div 100) * 72 + col div 100`, row = (cell_id-1) div 7200, col = (cell_id-1) mod 7200; file
  `cell_species/tile=<t>/data_0.parquet` (cell_id, aphia_id, suit 1-100; sorted by cell_id, one row
  group per few cells); join `species.parquet` on aphia_id for names, total_suit_area and links.
  AphiaID crosswalk: `eov_taxa.parquet`. Parameters and hashes: `method.json`, `provenance.json`.
- Non-commercial use only (CC BY-NC 3.0); cite AquaMaps (Kaschner et al. 2019).
")
write_lines(agents, file.path(dir_out, "AGENTS.md"))

# thumbnail: global map of the composite risk score ----
r_thumb <- rast(file.path(dir_out, "score_risk.tif"), lyrs = 1) |>
  aggregate(fact = 10, fun = "mean", na.rm = T)
g_thumb <- ggplot() +
  geom_spatraster(data = r_thumb, na.rm = T) +
  scale_fill_viridis_c(na.value = "transparent", limits = c(0, 100), name = "score") +
  coord_sf(expand = F) + theme_void() + theme(legend.position = "none")
ggsave(file.path(dir_out, "thumbnail.png"), g_thumb, width = 6, height = 3, dpi = 100, bg = "white")

The product has 1,449,161 H3 cells at resolution 5 and 10750 taxon by EOV rows in the crosswalk.

4.6 Cell to species

For a clicked cell the atlas lists the species present, their suitability and extinction risk, in the way the obis-hex cell pane does. The rows are the (species, cell) pairs of the product, that is the clipped distribution \(u_{s,c}>0\) at the 0.05° cells of the map (about 6,347,722,747 rows in the full product, too many for one file), stored as Parquet tiles of 5° (at most 10,000 cells each) with columns cell_id, aphia_id and suit = \(\mathrm{round}(v_{s,c})\), sorted by cell_id and then suit descending, in row groups of 32,768 rows. The column statistics of the sorted cell_id let a reader fetch one row group for one cell. The tile of a cell is (row div 100) * 72 + col div 100 with row = (cell_id - 1) div 7200 and col = (cell_id - 1) mod 7200 (cell_of_lonlat() and tile_of_cell() in R/eov_share.R).

Stage 1 (inside the batch loop above) writes each batch’s rows partitioned by 5° row band; stage 2 loads one band at a time, writes its tiles sorted, and every tile is checked (cell_species_check(): the cell_id statistics of its row groups must be sorted and non-overlapping, otherwise the render stops). Tiles without rows are absent. The stage-1 partials are deleted at the end; each batch, band and tile is a checkpoint (EOV_REDO=1 clears them).

Code
cs_info <- NULL
if (do_cs) {
  if (!file.exists(f_cs_fin)) {
    run("SET preserve_insertion_order = true")        # the tiles must keep their ORDER BY
    for (band in 0:35) {
      f_band <- file.path(dir_cs, sprintf("band_%02d.done", band))
      if (file.exists(f_band)) next
      if (length(Sys.glob(file.path(dir_cs, "batch_*", paste0("band=", band), "*.parquet"))) > 0) {
        st <- cell_species_stage2_sql(band, dir_cs, dir_tile)
        run(st$load)
        for (cc in dbGetQuery(con, st$cols)$c) {
          t <- band * 72L + cc
          if (file.exists(cell_species_tile_path(dir_tile, t))) next
          dir.create(dirname(cell_species_tile_path(dir_tile, t)), recursive = T, showWarnings = F)
          run(st$tile(cc))
          cell_species_commit(dir_tile, t)
        }
        run(st$drop)
      }
      file.create(f_band)
      cat(glue("cell_species band {band}/35 done\n"), file = stderr())
    }
    run("SET preserve_insertion_order = false")
  }
  tile_files <- list.files(dir_tile, pattern = "data_0.parquet$", recursive = T, full.names = T)
  for (f_ in tile_files) cell_species_check(f_, con)
  tf <- paste0("'", tile_files, "'", collapse = ", ")
  tile_rows <- dbGetQuery(con, glue("
    SELECT file_name AS file, sum(num_rows)::DOUBLE AS n
    FROM parquet_file_metadata([{tf}]) GROUP BY 1"))
  cs_info <- tibble(
    n_tiles = length(tile_files), rows = sum(tile_rows$n), bytes = sum(file.size(tile_files)),
    max_rows = max(tile_rows$n), max_bytes = max(file.size(tile_files)))
  # every species-cell pair of the product is in exactly one tile row
  n_expect <- dbGetQuery(con, "SELECT sum(n_cells)::DOUBLE AS n FROM eov_sp_total WHERE NOT excluded")$n
  stopifnot("cell_species rows != sum of species n_cells" = cs_info$rows == n_expect)
  if (!file.exists(f_cs_fin)) { file.create(f_cs_fin); unlink(dir_cs, recursive = T) }
  tile_manifest <- digest::digest(
    paste(sub(paste0("^", dir_tile, "/"), "", tile_files), file.size(tile_files), sep = ":", collapse = "\n"),
    algo = "sha256", serialize = F)
  kable(cs_info |> mutate(across(c(rows, bytes, max_rows, max_bytes), \(x) format(x, big.mark = ",", scientific = F))),
        caption = "Cell to species tiles written (see also the Results table).")
} else {
  cat("cell to species table skipped (test mode without EOV_CELL_SPECIES=1)\n")
}
Cell to species tiles written (see also the Results table).
n_tiles rows bytes max_rows max_bytes
1986 6,347,722,747 8,076,059,596 27,230,930 48,255,234

The lookup species.parquet is eov_taxa.parquet plus the columns a client needs to show a species: the English common name (WoRMS, preferred name first), the AquaMaps sp_key (the one of the aquamaps_native registry for the AphiaID, else the first of the models’ keys), the IUCN sis_taxon_id and page, and the WoRMS and AquaMaps pages.

Code
# the AquaMaps page pattern: one HEAD request with a real sp_key ----
sp_probe <- dbGetQuery(con, "SELECT split_part(sp_keys, ';', 1) AS k FROM eov_taxa WHERE sp_keys IS NOT NULL LIMIT 1")$k
am_tpl   <- "https://www.aquamaps.org/preMap2.php?cache=1&SpecID={sp_key}"
am_code  <- tryCatch(
  system2("curl", c("-sIL", "-o", "/dev/null", "-w", shQuote("%{http_code}"), "--max-time", "20",
                    shQuote(sub("\\{sp_key\\}", sp_probe, am_tpl))), stdout = T),
  error = \(e) NA_character_)
if (!identical(am_code, "200")) am_tpl <- "https://www.aquamaps.org/"       # no species page: the home page

# registry of aquamaps_native (aphia_id -> sp_key); empty when offline ----
reg_rel <- glue("read_parquet('{url_pub}/atlas/aquamaps_native/registry.parquet')")
reg_ok  <- tryCatch({
  invisible(run("INSTALL httpfs")); invisible(run("LOAD httpfs"))
  dbGetQuery(con, glue("SELECT count(*) AS n FROM {reg_rel}"))$n > 0
}, error = \(e) FALSE)
if (!reg_ok) reg_rel <- "(SELECT NULL::BIGINT AS aphia_id, NULL::VARCHAR AS sp_key WHERE false)"

f_species <- file.path(dir_out, "species.parquet")
run(glue("COPY ({species_lookup_sql('eov_taxa', 'spp.worms_vernacular', reg_rel, 'spp.iucn_redlist', am_tpl)})
          TO '{f_species}' (FORMAT parquet, COMPRESSION zstd)"))
species <- dbGetQuery(con, glue("SELECT * FROM read_parquet('{f_species}')"))
kable(tibble(
  rows = nrow(species), `with common name` = sum(!is.na(species$common_name)),
  `with sp_key` = sum(!is.na(species$sp_key)), `with IUCN page` = sum(!is.na(species$iucn_url)),
  `AquaMaps page check` = if (identical(am_code, "200")) "HEAD 200, species page pattern" else glue("HEAD {am_code}: home page"),
  `registry` = if (reg_ok) "aquamaps_native" else "offline: sp_keys only"),
  caption = "Species lookup (species.parquet).")
Species lookup (species.parquet).
rows with common name with sp_key with IUCN page AquaMaps page check registry
10750 4564 10750 8483 HEAD 200, species page pattern aquamaps_native
Code
# record the cell -> species table in method.json and provenance.json ----
if (do_cs) {
  cs_meta <- list(
    layout = "tiles of 5 degrees, tile = (row div 100) * 72 + col div 100, row-major global05 cell_id",
    columns = c("cell_id INTEGER", "aphia_id INTEGER", "suit UTINYINT"),
    suit = "round(max over the species' models of v), 1..100; cells outside the IUCN range are absent",
    sort = "cell_id, suit DESC, aphia_id", row_group_size = 32768L, compression = "zstd",
    n_tiles = cs_info$n_tiles, rows = cs_info$rows, bytes = cs_info$bytes,
    manifest_sha256 = tile_manifest, species_asset = "species.parquet")
  method$cell_species <- cs_meta
  prov$cell_species   <- cs_meta
  write_json(method, file.path(dir_out, "method.json"), auto_unbox = T, pretty = T, digits = NA, null = "null", na = "null")
  write_json(prov, file.path(dir_out, "provenance.json"), auto_unbox = T, pretty = T, digits = NA, null = "null")
}

4.7 STAC

Code
# assets with size and multihash checksum ----
f <- \(x) file.path(dir_out, x)
pq_type <- "application/vnd.apache.parquet"
cells_parts <- lapply(c("all", populated), \(g) {
  rel <- as.character(glue("cells/eov={g}/data.parquet"))
  list(eov = g, href = paste0("./", rel),
       `file:size` = as.numeric(file.size(f(rel))),
       `file:checksum` = file_multihash(f(rel)))
})
assets <- list(
  eov_taxa = stac_asset_file(f("eov_taxa.parquet"), "./eov_taxa.parquet", pq_type,
    "Taxon crosswalk: AphiaID by EOV by Red List category by weight", "metadata",
    list(`table:row_count` = nrow(taxa),
         `table:columns` = lapply(names(taxa), \(n) list(name = n)))),
  eov_summary = stac_asset_file(f("eov_summary.csv"), "./eov_summary.csv", "text/csv",
    "Per EOV and metric: n taxa, n with range, n weighted, q99", "metadata"),
  method = stac_asset_file(f("method.json"), "./method.json", "application/json",
    "Every parameter, the weight table, the seeds and the input versions", "metadata"),
  provenance = stac_asset_file(f("provenance.json"), "./provenance.json", "application/json",
    "Input sizes, hashes and software versions", "metadata"),
  cells = list(
    href = "./cells/", type = pq_type, roles = I("data"),
    title = "Per-cell share, risk and scores, partitioned by EOV (cells/eov=<g>/data.parquet)",
    `atlas:partitioned_by` = "eov", `atlas:partitions` = cells_parts),
  species = stac_asset_file(f("species.parquet"), "./species.parquet", pq_type,
    "Species lookup: eov_taxa plus common name, sp_key, IUCN id and the WoRMS, IUCN and AquaMaps links", "metadata",
    list(`table:row_count` = nrow(species),
         `table:columns` = lapply(names(species), \(n) list(name = n)))),
  h3_r5 = stac_asset_file(f("h3_r5.parquet"), "./h3_r5.parquet", pq_type,
    "H3 resolution 5: area-weighted mean scores and densities", "data",
    list(`sdm:h3_resolution` = 5L, `table:row_count` = n_h3)),
  density_risk  = stac_asset_file(f("density_risk.tif"), "./density_risk.tif", .eov_cog_type,
    "Risk-weighted density per km2, 8 bands (all and the EOVs)", "reference"),
  density_share = stac_asset_file(f("density_share.tif"), "./density_share.tif", .eov_cog_type,
    "Share density per km2, 8 bands (all and the EOVs)", "reference"),
  score_risk  = stac_asset_file(f("score_risk.tif"), "./score_risk.tif", .eov_cog_type,
    "Risk-weighted share score 0-100, 9 bands", "data"),
  score_share = stac_asset_file(f("score_share.tif"), "./score_share.tif", .eov_cog_type,
    "Share score 0-100, 9 bands", "data"),
  thumbnail = stac_asset_file(f("thumbnail.png"), "./thumbnail.png", "image/png",
    "Thumbnail: composite risk score", "thumbnail"))

# the cell -> species tiles are one asset (a directory of files) ----
if (do_cs) {
  assets$cell_species <- list(
    href = "./cell_species/", type = pq_type, roles = I("metadata"),
    title = "Species present per 0.05 degree cell: cell_id, aphia_id, suit; 5 degree tiles cell_species/tile=<t>/data_0.parquet",
    `atlas:n_tiles` = cs_info$n_tiles, `table:row_count` = cs_info$rows,
    `file:size` = cs_info$bytes,
    `table:columns` = list(
      list(name = "cell_id", type = "int32"), list(name = "aphia_id", type = "int32"),
      list(name = "suit", type = "uint8", description = "suitability 1-100 = round(max v)")))
}

# items: 8 EOV values x 2 metrics ----
grid <- expand.grid(eov = eov_levels(), metric = c("risk", "share"), stringsAsFactors = F) |> as_tibble()
n_by <- setNames(eov_summary$n_taxa[eov_summary$metric == "risk"], eov_summary$eov[eov_summary$metric == "risk"])
item_ids <- paste0(grid$eov, "_", grid$metric)
unlink(f("items"), recursive = T)
for (i in seq_len(nrow(grid))) {
  g <- grid$eov[i]; m <- grid$metric[i]
  q <- q_tab$q99[q_tab$eov == g & q_tab$metric == m]
  it <- stac_item_eov(
    g, m, version = ver,
    n_taxa = if (g %in% names(n_by)) as.integer(n_by[[g]]) else 0L,
    q99    = if (length(q) == 1) q else NA_real_)
  write_stac_json(it, f(file.path("items", paste0(item_ids[i], ".json"))))
}

# collection ----
col <- stac_collection_eov(version = ver, assets = assets, items = item_ids, scheme = scheme,
                           q = q_score, cell_species = do_cs)
write_stac_json(col, f("collection.json"))

n_items <- length(list.files(f("items"), pattern = "json$"))
files_out <- tibble(file = list.files(dir_out, recursive = T)) |>
  mutate(MB = file.size(file.path(dir_out, file)) / 1e6) |>
  filter(!startsWith(file, "items/"), !startsWith(file, "cell_species/"))
kable(files_out, digits = 2, caption = "Staged files (items/ and the cell_species/ tiles omitted; see the Results table for the tiles).")
Staged files (items/ and the cell_species/ tiles omitted; see the Results table for the tiles).
file MB
AGENTS.md 0.00
cells/eov=all/data.parquet 282.40
cells/eov=fish/data.parquet 259.35
cells/eov=hardCorals/data.parquet 169.19
cells/eov=mangroves/data.parquet 0.99
cells/eov=marineMammals/data.parquet 274.61
cells/eov=seaTurtles/data.parquet 63.71
collection.json 0.02
density_risk.tif 256.06
density_share.tif 332.68
eov_summary.csv 0.00
eov_taxa.parquet 0.26
h3_r5.parquet 122.79
method.json 0.01
provenance.json 0.00
README.md 0.00
score_risk.tif 7.13
score_share.tif 10.06
species.parquet 0.50
thumbnail.png 0.07

The collection has 16 items and 20 other staged files totalling 1779.8 MB.

4.8 Verify against the published COGs

The inputs here are the arrays that the published AquaMaps COGs (aquamaps_native, 0.05° global) were painted from. This chunk samples EOV_VERIFY_N of the species of this run, reads their published COG and compares it with the local partition at sampled cells. A mismatch is reported, not fatal: a few of the published COGs predate a rewrite of the distribution tables.

Code
verify <- tibble()
if (verify_n > 0) {
  verify <- tryCatch({
    invisible(run("INSTALL httpfs")); invisible(run("LOAD httpfs"))
    reg <- dbGetQuery(con, glue("
      SELECT mdl_key, cog_url_005
      FROM read_parquet('{url_pub}/atlas/aquamaps_native/registry.parquet')
      WHERE cog_url_005 IS NOT NULL"))
    cand <- am_map |> inner_join(reg, by = "mdl_key")
    set.seed(42)
    smp <- cand[sample(nrow(cand), min(verify_n, nrow(cand))), ]
    tpl <- rast(p$cell_tif)
    purrr::map(seq_len(nrow(smp)), \(k) {
      row <- smp[k, ]
      tryCatch({
        loc <- dbGetQuery(con, glue("SELECT cell_id, val FROM read_parquet('{row$file}')"))
        cid <- loc$cell_id[sample(nrow(loc), min(700, nrow(loc)))]
        oth <- sample(cells$cell_id, 300)                 # mostly cells outside the model
        cid <- unique(c(cid, oth))
        val <- loc$val[match(cid, loc$cell_id)]; val[is.na(val)] <- 0
        r   <- rast(paste0("/vsicurl/", row$cog_url_005))
        got <- terra::extract(r, terra::xyFromCell(tpl, cid))[[1]]
        got[is.na(got)] <- 0
        tibble(mdl_key = row$mdl_key, n_cells = length(cid),
               n_equal = sum(got == floor(val)), n_within1 = sum(abs(got - val) <= 1),
               max_abs_diff = max(abs(got - val)), error = NA_character_)
      }, error = \(e) tibble(mdl_key = row$mdl_key, n_cells = NA_integer_, n_equal = NA_integer_,
                             n_within1 = NA_integer_, max_abs_diff = NA_real_, error = conditionMessage(e)))
    }) |> bind_rows()
  }, error = \(e) tibble(mdl_key = NA_character_, n_cells = NA_integer_, n_equal = NA_integer_,
                         n_within1 = NA_integer_, max_abs_diff = NA_real_,
                         error = paste("registry unreachable:", conditionMessage(e))))
}
if (nrow(verify) > 0) {
  verify <- verify |> mutate(pct_equal = round(100 * n_equal / n_cells, 1))
  kable(verify, caption = "COG spot-check: published 0.05 degree COG versus the local partition.")
} else {
  cat("verification skipped (EOV_VERIFY_N = 0)\n")
}
COG spot-check: published 0.05 degree COG versus the local partition.
mdl_key n_cells n_equal n_within1 max_abs_diff error pct_equal
am|Fis-50089 1000 1000 1000 0.99 NA 100
am|Fis-147799 1000 1000 1000 0.99 NA 100
am|Fis-34847 1000 1000 1000 0.99 NA 100
am|Fis-26225 1000 1000 1000 0.99 NA 100
am|W-Scl-288214 1000 1000 1000 0.99 NA 100
am|Fis-28946 1000 1000 1000 0.99 NA 100
am|Fis-23617 1000 1000 1000 0.99 NA 100
am|W-Scl-135208 1000 1000 1000 0.99 NA 100
am|Fis-25909 1000 1000 1000 0.99 NA 100
am|Fis-48951 1000 1000 1000 0.99 NA 100
am|Fis-29592 1000 1000 1000 0.99 NA 100
am|Fis-33987 1000 1000 1000 0.99 NA 100
am|Fis-25178 1000 1000 1000 0.99 NA 100
am|Fis-139614 1000 1000 1000 0.99 NA 100
am|Fis-31142 1000 1000 1000 0.99 NA 100
am|W-Scl-289891 1000 1000 1000 0.99 NA 100
am|Fis-23150 1000 1000 1000 0.99 NA 100
am|Fis-25810 1000 1000 1000 0.99 NA 100
am|Fis-26246 1000 1000 1000 0.99 NA 100
am|Fis-25828 1000 1000 1000 0.99 NA 100

Of 20 species checked, 20 matched the published COG at every sampled cell (20 within one unit). The published Byte surface stores the truncated value, floor(v), so equality is tested against the floor of the partition value.

4.9 Upload

Uploading is the only side effect of the notebook. It runs only with EOV_S3=1 (and never in a test run), mirrors the staging directory to the public prefix with --delete, then reads collection.json back through the public URL and compares it with the local file.

Code
if (do_s3) {
  dest <- "s3://oceanmetrics.io-public/atlas/aquamaps_eov/"
  rc <- system2("aws", c("s3", "sync", shQuote(dir_out), dest, "--delete"))
  stopifnot(rc == 0)
  remote <- read_json(glue("{url_prod}/collection.json"))
  local  <- read_json(file.path(dir_out, "collection.json"))
  cat("read back collection.json identical to staged:", identical(remote, local), "\n")
  stopifnot(identical(remote, local))
} else {
  cat("skipped (EOV_S3 unset)\n")
}
read back collection.json identical to staged: TRUE 

5 Results

Code
q_wide <- q_tab |> pivot_wider(names_from = metric, values_from = q99, names_prefix = "q99_")
tx_sum |>
  left_join(q_wide, by = "eov") |>
  mutate(eov = factor(eov, eov_keys())) |>
  complete(eov, fill = list(n_taxa = 0L, n_with_range = 0L, n_weighted = 0L, n_excluded = 0L)) |>
  arrange(eov) |>
  mutate(EOV = unname(eov_labels()[as.character(eov)])) |>
  select(EOV, `Taxa` = n_taxa, `With range` = n_with_range, `Weighted` = n_weighted,
         `Excluded` = n_excluded, `q99 risk` = q99_risk, `q99 share` = q99_share) |>
  mutate(across(starts_with("q99"), \(x) ifelse(is.na(x), NA_character_, format(x, digits = 3, scientific = T)))) |>
  kable(na = "absent")
Table 6: Taxa per EOV: species with an AquaMaps model, with an IUCN range, with a Red List weight (not excluded), excluded (no habitat inside the range), and the q99 density used to score each metric (per km2).
EOV Taxa With range Weighted Excluded q99 risk q99 share
Fish 9898 2444 7433 3 1.20e-05 4.33e-04
Hard corals 730 552 539 0 1.22e-05 5.32e-05
Mangroves 2 2 2 0 0.00e+00 1.29e-06
Marine mammals 113 107 104 0 8.47e-07 2.86e-06
Seabirds 0 0 0 0 NA NA
Seagrasses 0 0 0 0 NA NA
Sea turtles 7 6 6 0 2.43e-07 6.02e-07
Code
r_all <- rast(file.path(dir_out, "score_risk.tif"), lyrs = 1) |>
  aggregate(fact = 10, fun = "mean", na.rm = T)
ggplot() +
  geom_spatraster(data = r_all, na.rm = T) +
  scale_fill_viridis_c(na.value = "transparent", limits = c(0, 100), name = "score") +
  coord_sf(expand = F) +
  labs(x = NULL, y = NULL) +
  theme_minimal()
Figure 3: Composite score (mean of the reportable EOV scores) of the risk-weighted share, 0.5 degree mean of the 0.05 degree cells; viridis, 0-100.
Code
r_eov <- rast(file.path(dir_out, "score_risk.tif"))[[populated]] |>
  aggregate(fact = 10, fun = "mean", na.rm = T)
names(r_eov) <- unname(eov_labels()[populated])
ggplot() +
  geom_spatraster(data = r_eov, na.rm = T) +
  facet_wrap(~lyr, ncol = 2) +
  scale_fill_viridis_c(na.value = "transparent", limits = c(0, 100), name = "score") +
  coord_sf(expand = F) +
  labs(x = NULL, y = NULL) +
  theme_minimal() +
  theme(axis.text = element_blank(), axis.ticks = element_blank())
Figure 4: Risk-weighted share score of each populated EOV, 0.5 degree mean; seabirds and seagrasses are absent (AquaMaps has none).
Code
# example cell: most reportable EOVs, then highest composite ----
ex <- order(-sc$risk$n_eov, -sc$risk$all, -sc$risk$dens_all, cells$cell_id)[1]
petals <- tibble(
  eov   = keys,
  label = unname(eov_labels()[keys]),
  score = as.integer(sc$risk$eov[ex, ])) |>
  mutate(label = factor(label, label),
         lab_txt = ifelse(is.na(score), "absent", score))
ggplot(petals, aes(label, score, fill = eov)) +
  geom_col(aes(y = 100), fill = NA, color = "grey80", width = 1) +
  geom_col(width = 1, color = "white", na.rm = T) +
  geom_text(aes(y = ifelse(is.na(score), 50, 108), label = lab_txt), size = 3, color = "grey30") +
  annotate("text", x = 1, y = -35, size = 9, fontface = "bold", label = sc$risk$all[ex]) +
  coord_polar() +
  scale_y_continuous(limits = c(-35, 115)) +
  scale_fill_viridis_d(guide = "none") +
  labs(x = NULL, y = NULL,
       title = glue("cell {cells$cell_id[ex]} ({round(cells$lon[ex], 2)}, {round(cells$lat[ex], 2)})")) +
  theme_minimal() +
  theme(axis.text.y = element_blank(), panel.grid = element_blank())
Figure 5: Flower plot of one example cell (the cell with the most reportable EOVs, then the highest composite): risk scores of the 7 EOV petals (absent EOVs empty) and the composite in the centre.

msens::ggplot_flower() draws the MarineSensitivity components with its own palette and a fixed component set; the figure above is a plain ggplot2 polar bar chart so that the seven EOV petals and the empty ones are explicit. The atlas draws the same flower from atlas:flower.

Code
top <- cells |>
  mutate(composite = sc$risk$all, n_eov = sc$risk$n_eov, dens_all = sc$risk$dens_all) |>
  bind_cols(as_tibble(sc$risk$eov[, populated, drop = F])) |>
  arrange(desc(composite), desc(n_eov), desc(dens_all), cell_id) |>
  slice_head(n = 10) |>
  select(cell_id, lon, lat, composite, n_eov, all_of(populated))
kable(top, digits = 2, col.names = c("cell_id", "lon", "lat", "composite", "n EOV", populated))
Table 7: Ten cells with the highest composite risk score (ties: more EOVs, then higher summed density), with longitude and latitude of the cell centre.
cell_id lon lat composite n EOV fish hardCorals mangroves marineMammals seaTurtles
14298447 142.33 -9.27 88 4 100 100 NA 100 50
14305646 142.28 -9.32 88 4 100 100 NA 100 50
14305647 142.33 -9.32 88 4 100 100 NA 100 50
14312846 142.28 -9.37 88 4 100 100 NA 100 50
14312847 142.33 -9.37 88 4 100 100 NA 100 50
14305649 142.43 -9.32 88 4 100 100 NA 100 50
14320046 142.28 -9.42 88 4 100 100 NA 100 50
14327249 142.43 -9.47 88 4 100 100 NA 100 50
14320047 142.33 -9.42 88 4 100 100 NA 100 50
14334449 142.43 -9.52 88 4 100 100 NA 100 50
Code
if (do_cs) {
  cell_ex <- cells$cell_id[ex]
  cs_cell <- dbGetQuery(con, glue("
    SELECT c.aphia_id, c.suit, s.scientific_name, s.common_name, s.eov, s.redlist_code, s.total_suit_area
    FROM read_parquet('{cell_species_tile_path(dir_tile, tile_of_cell(cell_ex))}') c
    JOIN read_parquet('{f_species}') s ON s.aphia_id = c.aphia_id
    WHERE c.cell_id = {cell_ex}")) |>
    mutate(share_pct = 100 * suit * cells$area_km2[ex] / total_suit_area) |>
    arrange(desc(suit), desc(share_pct), scientific_name)
  cs_cell |>
    slice_head(n = 15) |>
    mutate(species = ifelse(is.na(common_name), scientific_name, paste0(scientific_name, " (", common_name, ")")),
           eov = unname(eov_labels()[eov])) |>
    select(Species = species, EOV = eov, `Suitability` = suit, `Share %` = share_pct, `Red List` = redlist_code) |>
    kable(digits = 4, na = "")
} else {
  cat("cell to species table not built in this run\n")
}
Table 8: Top 15 species of the example cell of the flower plot, read from its cell_species tile: EOV, suitability (percent), share of the species’ global habitat that lies in the cell (percent) and Red List category.
Species EOV Suitability Share % Red List
Pristis pectinata (smalltooth sawfish) Fish 100 0.0079 CR
Mustelus norrisi (Florida smoothhound) Fish 100 0.0037 NT
Carcharhinus acronotus (blacknose shark) Fish 100 0.0036 EN
Agaricia undata (scroll coral) Hard corals 100 0.0032 LC
Neoconger mucronatus (ridged eel) Fish 100 0.0030 LC
Echiophis intertinctus (spotted spoon-nose eel) Fish 100 0.0029 LC
Elacatinus oceanops (neon goby) Fish 100 0.0028 LC
Hyporthodus mystacinus Fish 100 0.0027 LC
Haemulon album (margate) Fish 100 0.0026 DD
Apogon lachneri (whitestar cardinalfish) Fish 100 0.0026 LC
Anarchias similis (pygmy moray) Fish 100 0.0026 LC
Carcharhinus perezii (Caribbean reef shark) Fish 100 0.0025 EN
Meandrina danai Hard corals 100 0.0024 LC
Agaricia humilis (low-relief lettuce coral) Hard corals 100 0.0024 CR
Rhizoprionodon porosus (Caribbean sharpnose shark) Fish 100 0.0023 VU
Code
if (do_cs) {
  fmt <- \(x) format(x, big.mark = ",", scientific = F)
  kable(tibble(
    `Tiles` = fmt(cs_info$n_tiles), `Rows` = fmt(cs_info$rows),
    `Bytes` = fmt(cs_info$bytes), `GB` = round(cs_info$bytes / 1e9, 2),
    `Largest tile rows` = fmt(cs_info$max_rows), `Largest tile bytes` = fmt(cs_info$max_bytes)))
} else {
  cat("cell to species table not built in this run\n")
}
Table 9: Size of the cell to species table: number of tiles, rows and bytes in total, and the largest tile.
Tiles Rows Bytes GB Largest tile rows Largest tile bytes
1,986 6,347,722,747 8,076,059,596 8.08 27,230,930 48,255,234

In this run the staging directory 1.0.0 holds the product for 16,248,828 cells; the composite risk score reaches 88 and the most EOVs reported in one cell are 5. For the example cell, the per-EOV sums of the species shares computed from the tiles (suit x area / total) are within 0.2 percent of the product share (rounding of suit to an integer).

6 Reproducibility and use

Render. The notebook needs the MarineSensitivity v9 files (MSENS_DERIVED, default ~/_big/msens/derived) and the CRAN packages loaded in the init chunk. A cheap test run on a subset (first 50 species per EOV, written to .../aquamaps_eov/test, never uploaded) and the full run are

EOV_TEST_N=50 quarto render aquamaps_eov.qmd          # test subset
quarto render aquamaps_eov.qmd                        # full run (about 12 GB of memory)
EOV_REDO=1 quarto render aquamaps_eov.qmd             # recompute the checkpoints
EOV_S3=1 quarto render aquamaps_eov.qmd               # also upload (the only side effect)

Flags: EOV_REDO, EOV_S3, EOV_TEST_N, EOV_WEIGHTS (ohi or mst_v1), EOV_Q (score quantile), EOV_BATCH_ROWS (rows per batch), EOV_CELL_SPECIES (the cell to species table: on in full runs, 1 forces it in a test run, 0 skips it), EOV_VERIFY_N (COG spot-check, 0 to skip), MSENS_DERIVED, BUILD_MEMORY_GB, BUILD_THREADS, BUILD_TMP_GB (cap on DuckDB spill files, default 100). The rules live in R/eov_share.R and are guarded by tests/testthat (Rscript -e 'testthat::test_dir("tests/testthat")').

Read the product from R (not run here):

Code
librarian::shelf(rstac, terra, duckdb, quiet = T)
base <- "https://s3.us-east-1.amazonaws.com/oceanmetrics.io-public/atlas/aquamaps_eov"

# one layer: the item names the COG and the band
item <- read_stac(paste0(base, "/items/fish_risk.json"))
cog  <- paste0("/vsicurl/", base, "/score_risk.tif")
r    <- rast(cog, lyrs = item$assets$cog[["atlas:bidx"]])      # 0-100, nodata 255

# cells of one EOV with DuckDB over HTTP
con <- DBI::dbConnect(duckdb())
DBI::dbExecute(con, "INSTALL httpfs; LOAD httpfs")
DBI::dbGetQuery(con, glue::glue(
  "SELECT * FROM read_parquet('{base}/cells/eov=fish/data.parquet') ORDER BY score_risk DESC LIMIT 10"))

# the AphiaID crosswalk, to join with obis-hex or obisindicators
DBI::dbGetQuery(con, glue::glue(
  "SELECT eov, count(*) n FROM read_parquet('{base}/eov_taxa.parquet') GROUP BY 1"))

Species in one cell (full runs only; not run here). The tile of a cell follows from its grid position, so one small file and, thanks to the sorted cell_id statistics, one row group is read:

Code
librarian::shelf(duckdb, glue, quiet = T)
base <- "https://s3.us-east-1.amazonaws.com/oceanmetrics.io-public/atlas/aquamaps_eov"

# the cell and tile of a point (global05: 7200 x 3600 cells of 0.05 degree, row-major from the top-left)
lon <- -86.73; lat <- 21.62
col  <- floor((lon + 180) / 0.05); row <- floor((90 - lat) / 0.05)
cell <- row * 7200 + col + 1                      # 9844266
tile <- (row %/% 100) * 72 + col %/% 100          # 954

con <- dbConnect(duckdb())
dbExecute(con, "INSTALL httpfs; LOAD httpfs")
dbGetQuery(con, glue(
  "SELECT s.scientific_name, s.common_name, s.eov, c.suit, s.redlist_code, s.aquamaps_url
   FROM read_parquet('{base}/cell_species/tile={tile}/data_0.parquet') c
   JOIN read_parquet('{base}/species.parquet') s USING (aphia_id)
   WHERE c.cell_id = {cell}
   ORDER BY c.suit DESC LIMIT 15"))

The same query in the DuckDB shell:

INSTALL httpfs; LOAD httpfs;
SELECT s.scientific_name, s.eov, c.suit, s.redlist_code
FROM read_parquet('https://s3.us-east-1.amazonaws.com/oceanmetrics.io-public/atlas/aquamaps_eov/cell_species/tile=954/data_0.parquet') c
JOIN read_parquet('https://s3.us-east-1.amazonaws.com/oceanmetrics.io-public/atlas/aquamaps_eov/species.parquet') s USING (aphia_id)
WHERE c.cell_id = 9844266
ORDER BY c.suit DESC LIMIT 15;

The share of a species’ global habitat that lies in the cell is suit * area_km2 / total_suit_area (total_suit_area is in species.parquet; the totals were summed with the suitability on the 1-100 scale, and suit is the rounded value).

Read the product from Python (not run here):

Code
import pystac, rasterio

base = "https://s3.us-east-1.amazonaws.com/oceanmetrics.io-public/atlas/aquamaps_eov"
col  = pystac.Collection.from_file(f"{base}/collection.json")
item = pystac.Item.from_file(f"{base}/items/all_risk.json")
cog  = item.assets["cog"]
band = cog.extra_fields["atlas:bidx"]                      # 1 for the composite
with rasterio.open(cog.get_absolute_href()) as src:
    arr = src.read(band, window=((1200, 1400), (3000, 3200)))   # 0-100, nodata 255

In the atlas. The OceanMetrics atlas lists the collection as its first child, so the default layer is the first value of each cube:dimensions entry: eov = all, metric = risk. The collection field atlas:flower (dimension: eov, composite: all) tells the app to draw a flower plot on a cell click, with one petal per sibling item across eov for the current metric and the all score in the centre. The atlas reads atlas:bidx, atlas:rescale and atlas:colormap on the item’s cog asset and the dimension labels in atlas:labels, so a new EOV or metric needs no app change.

References

Butchart, Stuart H. M., H. Resit Akçakaya, Janice Chanson, Jonathan E. M. Baillie, Ben Collen, Suhel Quader, Will R. Turner, Rajan Amin, Simon N. Stuart, and Craig Hilton-Taylor. 2007. “Improvements to the Red List Index.” PLoS ONE 2 (1): e140. https://doi.org/10.1371/journal.pone.0000140.
Butchart, Stuart H. M., Alison J. Stattersfield, Leon A. Bennun, Susanne M. Shutes, H. Resit Akçakaya, Jonathan E. M. Baillie, Simon N. Stuart, Craig Hilton-Taylor, and Georgina M. Mace. 2004. “Measuring Global Trends in the Status of Biodiversity: Red List Indices for Birds.” PLoS Biology 2 (12): e383. https://doi.org/10.1371/journal.pbio.0020383.
Halpern, Benjamin S., Catherine Longo, Darren Hardy, Karen L. McLeod, Jameal F. Samhouri, Steven K. Katona, Kristin Kleisner, et al. 2012. “An Index to Assess the Health and Benefits of the Global Ocean.” Nature. https://doi.org/10.1038/nature11397.
IOOS Marine Life Data Network. n.d. “IOOS Marine Life Data Network: EOV Taxonomy (eov_taxonomy).” GitHub repository. https://github.com/ioos/marine_life_data_network.
IUCN. 2025. “The IUCN Red List of Threatened Species.” https://www.iucnredlist.org.
Kaschner, K., K. Kesner-Reyes, C. Garilao, J. Segschneider, J. Rius-Barile, T. Rees, and R. Froese. 2019. “AquaMaps: Predicted Range Maps for Aquatic Species.” www.aquamaps.org. https://www.aquamaps.org.
MarineSensitivity. 2026. “MarineSensitivity Workflows: Version 10 Distribution-Share Method.” GitHub repository. https://github.com/MarineSensitivity/workflows.
Miloslavich, Patricia, Nicholas J. Bax, Samantha E. Simmons, Eduardo Klein, Ward Appeltans, Octavio Aburto-Oropeza, et al. 2018. “Essential Ocean Variables for Global Sustained Observations of Biodiversity and Ecosystem Changes.” Global Change Biology 24 (6): 2416–33. https://doi.org/10.1111/gcb.14108.
Ready, Jonathan, Kristin Kaschner, Andy B. South, Paul D. Eastwood, Tony Rees, Josephine Rius, Eli Agbayani, Sven Kullander, and Rainer Froese. 2010. “Predicting the Distributions of Marine Organisms at the Global Scale.” Ecological Modelling 221 (3): 467–78. https://doi.org/10.1016/j.ecolmodel.2009.10.025.
WoRMS Editorial Board. 2026. “World Register of Marine Species.” https://doi.org/10.14284/170.