Ammonium in the CalCOFI record, part 2: what the v2026.07.30 fixes changed

Re-running the four-path comparison after promoting ammonium to canonical, retaining bottle depths, and stripping the -99 sentinel

Author

CalCOFI

Published

2026-07-30

Summary

Part 1 asked what the ranges of ammonium are over time, space and depth, and found that two of the four ways into the data could not answer at all — plus a set of values in the released product that could not be real. This notebook re-runs that comparison against release v2026.07.30 to check the fixes actually landed, and to quantify one that turned out to be much larger than first reported.

ImportantThe four paths, then and now
# Access path v2026.07.17 v2026.07.30
1 ctd-cast.nc — thinned CF NetCDF no ammonium (15 sensors) 16 sensors, btl_ammonium present
2 ctd-cast_full.nc — full-resolution no ammonium (32 of 54 types) all 54 types
3 Release Parquet obs — thinned ammonium only via calcofi_bottle also from calcofi_ctd-cast: 67,385 values
4 Release Parquet obs_ctd_full btl_ammonium, no flags, 1.99× duplicated unchanged by design (still the supplemental tier)

Three findings, in order of how much they change an answer:

  1. The -99 sentinel was ~47× bigger than part 1 measured. Part 1 counted 84,302 sentinel values in the thinned product. Across the full-resolution record it was 3,983,321 values in 35 of 54 measurement types, including canonical oxygen. -99 is the source’s documented missing marker and the ingest already stripped it from longitude/latitude — the rule had simply never reached the measurement columns, and the standard NaN/infinity guard cannot catch it because -99 is finite. Now zero remain.

  2. Promoting ammonium to canonical was necessary but not sufficient. The thinning retains depths where the temperature/salinity profile bends, which knows nothing about where a bottle was tripped. Without also retaining bottle-trip depths, the flag change would have delivered 27% of the ammonium record and looked like success.

  3. The censoring story is unchanged, and still the main analytical hazard. The below-detection flag remains unusable as a time series; measurement_value == 0 is still the portable test. Fixing the sentinel did not fix censoring — they are different problems that both happened to produce impossible summary statistics.

TipFor anyone who computed CTD statistics from an earlier release

Redo them. A -99 mL/L oxygen inside a mean, minimum or anomaly is silently wrong rather than obviously wrong, and it affected canonical sensors in the headline product.

Setup

Code
librarian::shelf(
  DBI, duckdb, dplyr, tidyr, ggplot2, glue, ncdf4, readr, scales,
  knitr, tibble, stringr, patchwork, sessioninfo, here, quiet = TRUE)
here <- here::here

REL_OLD <- "v2026.07.17"
REL_NEW <- "v2026.07.30"
RELEASES <- "https://storage.googleapis.com/calcofi-db/ducklake/releases"
NC_SITE  <- "https://storage.calcofi.io/calcofi-files-public/netcdf"

obs_url <- function(rel, ds = "calcofi_ctd-cast")
  glue("{RELEASES}/{rel}/parquet/obs/dataset_key={ds}/data_0.parquet")

cat(glue("comparing {REL_OLD} -> {REL_NEW}\n"))
comparing v2026.07.17 -> v2026.07.30
Code
CC_BLUE <- "#2a78d6"; CC_ORANGE <- "#eb6834"; CC_AQUA <- "#1baf7a"
INK <- "#0b0b0b"; INK2 <- "#52514e"; MUTED <- "#898781"
GRID <- "#e1e0d9"; AXIS <- "#c3c2b7"

theme_cc <- function(base_size = 11) {
  theme_minimal(base_size = base_size) +
    theme(
      plot.title       = element_text(colour = INK, face = "bold", size = rel(1.05)),
      plot.subtitle    = element_text(colour = INK2, size = rel(0.92)),
      plot.caption     = element_text(colour = MUTED, size = rel(0.82), hjust = 0),
      axis.title       = element_text(colour = INK2, size = rel(0.90)),
      axis.text        = element_text(colour = MUTED),
      panel.grid.major = element_line(colour = GRID, linewidth = 0.3),
      panel.grid.minor = element_blank(),
      axis.line        = element_line(colour = AXIS, linewidth = 0.3),
      strip.text       = element_text(colour = INK, face = "bold", size = rel(0.90)),
      legend.title     = element_text(colour = INK2, size = rel(0.88)),
      legend.text      = element_text(colour = INK2, size = rel(0.88)),
      legend.position  = "top", legend.justification = "left")
}
theme_set(theme_cc())
Code
con <- dbConnect(duckdb())
for (s in c("INSTALL httpfs", "LOAD httpfs", "SET memory_limit='8GB'",
            "SET enable_progress_bar=false")) try(dbExecute(con, s), silent = TRUE)
