Tabular Data#

This tutorial processes contaminant concentrations measured in Antarctic marine tissue samples from the Scott Base area. The raw data arrives as a wide-format lab export — contaminants as rows, samples as columns — and is reshaped into three tidy, FAIR-ready CSVs: one per contaminant class.

Donwload raw lab file
raw_lab_chemical_data_example.csv

Donwload transformed raw lab file
transformed_chemical_data_example.csv

Download curated files
trace_metals_in_biomatter.csv
polycyclic_aromatic_hydrocarbons_in_biomatter.csv
polychlorinated_biphenyls_in_biomatter.csv


Script


Dependencies:

library(tidyverse)   # dplyr, tidyr, stringr, purrr
import pandas as pd
import re
from datetime import datetime, timezone

Step 1: Transform Raw Lab Data#

The raw laboratory CSV (raw_lab_chemical_data_example.csv) uses a wide format with metadata in the header: each row is a contaminant and each column (after the first three) is a sample. Sample column names encode the expedition, station-replicate, tissue species, and sample ID, separated by --. Before running the script, this file is manually reshaped into a tidy format (transformed_chemical_data_example.csv) where each sample is a row.

_images/input_chemical_1.png

⬇   manually reshaped to tidy format   ⬇


_images/input_chemical_2.png

Step 2: Read and Reshape#

The data is transposed from wide format into tidy format/long format: each row becomes one measurement (one contaminant in one sample). Contaminant names then become column headers, and samples become rows. Finally the data is split into three separate DataFrames — one per contaminant class.

After reshape — each row is now a sample, each contaminant is a column (trace metals subset shown):

type

unit

name

Arsenic

Cadmium

Copper

Lead

Mercury

Zinc

trace metals

mg/kg as rcvd

ANZ21201–SB2-1–Sphaerotylus–…

1.48

7.30

4.0

1.28

0.044

12.2

trace metals

mg/kg as rcvd

ANZ21201–SB2-2–Sphaerotylus–…

1.27

5.90

3.1

1.66

0.039

9.8

trace metals

mg/kg as rcvd

ANZ21201–SB2-1–Laternula–…

5.70

6.30

35.0

0.65

0.059

32.0

ds_tissue <- read.csv("transformed_chemical_data_example.csv", check.names = FALSE) %>%
  # Wide → long: each row is one contaminant × one sample
  pivot_longer(., -c("species", "type", "unit")) %>%
  # Long → wide: contaminants become columns, samples become rows
  pivot_wider(., names_from = "species", values_from = "value") %>%
  # Split into a named list by contaminant class
  split(., factor(.$type, levels = unique(.$type))) %>%
  # Drop columns that are entirely NA within each class
  map(~ select(.x, where(~ !all(is.na(.)))))
df = pd.read_csv("transformed_chemical_data_example.csv")

# Wide → long (pivot_longer equivalent)
df_long = df.melt(
    id_vars=["species", "type", "unit"],
    var_name="name",
    value_name="value"
)

# Long → wide: contaminants (species) become columns, samples become rows
df_wide = (
    df_long
    .pivot_table(
        index=["type", "unit", "name"],
        columns="species",
        values="value",
        aggfunc="first"
    )
    .reset_index()
)
df_wide.columns.name = None

# Split by contaminant class; drop all-NA columns within each group
groups = {
    t: df_wide[df_wide["type"] == t].dropna(axis=1, how="all").copy()
    for t in df_wide["type"].unique()
}

Step 3: Create Sample IDs#

The sample column name encodes four fields joined by --:

ANZ21201 -- SB2-1 -- Sphaerotylus -- 3194448.1
expedition   station-rep  tissue species   sample ID

A concise id column is built from the station-replicate and tissue species, and the original verbose name is dropped. The sample name is decoded into a readable id and measurement columns carry units:

id

Arsenic [mg/kg]

Cadmium [mg/kg]

Copper [mg/kg]

SB2-1-Sphaerotylus

1.48

7.30

4.0

SB2-2-Sphaerotylus

1.27

5.90

3.1

SB2-1-Laternula

5.70

6.30

35.0

ds_tissue <- ds_tissue %>%
  map(~ .x %>%
        rowwise() %>%
        mutate(
          id = paste0(
            str_split(name, "--")[[1]][2],  # station-rep  e.g. "SB2-1"
            "-",
            str_split(name, "--")[[1]][3]   # tissue       e.g. "Sphaerotylus"
          )
        ) %>%
        ungroup() %>%
        select(-name, -unit, -type) %>%
        rename_with(~ paste0(.x, " [mg/kg]"), -id) %>% # add units
        select(id, everything())
  )
def make_id(name: str) -> str:
    parts = name.split("--")
    return f"{parts[1]}-{parts[2]}"   # e.g. "SB2-1-Sphaerotylus"

for key, grp in groups.items():
    grp["id"] = grp["name"].apply(make_id)
    grp.drop(columns=["name", "unit", "type"], inplace=True)

    # Add unit label to every measurement column
    meas_cols = [c for c in grp.columns if c != "id"]
    grp.rename(columns={c: f"{c} [mg/kg]" for c in meas_cols}, inplace=True)

    # id column first
    groups[key] = grp[["id"] + [c for c in grp.columns if c != "id"]]