q <- function(sql, ...) dbGetQuery(con, glue(sql, ..., .envir = parent.frame()))

dbExecute(con, glue("
  CREATE VIEW old AS SELECT * FROM read_parquet('{obs_url(REL_OLD)}');
  CREATE VIEW new AS SELECT * FROM read_parquet('{obs_url(REL_NEW)}');
  CREATE VIEW btl_old AS SELECT * FROM read_parquet('{obs_url(REL_OLD, \"calcofi_bottle\")}');
  CREATE VIEW btl_new AS SELECT * FROM read_parquet('{obs_url(REL_NEW, \"calcofi_bottle\")}');"))
[1] 0

The -99 sentinel: what it was doing to the released product

This is the finding that most changes a user’s answer, and it is worth seeing at the level of the individual sensor rather than as a total. Below: the reported range of each canonical CTD sensor in the old release against the new one.

Code
rng <- q("
  WITH o AS (
    SELECT measurement_type,
           COUNT(*)                                       AS n_old,
           COUNT(*) FILTER (WHERE measurement_value = -99) AS n_neg99,
           MIN(measurement_value)                         AS min_old,
           MAX(measurement_value)                         AS max_old
    FROM old GROUP BY 1),
  n AS (
    SELECT measurement_type,
           COUNT(*)               AS n_new,
           MIN(measurement_value) AS min_new,
           MAX(measurement_value) AS max_new
    FROM new GROUP BY 1)
  SELECT o.measurement_type, o.n_old, o.n_neg99, n.n_new,
         o.min_old, n.min_new, o.max_old, n.max_new
  FROM o LEFT JOIN n USING (measurement_type)
  ORDER BY o.n_neg99 DESC, o.measurement_type")

rng |>
  transmute(
    measurement_type,
    `rows (old)` = format(n_old, big.mark = ","),
    `-99 (old)`  = format(n_neg99, big.mark = ","),
    `min old` = signif(min_old, 4), `min new` = signif(min_new, 4),
    `max old` = signif(max_old, 4), `max new` = signif(max_new, 4)) |>
  kable(caption = glue(
    "Canonical CTD sensors, {REL_OLD} vs {REL_NEW}. Every `min old` of -99 is a ",
    "sentinel that was being reported as a measurement."))
Canonical CTD sensors, v2026.07.17 vs v2026.07.30. Every min old of -99 is a sentinel that was being reported as a measurement.
measurement_type rows (old) -99 (old) min old min new max old max new
isus_v 345,552 40,479 -9.900e+01 0.000e+00 4.097e+00 4.097e+00
ph 239,475 31,493 -9.900e+01 -1.002e+01 1.655e+01 1.657e+01
spar 350,600 6,189 -2.679e+02 -2.679e+02 1.200e+05 1.200e+05
oxygen_umol_kg_ave_sta_corr 283,008 4,294 -9.900e+01 0.000e+00 5.841e+10 5.841e+10
oxygen_ml_l_ave_sta_corr 327,492 953 -9.900e+01 0.000e+00 2.106e+09 2.106e+09
beam_attenuation 365,884 636 -9.900e+01 -1.229e+01 6.144e+01 6.144e+01
transmissometer 365,884 222 -9.900e+01 -5.334e+01 1.142e+02 1.142e+02
dynamic_height 412,345 31 -9.900e+01 -1.773e+01 2.038e+01 2.038e+01
specific_volume_anomaly 301,278 5 -6.392e+04 -6.392e+04 8.687e+03 8.687e+03
fluorescence_v 437,737 0 -5.180e-02 -5.180e-02 9.000e+00 9.000e+00
par 386,286 0 -5.203e+03 -5.203e+03 1.419e+04 1.419e+04
pressure 437,737 0 1.006e+00 1.006e+00 3.688e+03 3.688e+03
salinity_ave_corr 422,842 0 -4.542e+01 -4.542e+01 1.016e+03 1.016e+03
sigma_theta_1 437,717 0 -5.082e+01 -5.082e+01 1.999e+03 1.999e+03
temperature_ave 437,714 0 -2.065e+01 -2.065e+01 6.038e+01 6.038e+01
Code
tot <- q("
  SELECT (SELECT COUNT(*) FROM old WHERE measurement_value = -99) AS neg99_old,
         (SELECT COUNT(*) FROM new WHERE measurement_value = -99) AS neg99_new,
         (SELECT COUNT(*) FROM old) AS rows_old,
         (SELECT COUNT(*) FROM new) AS rows_new,
         (SELECT COUNT(DISTINCT measurement_type) FROM old) AS types_old,
         (SELECT COUNT(DISTINCT measurement_type) FROM new) AS types_new")

tibble(
  metric = c("-99 rows in thinned obs", "total rows in thinned obs",
             "distinct measurement types",
             "-99 rows removed from the FULL record (ingest log)"),
  !!REL_OLD := c(format(tot$neg99_old, big.mark = ","),
                 format(tot$rows_old, big.mark = ","),
                 as.character(tot$types_old), "—"),
  !!REL_NEW := c(format(tot$neg99_new, big.mark = ","),
                 format(tot$rows_new, big.mark = ","),
                 as.character(tot$types_new), "3,983,321 across 35 types")) |>
  kable()
metric v2026.07.17 v2026.07.30
-99 rows in thinned obs 84,302 0
total rows in thinned obs 5,551,551 5,940,598
distinct measurement types 15 16
-99 rows removed from the FULL record (ingest log) 3,983,321 across 35 types

The per-sensor table is the honest way to read this. -99 was not a rounding nuisance: for several sensors it was the reported minimum, so any consumer computing a minimum or an anomaly got -99 as an answer.

NoteWhat is still not fixed

Sentinels are gone; physically impossible maxima are not. Values like salinity_ave_corr above 1000 PSU or oxygen near 1e9 survive in the max new column, because we do not yet know whether they are bad scans or a units error. They are reported by the ingest’s new range audit and are open question 02 in metadata/calcofi/ctd-cast/questions.csv; dropping them before the providers answer would be guessing rather than fixing.

Ammonium now reaches the thinned record

Code
amm_new <- q("
  SELECT COUNT(*) AS n,
         COUNT(DISTINCT sample_key) AS casts,
         MIN(datetime)::DATE AS t0, MAX(datetime)::DATE AS t1,
         MIN(depth_min_m) AS d0, MAX(depth_min_m) AS d1,
         COUNT(*) FILTER (WHERE measurement_value = 0) AS n_zero
  FROM new WHERE measurement_type = 'btl_ammonium'")

tibble(
  item = c("btl_ammonium rows in thinned obs", "distinct casts", "temporal range",
           "depth range (m)", "exactly zero (below detection)",
           "would have survived WITHOUT bottle-depth retention"),
  value = c(
    format(amm_new$n, big.mark = ","),
    format(amm_new$casts, big.mark = ","),
    paste(amm_new$t0, "to", amm_new$t1),
    paste(amm_new$d0, "to", amm_new$d1),
    glue("{format(amm_new$n_zero, big.mark=',')} ({round(100*amm_new$n_zero/amm_new$n,1)}%)"),
    "35,549 (26.7%) — measured against v2026.07.17")) |>
  kable()
item value
btl_ammonium rows in thinned obs 67,385
distinct casts 4,265
temporal range 2008-01-07 to 2025-04-18
depth range (m) 1 to 3510
exactly zero (below detection) 35,822 (53.2%)
would have survived WITHOUT bottle-depth retention 35,549 (26.7%) — measured against v2026.07.17

The last row is the point. The 10 m backbone and the Douglas–Peucker inflections are both derived from temperature and salinity, so the retained depths track where the sensor profile bends. A bottle is tripped where the watch decides, which the sensor geometry knows nothing about. Selecting depths by one and then subsetting the other silently discards most of it — so ctd_thin now carries a third retained_reason, 'bottle', keeping every depth that holds a canonical bottle-grain value.

Ranges over depth, time and space — recomputed

Now the original question, answered from the new release. Because ammonium is carried by two datasets, both are shown: calcofi_bottle (the QC’d series, with detection flags) and calcofi_ctd-cast (the CTD-file copy, now in the headline product).

Code
depth_bins <- c(0, 10, 50, 100, 200, 500, Inf)
depth_labs <- c("0–10", "11–50", "51–100", "101–200", "201–500", ">500")

pull_amm <- function(view, mt, label) {
  q("SELECT depth_min_m AS depth_m, datetime, latitude, longitude,
            measurement_value AS ammonium
     FROM {view} WHERE measurement_type = '{mt}'",
    view = view, mt = mt) |>
    as_tibble() |>
    mutate(src = label, year = as.integer(format(datetime, "%Y")),
           is_zero = ammonium == 0)
}

amm <- bind_rows(
  pull_amm("btl_new", "ammonia",      "calcofi_bottle (QC'd)"),
  pull_amm("new",     "btl_ammonium", "calcofi_ctd-cast (CTD files)"))

by_depth <- amm |>
  mutate(bin = cut(depth_m, depth_bins, labels = depth_labs, include.lowest = TRUE)) |>
  filter(!is.na(bin)) |>
  summarise(n = n(), pct_zero = 100 * mean(is_zero),
            p50_pos = median(ammonium[ammonium > 0]),
            p95 = quantile(ammonium, 0.95), .by = c(src, bin))

by_depth |>
  select(src, bin, `censored — value = 0 (%)` = pct_zero,
         `median of detectable (µmol/L)` = p50_pos,
         `95th percentile (µmol/L)` = p95) |>
  pivot_longer(-c(src, bin), names_to = "measure", values_to = "value") |>
  mutate(measure = factor(measure, levels = c(
    "censored — value = 0 (%)", "median of detectable (µmol/L)",
    "95th percentile (µmol/L)"))) |>
  ggplot(aes(value, bin, colour = src, group = src)) +
  geom_path(linewidth = 0.8) + geom_point(size = 2.1) +
  scale_y_discrete(limits = rev(depth_labs)) +
  scale_colour_manual(values = c("calcofi_bottle (QC'd)" = CC_BLUE,
                                 "calcofi_ctd-cast (CTD files)" = CC_ORANGE),
                      name = NULL) +
  scale_x_continuous(expand = expansion(mult = c(0.08, 0.16))) +
  facet_wrap(~measure, nrow = 1, scales = "free_x") +
  labs(title = "Ammonium against depth, release v2026.07.30",
       subtitle = "Two carriers of the same quantity, now both in the headline product",
       x = NULL, y = "depth (m)",
       caption = glue("obs / ammonia + btl_ammonium · release {REL_NEW}"))
Figure 1: Ammonium against depth from the new release, both carriers. The shape from part 1 is reproduced: detectability and peak magnitude fall with depth while the typical detectable value barely moves. The CTD-file copy tracks the bottle series, which is the agreement check that matters now that both ship in the headline product.
Code
by_depth |>
  transmute(src, `depth (m)` = bin, n = format(n, big.mark = ","),
            `% zero` = round(pct_zero, 1),
            `median of detectable` = round(p50_pos, 3),
            `95th pct` = round(p95, 3)) |>
  arrange(src, `depth (m)`) |>
  kable(caption = "Ammonium by depth stratum and carrier (µmol/L)")
Table 1: Ammonium by depth stratum and carrier (µmol/L)
src depth (m) n % zero median of detectable 95th pct
calcofi_bottle (QC’d) 0–10 12,022 34.0 0.09 0.53
calcofi_bottle (QC’d) 11–50 17,655 36.7 0.12 0.85
calcofi_bottle (QC’d) 51–100 15,305 53.2 0.07 0.37
calcofi_bottle (QC’d) 101–200 17,973 70.1 0.05 0.12
calcofi_bottle (QC’d) 201–500 24,460 76.3 0.05 0.11
calcofi_bottle (QC’d) >500 3,074 74.7 0.06 0.14
calcofi_ctd-cast (CTD files) 0–10 8,231 32.8 0.16 0.78
calcofi_ctd-cast (CTD files) 11–50 14,580 31.3 0.20 0.98
calcofi_ctd-cast (CTD files) 51–100 13,142 49.6 0.12 0.60
calcofi_ctd-cast (CTD files) 101–200 12,878 66.7 0.07 0.26
calcofi_ctd-cast (CTD files) 201–500 15,255 72.7 0.07 0.20
calcofi_ctd-cast (CTD files) >500 3,299 71.3 0.07 0.20
Code
cens <- amm |>
  summarise(n = n(), pct_zero = 100 * mean(is_zero), .by = c(src, year)) |>
  filter(n > 100) |>
  complete(src, year = full_seq(year, 1))

ggplot(cens, aes(year, pct_zero, colour = src)) +
  geom_line(linewidth = 0.8) + geom_point(size = 2) +
  scale_colour_manual(values = c("calcofi_bottle (QC'd)" = CC_BLUE,
                                 "calcofi_ctd-cast (CTD files)" = CC_ORANGE),
                      name = NULL) +
  scale_y_continuous(labels = label_percent(scale = 1), limits = c(0, 100)) +
  labs(title = "Share of ammonium values exactly zero (below detection), by year",
       subtitle = "Gaps are years absent from that carrier, not zero censoring",
       x = NULL, y = "share exactly zero",
       caption = glue("release {REL_NEW} · the reliable censoring test is value == 0, not the flag"))
Figure 2: The censoring hazard from part 1, unchanged by these fixes. The below-detection flag is near-zero before 2013 while exact zeros already run 40-70%, so filtering on the flag removes almost nothing early and most of the record late. The two carriers now let this be cross-checked rather than taken on faith.
Code
by_lat <- amm |>
  filter(depth_m <= 50, !is.na(latitude)) |>
  mutate(lat_band = floor(latitude)) |>
  summarise(n = n(), pct_zero = 100 * mean(is_zero),
            p50 = median(ammonium), .by = c(src, lat_band)) |>
  filter(n > 200)

ggplot(by_lat, aes(lat_band, p50, colour = src)) +
  geom_line(linewidth = 0.8) + geom_point(size = 2.2) +
  scale_colour_manual(values = c("calcofi_bottle (QC'd)" = CC_BLUE,
                                 "calcofi_ctd-cast (CTD files)" = CC_ORANGE),
                      name = NULL) +
  scale_x_continuous(breaks = sort(unique(by_lat$lat_band)),
                     labels = \(x) paste0(x, "°N")) +
  labs(title = "Median ammonium by latitude band, upper 50 m",
       subtitle = "Higher and less often censored toward the north",
       x = NULL, y = "ammonium (µmol/L)",
       caption = glue("bottles/bottle-depths shallower than 50 m · release {REL_NEW}"))
Figure 3: Upper-50 m ammonium by latitude band, reproducing part 1’s monotonic south-to-north increase from the new release.

The netCDF files, re-checked

Both files were regenerated against v2026.07.30 and published under version-scoped paths, so the v2026.07.17 files remain exactly where they were — which is the point of versioning them.

Code
# read from the local build directory when present (this is the artifact that gets
# published, byte-for-byte); otherwise fetch the published copy
nc_local <- function(f) {
  p <- here("data/netcdf", f)
  if (file.exists(p)) return(p)
  dest <- file.path(tempdir(), f)
  ds <- sub("\\.nc$", "", f)
  utils::download.file(glue("{NC_SITE}/{ds}/{REL_NEW}/{f}"), dest, mode = "wb", quiet = TRUE)
  dest
}

nc_summary <- function(f) {
  p <- nc_local(f)
  nc <- nc_open(p); on.exit(nc_close(nc))
  vars <- names(nc$var)
  coord <- c("profile_id", "cruise_key", "grid_key", "time", "latitude",
             "longitude", "rowSize", "depth")
  tibble(
    file        = f,
    size_mb     = round(file.size(p) / 1048576, 1),
    profiles    = nc$dim[["profile"]]$len,
    obs_levels  = nc$dim[["obs"]]$len,
    sensor_vars = length(setdiff(vars, coord)),
    ammonium    = "btl_ammonium" %in% vars,
    featureType = ncatt_get(nc, 0, "featureType")$value)
}

bind_rows(nc_summary("ctd-cast.nc"), nc_summary("ctd-cast_full.nc")) |>
  kable(caption = glue("Regenerated CF NetCDF, release {REL_NEW}"))
Regenerated CF NetCDF, release v2026.07.30
file size_mb profiles obs_levels sensor_vars ammonium featureType
ctd-cast.nc 61.9 7175 465428 16 TRUE profile
ctd-cast_full.nc 2554.6 14336 6082688 54 TRUE profile

Against part 1: the thinned file went from 15 sensor variables to 16 (ammonium arriving) and its obs levels from 434,312 to 465,428 (the retained bottle depths). The full-resolution file went from 32 variables to 54 — the union fix. Its variable list had been inferred from a single 1998 cruise partition, which predates bottle nutrients being recorded alongside CTD casts, so every type introduced later was silently absent from a file advertised as full resolution. The publish step now takes the union across all 96 partitions and reports what the single-partition path would have declared, so it cannot regress quietly.

Accounting for “how many measurement types?”

Worth stating explicitly, because the ambiguity caused a real miscommunication: the CTD ingest loads the entire shared registry as a reference table, so a reader looking at its measurement_type preview sees every dataset’s types, not just CTD’s.

Code
reg <- read_csv(here("metadata/measurement_type.csv"), show_col_types = FALSE)
ctd_reg <- reg |> filter(str_detect(coalesce(`_source_datasets`, ""), "calcofi_ctd-cast"))

tibble(
  scope = c("measurement types in the registry (ALL datasets)",
            "datasets represented in the registry",
            "types owned by calcofi_ctd-cast",
            "of those, is_canonical = TRUE (-> thinned product)",
            "distinct types actually in thinned obs for ctd-cast",
            "sensor variables in ctd-cast_full.nc"),
  count = c(
    nrow(reg),
    length(unique(unlist(str_split(na.omit(reg$`_source_datasets`), ";")))),
    nrow(ctd_reg),
    sum(ctd_reg$is_canonical, na.rm = TRUE),
    tot$types_new,
    54L)) |>
  kable()
scope count
measurement types in the registry (ALL datasets) 198
datasets represented in the registry 14
types owned by calcofi_ctd-cast 54
of those, is_canonical = TRUE (-> thinned product) 16
distinct types actually in thinned obs for ctd-cast 16
sensor variables in ctd-cast_full.nc 54

So the CTD dataset has 54 registered variables of which 16 are canonical. The larger registry figure spans all datasets — calcofi_mets owns 54 of its own and calcofi_bottle 45.

Open items

Carried forward from part 1, with status:

item status
-99 reaching obs as a measurement fixed — 3,983,321 removed, 0 remain
ammonium absent from the thinned product fixed — 67,385 values, canonical
ammonium lost to depth thinning fixedretained_reason = 'bottle'
full netCDF declaring 32 of 54 types fixed — union across all partitions
physically impossible sensor maxima open — question 02, reported not gated
no quality flags on the canonical averages open — question 09; sensor-level flags exist, the averages carry none
calibration pairs (btl_temperature, salinity_btl, oxygen_btl_ml_l) excluded from the default release open — question 10; the same promotion now works correctly
below-detection flag unusable as a series open by nature — a documented property, not a bug

The censoring caveat is the one that most deserves repeating: report a censored fraction alongside order statistics, never a bare mean, and test on measurement_value == 0 rather than the flag.

Code
dbDisconnect(con, shutdown = TRUE)

Session

Code
sessioninfo::session_info(pkgs = c("duckdb", "ncdf4", "ggplot2"))$packages |>
  as_tibble() |> select(package, loadedversion, source) |> kable()
package loadedversion source
cli 3.6.6 CRAN (R 4.5.2)
cpp11 NA CRAN (R 4.5.2)
DBI 1.3.0 CRAN (R 4.5.2)
duckdb 1.5.2 CRAN (R 4.5.2)
farver 2.1.2 CRAN (R 4.5.0)
ggplot2 4.0.3 CRAN (R 4.5.2)
glue 1.8.1 CRAN (R 4.5.2)
gtable 0.3.6 CRAN (R 4.5.0)
isoband NA CRAN (R 4.5.2)
labeling 0.4.3 CRAN (R 4.5.0)
lifecycle 1.0.5 CRAN (R 4.5.2)
ncdf4 1.24 CRAN (R 4.5.0)
R6 2.6.1 CRAN (R 4.5.0)
RColorBrewer 1.1-3 CRAN (R 4.5.0)
rlang 1.2.0 CRAN (R 4.5.2)
S7 0.2.2 CRAN (R 4.5.2)
scales 1.4.0 CRAN (R 4.5.0)
vctrs 0.7.3 CRAN (R 4.5.2)
viridisLite NA CRAN (R 4.5.2)
withr 3.0.3 CRAN (R 4.5.2)