Step 4: Add Spatial and Temporal Metadata#

Each sample inherits the collection’s spatial location in decimal degrees and collection date is added in ISO 8601 format. Columns are lowercased for consistency. The dataset is spatially and temporally anchored.

id

time

latitude

longitude

arsenic [mg/kg]

cadmium [mg/kg]

SB2-1-Sphaerotylus

2023-10-21T00:00:00Z

-77.85383

166.7759

1.48

7.30

SB2-2-Sphaerotylus

2023-10-21T00:00:00Z

-77.85383

166.7759

1.27

5.90

SB2-1-Laternula

2023-10-21T00:00:00Z

-77.85383

166.7759

5.70

6.30

ds_tissue <- ds_tissue %>%
  map(~ .x %>%
        mutate(
          latitude  = -77.85383,
          longitude = 166.7759,
          time      = ymd_hms("2023-10-21T00:00:00Z")
        ) %>%
        rename_with(tolower) %>%
        select(id, time, latitude, longitude, everything())
  )
for key, grp in groups.items():
    grp["latitude"]  = -77.85383
    grp["longitude"] = 166.7759
    grp["time"]      = datetime(2023, 10, 21, tzinfo=timezone.utc)
    grp.columns      = [c.lower() for c in grp.columns]

    front = ["id", "time", "latitude", "longitude"]
    rest  = [c for c in grp.columns if c not in front]
    groups[key] = grp[front + rest]

Step 5: Standardise Naming and Data Types#

Trace metal columns are cast to numeric (all values in this class are clean numbers). PAH and PCB column names are corrected to match standard nomenclature: two PAH names are updated to use bracket notation, and PCB column prefixes are expanded from abbreviation to full name. I used the NERC Vocabulary Server to verify naming.

# Trace metals: enforce numeric type
ds_tissue$`trace metals` <- ds_tissue$`trace metals` %>%
  mutate(across(-c(id, time), as.numeric))

# PAHs: correct two non-standard names
ds_tissue$`Polycyclic Aromatic Hydrocarbons in Biomatter` <-
  ds_tissue$`Polycyclic Aromatic Hydrocarbons in Biomatter` %>%
  rename(
    `benzo[a]pyrene [mg/kg]`       = `benzo[a]pyrene (bap) [mg/kg]`,
    `indeno[1,2,3-cd]pyrene [mg/kg]` = `indeno(1,2,3-c,d)pyrene [mg/kg]`
  )

# PCBs: expand abbreviated prefix to full name
ds_tissue$`Polychlorinated biphenyls in Biomatter` <-
  ds_tissue$`Polychlorinated biphenyls in Biomatter` %>%
  rename_with(~ str_replace_all(., regex("^pcb-", ignore_case = TRUE),
                                "polychlorinated biphenyl "))
# Trace metals: enforce numeric type
tm = groups["trace metals"]
num_cols = [c for c in tm.columns if c not in ("id", "time")]
groups["trace metals"][num_cols] = tm[num_cols].apply(pd.to_numeric, errors="coerce")

# PAHs: correct two non-standard names
pah_key = "Polycyclic Aromatic Hydrocarbons in Biomatter"
groups[pah_key].rename(columns={
    "benzo[a]pyrene (bap) [mg/kg]":      "benzo[a]pyrene [mg/kg]",
    "indeno(1,2,3-c,d)pyrene [mg/kg]":   "indeno[1,2,3-cd]pyrene [mg/kg]",
}, inplace=True)

# PCBs: expand abbreviated prefix to full name
pcb_key = "Polychlorinated biphenyls in Biomatter"
groups[pcb_key].rename(
    columns=lambda c: re.sub(r"(?i)^pcb-", "polychlorinated biphenyl ", c),
    inplace=True
)

Step 6: Export#

Each contaminant class is written to its own CSV file, ready for archiving and linking to the catalogue.

write.csv(groups[["trace metals"]],
          "trace_metals_in_biomatter.csv", row.names = FALSE)

write.csv(groups[["Polycyclic Aromatic Hydrocarbons in Biomatter"]],
          "polycyclic_aromatic_hydrocarbons_in_biomatter.csv", row.names = FALSE)

write.csv(groups[["Polychlorinated biphenyls in Biomatter"]],
          "polychlorinated_biphenyls_in_biomatter.csv", row.names = FALSE)
name_map = {
    "trace metals":                                  "trace_metals_in_biomatter.csv",
    "Polycyclic Aromatic Hydrocarbons in Biomatter": "polycyclic_aromatic_hydrocarbons_in_biomatter.csv",
    "Polychlorinated biphenyls in Biomatter":        "polychlorinated_biphenyls_in_biomatter.csv",
}

for key, filename in name_map.items():
    groups[key].to_csv(filename, index=False)

Download output files
trace_metals_in_biomatter.csv
polycyclic_aromatic_hydrocarbons_in_biomatter.csv
polychlorinated_biphenyls_in_biomatter.csv

Further

Once exported, link each CSV to a metadata record in the Antarctic Metadata Catalogue. These records are then grouped into a collection.