Fram Strait 2020-2025

This handler converts Fram Strait I-129, U-236, and U-238 seawater CSV records from Zenodo into MARIS-standard NetCDF4 data. RECORDS lists six source files spanning 2020 to 2025.

I-129 measurements come from ETH Zürich (Laboratory of Ion Beam Physics). U-236 and U-238 measurements come from ETH and from VERA (Vienna Environmental Research Accelerator facility, University of Vienna). Where VERA reported a value, the handler uses it and drops the matching ETH value. U-238 is converted from ppb to atoms/kg.

The handler reshapes each record’s wide value and uncertainty columns into one row per measurement, and combines all records into the SEAWATER group. Each output row keeps its source Cruise identifier and carries MARIS identifiers for nuclide, unit, and detection status. Laboratory reconciliation is in progress.

Adapters

The Fram Strait records on Zenodo need to be homogenized before ingestion. The FS2020_21 record contains measurements from two laboratories: the Laboratory of Ion Beam Physics at ETH Zürich, Switzerland (ETH), and the Vienna Environmental Research Accelerator facility at the University of Vienna, Austria (VERA). The FS2022 through FS2025 records contain ETH measurements only. Their latitude and longitude columns use different names than FS2020_21, and their date columns are inconsistent even among themselves: some records report a single Date column, others report separate Year, Month, and Day columns.

Some samples were measured by both ETH and VERA. Measurement values and their uncertainties arrive in wide format: each column name encodes a laboratory, nuclide, and unit, such as ETH_unc_I129_at_l, and VERA’s columns carry no laboratory prefix. The handler prefixes VERA’s columns with VERA_ and the FS2022-2025 records’ columns with ETH_, so every record follows the same {lab}_[unc_]{nuclide}_{unit} naming before reshaping.

For a given sample and nuclide, MARIS reports a measurement from one laboratory. The data provider set this precedence: ETH measured I-129 in every case. For U-236 and U-238, the handler uses VERA’s value when VERA reported one, and ETH’s value otherwise. The handler drops the U-236/U-238 ratio columns that ETH alone reported, since MARIS derives that ratio from the other columns.


source

load_data

def load_data()->dict:

Fetch Fram Strait CSV records and return one combined SEAWATER DataFrame

Exported source
def _lab_meas(lab: str, stem: str) -> tuple:
    "Value and uncertainty column names for `stem` as measured by `lab`"
    return f'{lab}_{stem}', f'{lab}_unc_{stem}'
Exported source
def _prefix_lab_cols(df: pd.DataFrame, lab: str, cols: list) -> pd.DataFrame:
    "Prefix `cols` and their `unc_` twins with `lab_`, recording which lab produced them"
    ren = {c: f'{lab}_{c}' for c in cols} | {f'unc_{c}': f'{lab}_unc_{c}' for c in cols}
    return df.rename(columns=ren)
Exported source
def _prefer_vera(df: pd.DataFrame, stem: str) -> pd.DataFrame:
    "Where VERA reported `stem`, clear ETH's measurement (value and unc)"
    (v, vu), (e, eu) = _lab_meas('VERA', stem), _lab_meas('ETH', stem)
    df.loc[df[v].notna(), [e, eu]] = np.nan
    return df
Exported source
META_COLS = ['Cruise', 'Station', 'Latitude_degN', 'Longitude_degE', 'Date',
             'Niskin', 'Pressure_dbar', 'PracticalSalinity', 'Temperature_degC', 'Sample_ID']

ETH_COLS = ['ETH_I129_at_l', 'ETH_unc_I129_at_l', 'ETH_I129_at_kg', 'ETH_unc_I129_at_kg',
            'ETH_U236_at_l', 'ETH_unc_U236_at_l', 'ETH_U236_at_kg', 'ETH_unc_U236_at_kg',
            'ETH_U238_ppb', 'ETH_unc_U238_ppb']

VERA_COLS = ['VERA_U236_at_l', 'VERA_unc_U236_at_l', 'VERA_U236_at_kg', 'VERA_unc_U236_at_kg',
             'VERA_U238_ppb', 'VERA_unc_U238_ppb']

RAW_COLS = META_COLS + ETH_COLS + VERA_COLS
Exported source
def _normalize_2020_21(df: pd.DataFrame) -> pd.DataFrame:
    "Align FS20/FS21 records (ETH + VERA labs) to the canonical Fram Strait raw schema"
    df = df.drop(columns=[c for c in df.columns if 'U236_U238' in c])
    df = df.rename(columns={
        'Temperature_degC_ctd': 'Temperature_degC',
        'Salinity_ctd':         'PracticalSalinity',
        'Bottle':               'Niskin',
    })
    df = _prefix_lab_cols(df, 'ETH',  ['I129_at_l', 'I129_at_kg'])
    df = _prefix_lab_cols(df, 'VERA', ['U236_at_l', 'U236_at_kg', 'U238_ppb'])
    for stem in ['U236_at_l', 'U236_at_kg', 'U238_ppb']:
        df = _prefer_vera(df, stem)
    df['Date'] = pd.to_datetime(df['Date'], format='%d/%m/%Y').dt.strftime('%Y-%m-%d')
    return df[RAW_COLS]
Exported source
def _normalize_2022_25(df: pd.DataFrame) -> pd.DataFrame:
    "Align FS22-25 records (ETH lab only) to the canonical Fram Strait raw schema"
    df = df.rename(columns={'Latitude': 'Latitude_degN', 'Longitude': 'Longitude_degE'})
    if 'Date' not in df.columns:
        df['Date'] = pd.to_datetime(df[['Year', 'Month', 'Day']]).dt.strftime('%Y-%m-%d')
    df = _prefix_lab_cols(df, 'ETH', ['I129_at_l', 'I129_at_kg'])
    return df.reindex(columns=RAW_COLS)
Exported source
RECORDS = {
    "FS2020_21": {
        "url": "https://zenodo.org/records/19387002/files/FramStrait_2020_2021_radionuclides.csv?download=1",
        "adapter": _normalize_2020_21
    },
    "FS2022_i129": {
        "url": "https://zenodo.org/records/20425174/files/FS2022_i129.csv?download=1",
        "adapter": _normalize_2022_25
    },
    "FS2022S_i129": {
        "url": "https://zenodo.org/records/20628610/files/FS2022S_data_i129.csv?download=1",
        "adapter": _normalize_2022_25
    },
    "FS2023_i129": {
        "url": "https://zenodo.org/records/20448047/files/FS2023_i129.csv?download=1",
        "adapter": _normalize_2022_25
    },
    "FS2024_i129": {
        "url": "https://zenodo.org/records/20761705/files/FS2024_i129.csv?download=1",
        "adapter": _normalize_2022_25
    },
    "FS2025_i129": {
        "url": "https://zenodo.org/records/20761832/files/FS2025_i129.csv?download=1",
        "adapter": _normalize_2022_25
    },
}


status = 'Active'
dfs = load_data()
dfs['SEAWATER'].columns
Index(['Cruise', 'Station', 'Latitude_degN', 'Longitude_degE', 'Date',
       'Niskin', 'Pressure_dbar', 'PracticalSalinity', 'Temperature_degC',
       'Sample_ID', 'ETH_I129_at_l', 'ETH_unc_I129_at_l', 'ETH_I129_at_kg',
       'ETH_unc_I129_at_kg', 'ETH_U236_at_l', 'ETH_unc_U236_at_l',
       'ETH_U236_at_kg', 'ETH_unc_U236_at_kg', 'ETH_U238_ppb',
       'ETH_unc_U238_ppb', 'VERA_U236_at_l', 'VERA_unc_U236_at_l',
       'VERA_U236_at_kg', 'VERA_unc_U236_at_kg', 'VERA_U238_ppb',
       'VERA_unc_U238_ppb'],
      dtype='str')
print(dfs['SEAWATER'].describe(include='number').T[['count', 'mean', 'min', 'max']])
                     count          mean           min           max
Station              877.0  2.052212e+02  1.000000e+00  4.150000e+02
Latitude_degN        877.0  7.886102e+01  7.867350e+01  8.040850e+01
Longitude_degE       877.0 -3.883666e+00 -1.699983e+01  8.005167e+00
Niskin               877.0  1.040023e+01  1.000000e+00  2.400000e+01
Pressure_dbar        877.0  1.859958e+02  1.992000e+00  2.703528e+03
PracticalSalinity    875.0  3.358706e+01  2.850500e+01  3.510550e+01
Temperature_degC     876.0  6.933839e-01 -1.815000e+00  9.699700e+00
Sample_ID            877.0  6.550969e+01  1.000000e+00  1.800000e+02
ETH_I129_at_l        872.0  3.298183e+09  1.424944e+08  7.422415e+09
ETH_unc_I129_at_l    872.0  1.120120e+08  2.905559e+06  5.696773e+08
ETH_I129_at_kg       345.0  3.389845e+09  1.390414e+08  7.092565e+09
ETH_unc_I129_at_kg   345.0  1.609641e+08  2.835150e+06  5.560286e+08
ETH_U236_at_l         17.0  1.289830e+07  1.110658e+07  1.974957e+07
ETH_unc_U236_at_l     17.0  5.531827e+05  3.803456e+05  8.944368e+05
ETH_U236_at_kg        17.0  1.254941e+07  1.080000e+07  1.921000e+07
ETH_unc_U236_at_kg    17.0  5.382353e+05  3.700000e+05  8.700000e+05
ETH_U238_ppb          17.0  3.306471e+00  2.970000e+00  3.490000e+00
ETH_unc_U238_ppb      17.0  6.647059e-02  5.000000e-02  1.100000e-01
VERA_U236_at_l       155.0  1.813813e+07  4.730000e+06  3.901000e+07
VERA_unc_U236_at_l   155.0  1.331226e+06  4.000000e+05  7.650000e+06
VERA_U236_at_kg      155.0  1.765394e+07  4.548323e+06  3.799334e+07
VERA_unc_U236_at_kg  155.0  1.295806e+06  3.846362e+05  7.450630e+06
VERA_U238_ppb        155.0  3.240774e+00  2.640000e+00  3.600000e+00
VERA_unc_U238_ppb    155.0  1.118065e-01  3.000000e-02  2.700000e-01

Column renaming, time parsing, and depth conversion

FS is seawater-only. RenameColsCB maps provider metadata columns to MARIS working names, ParseDateTimeCB converts the collection date to UTC TIME, and AddDepthCB computes sampling depth from the reported Pressure_dbar using the TEOS-10 equation of state via the gsw library.


source

RenameColsCB

def RenameColsCB(
    grps:list=None, # Groups to process; None = all groups in `tfm.dfs`
):

Map Fram Strait provider columns to MARIS standard names

# Verify RenameColsCB maps provider columns to MARIS names
dfs_mock = {
    "SEAWATER": pd.DataFrame({
        "Station": [341],
        "Latitude_degN": [78.832167],
        "Longitude_degE": [-2.004667],
        "PracticalSalinity": [34.9],
        "Temperature_degC": [0.0538],
        "Sample_ID": [1],
    })
}

tfm = Transformer(dfs_mock, cbs=[RenameColsCB()])
tfm()

for col in ["STATION", "LAT", "LON", "SAL", "TEMP", "SMP_ID_PROVIDER"]:
    test_eq(col in tfm.dfs["SEAWATER"].columns, True)

print("RenameColsCB: FS2025 columns mapped correctly. ✓")
RenameColsCB: FS2025 columns mapped correctly. ✓
tfm = Transformer(dfs, cbs=[RenameColsCB()])
tfm()
print(
    tfm.dfs["SEAWATER"][
        ["LAT", "LON", "STATION", "SAL", "TEMP", "SMP_ID_PROVIDER"]
    ].head(2).to_string()
)
         LAT       LON  STATION      SAL    TEMP  SMP_ID_PROVIDER
0  78.833167 -0.003667       47  34.9311  1.7576                1
1  78.833167 -0.003667       47  34.9247  2.4914                2

source

ParseDateTimeCB

def ParseDateTimeCB(
    grps:list=None, # Groups to process; None = all groups in `tfm.dfs`
):

Parse FS collection date into a UTC TIME value

# Verify ParseDateTimeCB parses Date into UTC TIME
dfs_mock = {"SEAWATER": pd.DataFrame({"Date": ["2025-07-30"]})}

tfm = Transformer(dfs_mock, cbs=[ParseDateTimeCB()])
tfm()

test_eq("TIME" in tfm.dfs["SEAWATER"].columns, True)
test_eq("Date" not in tfm.dfs["SEAWATER"].columns, True)
print(f"ParseDateTimeCB: TIME = {tfm.dfs['SEAWATER']['TIME'].iloc[0]}. ✓")
ParseDateTimeCB: TIME = 2025-07-30 00:00:00+00:00. ✓
tfm = Transformer(dfs, cbs=[
    RenameColsCB(),
    ParseDateTimeCB()
])
tfm()

print(tfm.dfs["SEAWATER"][["TIME"]].head(3).to_string())
                       TIME
0 2020-08-27 00:00:00+00:00
1 2020-08-27 00:00:00+00:00
2 2020-08-27 00:00:00+00:00
tfm.dfs["SEAWATER"].head()
Cruise STATION LAT LON Niskin Pressure_dbar SAL TEMP SMP_ID_PROVIDER ETH_I129_at_l ... ETH_unc_U236_at_kg ETH_U238_ppb ETH_unc_U238_ppb VERA_U236_at_l VERA_unc_U236_at_l VERA_U236_at_kg VERA_unc_U236_at_kg VERA_U238_ppb VERA_unc_U238_ppb TIME
0 FS20 47 78.833167 -0.003667 13 400.374 34.9311 1.7576 1 1.744666e+09 ... NaN NaN NaN 13570000.0 1120000.0 13177053.06 1.087568e+06 3.35 0.15 2020-08-27 00:00:00+00:00
1 FS20 47 78.833167 -0.003667 15 200.500 34.9247 2.4914 2 2.015258e+09 ... NaN NaN NaN 12520000.0 470000.0 12169354.84 4.568368e+05 3.11 0.06 2020-08-27 00:00:00+00:00
2 FS20 47 78.833167 -0.003667 16 150.565 34.9469 2.9641 3 2.142153e+09 ... NaN NaN NaN 11910000.0 1220000.0 11579324.34 1.186127e+06 3.09 0.14 2020-08-27 00:00:00+00:00
3 FS20 47 78.833167 -0.003667 17 100.702 34.9915 4.0705 4 2.384657e+09 ... NaN NaN NaN 13760000.0 620000.0 13381607.36 6.029503e+05 3.13 0.11 2020-08-27 00:00:00+00:00
4 FS20 47 78.833167 -0.003667 18 74.915 34.9148 4.1901 5 2.700046e+09 ... NaN NaN NaN 14300000.0 490000.0 13909361.65 4.766145e+05 3.10 0.08 2020-08-27 00:00:00+00:00

5 rows × 26 columns

We use the Thermodynamic Equation Of Seawater - 2010 (TEOS-10) via the gsw Python package (gsw.z_from_p) to convert reported Pressure_dbar to sampling depth in metres. The conversion uses the reported latitude for the gravitational acceleration correction. We have rounded depths to one decimal place.


source

AddDepthCB

def AddDepthCB(
    grps:list=None, # Groups to process; None = all groups in `tfm.dfs`
):

Compute sampling depth using Thermodynamic Equation of SeaWater 2010 (TEOS-10)

tfm = Transformer(dfs, cbs=[
    RenameColsCB(),
    ParseDateTimeCB(),
    AddDepthCB()
])
tfm()

print(tfm.dfs["SEAWATER"].SMP_DEPTH)
0      395.8
1      198.3
2      148.9
3       99.6
4       74.1
       ...  
872    198.3
873    148.6
874     99.1
875     49.6
876      4.9
Name: SMP_DEPTH, Length: 877, dtype: float64

Reshaping from wide to long format

As already mentioned, Fram Strait data layout is wide. Each sample occupies one row, and each laboratory result sits in its own column, named for the laboratory, nuclide, and unit, with a twin column holding its uncertainty.

In MARIS, each variable (lab, unit, value, uncertainty) is a column, each measurement is a row.

MeltFramStraitCB performs that reshape. It matches every measurement column by name, melts the values and uncertainties into one row per measurement so that each value keeps its own uncertainty, and drops the rows where no value was reported.

ImportantFEEDBACK TO DATA PROVIDER

Column headers in the Fram Strait dataset encode multiple pieces of information (lab, nuclide, unit) into a single string. This adds friction to data ingestion, since every new dataset with a different naming convention requires custom parsing logic. MARIS prefers a tidy data layout (Wickham 2014, doi:10.18637/jss.v059.i10 where each column holds a single variable, and metadata like nuclide, unit, and method are stored as separate columns, not baked into the header.


source

MeltFramStraitCB

def MeltFramStraitCB(
    grps:list=None, # Groups to process; None = all groups in `tfm.dfs`
):

Reshape wide Fram Strait value and uncertainty columns into one row per measurement

Exported source
MEAS_PAT = re.compile(r'^(?P<LAB>[A-Z]+)_(?:(?P<UNC>unc)_)?(?P<NUCLIDE>[A-Za-z]+\d+)_(?P<UNIT>.+)$')
dfs_mock = {
    "SEAWATER": pd.DataFrame({
        "STATION": [101, 102],
        "LAT": [78.5, 79.0],
        "LON": [1.0, 2.0],
        "TIME": pd.to_datetime(["2025-07-30", "2025-07-31"], utc=True),
        "ETH_I129_at_l": [1.2e9, 2.4e9],
        "ETH_unc_I129_at_l": [1.0e8, 2.0e8],
        "ETH_I129_at_kg": [3.4e9, np.nan],
        "ETH_unc_I129_at_kg": [3.0e8, np.nan],
        "VERA_U236_at_l": [1.5e7, np.nan],
        "VERA_unc_U236_at_l": [1.2e6, np.nan],
    })
}

tfm = Transformer(dfs_mock, cbs=[MeltFramStraitCB()])
tfm()
out = tfm.dfs["SEAWATER"].sort_values(["STATION", "LAB", "NUCLIDE", "UNIT"]).reset_index(drop=True)

print(out[["STATION", "LAB", "NUCLIDE", "UNIT", "VALUE", "UNC"]].to_string(index=False))

test_eq(len(out), 4)
test_eq(out["STATION"].tolist(), [101, 101, 101, 102])
test_eq(out["LAB"].tolist(), ["ETH", "ETH", "VERA", "ETH"])
test_eq(out["NUCLIDE"].tolist(), ["I129", "I129", "U236", "I129"])
test_eq(out["UNIT"].tolist(), ["at_kg", "at_l", "at_l", "at_l"])
test_eq(out["VALUE"].tolist(), [3.4e9, 1.2e9, 1.5e7, 2.4e9])
test_eq(out["UNC"].tolist(), [3.0e8, 1.0e8, 1.2e6, 2.0e8])

print("MeltFramStraitCB: wide results reshaped, two labs kept separate, missing values removed. ✓")
 STATION  LAB NUCLIDE  UNIT        VALUE         UNC
     101  ETH    I129 at_kg 3400000000.0 300000000.0
     101  ETH    I129  at_l 1200000000.0 100000000.0
     101 VERA    U236  at_l   15000000.0   1200000.0
     102  ETH    I129  at_l 2400000000.0 200000000.0
MeltFramStraitCB: wide results reshaped, two labs kept separate, missing values removed. ✓

The following example shows how MeltFramStraitCB converts the wide value and uncertainty columns for both laboratories into one row per reported measurement, keeps laboratory, nuclide, and unit as separate columns, and drops missing values.

tfm = Transformer(dfs, cbs=[
    RenameColsCB(),
    ParseDateTimeCB(),
    AddDepthCB(),
    MeltFramStraitCB(),
])
tfm()
out = tfm.dfs['SEAWATER']
print(f"Rows: {len(out)}")
print(out.groupby(['LAB', 'NUCLIDE', 'UNIT']).size())
print(out[['Cruise', 'LAB', 'NUCLIDE', 'UNIT', 'VALUE', 'UNC']].head(6).to_string())
Rows: 1733
LAB   NUCLIDE  UNIT 
ETH   I129     at_kg    345
               at_l     872
      U236     at_kg     17
               at_l      17
      U238     ppb       17
VERA  U236     at_kg    155
               at_l     155
      U238     ppb      155
dtype: int64
  Cruise   LAB NUCLIDE   UNIT         VALUE           UNC
0   FS20   ETH    I129  at_kg  1.702534e+09  1.341982e+08
1   FS20   ETH    I129   at_l  1.744666e+09  1.375192e+08
5   FS20  VERA    U236  at_kg  1.317705e+07  1.087568e+06
6   FS20  VERA    U236   at_l  1.357000e+07  1.120000e+06
7   FS20  VERA    U238    ppb  3.350000e+00  1.500000e-01
8   FS20   ETH    I129  at_kg  1.966604e+09  1.549447e+08
# Each output row carries a value and its uncertainty, so the non-null cell count
# across all measurement columns should be exactly twice the melted row count.
wide = dfs['SEAWATER']
meas = [c for c in wide.columns if MEAS_PAT.fullmatch(c)]
test_eq(len(out) * 2, wide[meas].notna().sum().sum())
print(f"{len(out)} measurements from {len(meas)} columns. ✓")
1733 measurements from 16 columns. ✓
EXPECTED = {
    ('ETH', 'I129', 'at_l'): 872, ('ETH', 'I129', 'at_kg'): 345,
    ('ETH', 'U236', 'at_l'): 17, ('ETH', 'U236', 'at_kg'): 17,
    ('ETH', 'U238', 'ppb'): 17, ('VERA', 'U236', 'at_l'): 155,
    ('VERA', 'U236', 'at_kg'): 155, ('VERA', 'U238', 'ppb'): 155,
}
test_eq(out.groupby(['LAB', 'NUCLIDE', 'UNIT']).size().to_dict(), EXPECTED)

Convert U-238 units

Fram Strait reports U-238 in parts-per-billion (ppb, mass of U per mass of seawater), while MARIS requires atoms per kg. ConvertU238CB converts the VALUE column for U-238 rows using:

\[\text{atoms/kg} = C_{\text{ppb}} \times 10^{-9} \times 1000 \times \frac{N_A}{M_{238}}\]

where (N_A = 6.02214076 {23} {-1}) is Avogadro’s number and (M_{238} = 238.05 ^{-1}) is the molar mass of U-238, giving a conversion factor of (2.530 {15} {-1},^{-1}). Rows with other nuclides are left unchanged.


source

ConvertU238CB

def ConvertU238CB(
    grps:list=None, # Groups to process; None = all groups in `tfm.dfs`
):

Convert U-238 VALUE from ppb to atoms/kg

Exported source
# Convert U-238 from ppb to atoms/kg: ppb * 1e-9 * 1000 * (1/238.05) * 6.02214076e23
U238_PPB_TO_AT_KG = 2.529_780e15
# Verify ConvertU238CB scales VALUE and UNC for U-238 only
dfs_mock = {'SEAWATER': pd.DataFrame({
    'NUCLIDE': ['I129', 'U238'],
    'UNIT': ['at_kg', 'at_ppb'],
    'VALUE': [1.0, 2.0],
    'UNC': [0.1, 0.2],
})}
tfm = Transformer(dfs_mock, cbs=[ConvertU238CB()])
tfm()
out = tfm.dfs['SEAWATER']

test_eq(out.loc[out['NUCLIDE']=='U238', 'VALUE'].iloc[0], 2.0 * U238_PPB_TO_AT_KG)
test_eq(out.loc[out['NUCLIDE']=='U238', 'UNC'].iloc[0], 0.2 * U238_PPB_TO_AT_KG)
test_eq(out.loc[out['NUCLIDE']=='U238', 'UNIT'].iloc[0], 'at_kg')
print("ConvertU238CB: I-129 unchanged, U-238 scaled, UNIT updated. ✓")
ConvertU238CB: I-129 unchanged, U-238 scaled, UNIT updated. ✓
tfm = Transformer(dfs, cbs=[
    RenameColsCB(),
    ParseDateTimeCB(),
    AddDepthCB(),
    MeltFramStraitCB(),
    ConvertU238CB()])
tfm()
out = tfm.dfs['SEAWATER']
u238 = out[out['NUCLIDE']=='U238']

print(f"U-238 rows: {len(u238)}, mean VALUE = {u238['VALUE'].mean():.2e} atoms/kg")
U-238 rows: 172, mean VALUE = 8.21e+15 atoms/kg

Remap nomenclatures to MARIS identifiers

The melt produces string columns: NUCLIDE (I129, U236, U238), UNIT (at_kg, at_l), and LAB columns. MARIS stores these as integer foreign-key IDs from the central nomenclatures.

RemapCB maps source column values through a lookup table to a target column.

Exported source
# MARIS nuclide IDs confirmed via get_lut('NUCLIDE')
NUCLIDE_LUT = {'I129': 28, 'U236': 108, 'U238': 64}

# MARIS unit IDs confirmed via get_lut('UNIT')
UNIT_LUT = {'at_kg': 9, 'at_l': 12}

# MARIS unit IDs confirmed via get_lut('LAB')
LAB_LUT = {'ETH': 345, 'VERA': 504}
# Verify RemapCB assigns correct NUCLIDE, UNIT, LAB, AREA IDs
dfs_mock = {'SEAWATER': pd.DataFrame({
    'NUCLIDE': ['I129', 'U236'],
    'LAB': ['ETH', 'VERA'],
    'UNIT': ['at_kg', 'at_l'],
    'VALUE': [1.0, 2.0],
    'UNC': [0.1, 0.2],
})}
tfm = Transformer(dfs_mock, cbs=[
    RemapCB(lut=NUCLIDE_LUT, col_remap='NUCLIDE', col_src='NUCLIDE'),
    RemapCB(lut=UNIT_LUT, col_remap='UNIT', col_src='UNIT'),
    RemapCB(lut=LAB_LUT, col_remap='LAB', col_src='LAB')
])
tfm()
out = tfm.dfs['SEAWATER']

test_eq(out['NUCLIDE'].tolist(), [28, 108])
test_eq(out['UNIT'].tolist(), [9, 12])
print("RemapCB: all nomenclatures mapped to correct MARIS IDs. ✓")
RemapCB: all nomenclatures mapped to correct MARIS IDs. ✓
tfm = Transformer(dfs, cbs=[
    RenameColsCB(),
    ParseDateTimeCB(),
    AddDepthCB(),
    MeltFramStraitCB(),
    ConvertU238CB(),
    RemapCB(lut=NUCLIDE_LUT, col_remap='NUCLIDE', col_src='NUCLIDE'),
    RemapCB(lut=UNIT_LUT, col_remap='UNIT', col_src='UNIT'),
    RemapCB(lut=LAB_LUT, col_remap='LAB', col_src='LAB')
])
tfm()
out = tfm.dfs['SEAWATER']
print(f"Rows: {len(out)}")
print(out.groupby(['LAB', 'NUCLIDE', 'UNIT']).size())
print(out[['Cruise', 'LAB', 'NUCLIDE', 'UNIT', 'VALUE', 'UNC']].head(6).to_string())
print('Units: ', out.UNIT.unique())
Rows: 1733
LAB  NUCLIDE  UNIT
345  28       9       345
              12      872
     64       9        17
     108      9        17
              12       17
504  64       9       155
     108      9       155
              12      155
dtype: int64
  Cruise  LAB  NUCLIDE  UNIT         VALUE           UNC
0   FS20  345       28     9  1.702534e+09  1.341982e+08
1   FS20  345       28    12  1.744666e+09  1.375192e+08
5   FS20  504      108     9  1.317705e+07  1.087568e+06
6   FS20  504      108    12  1.357000e+07  1.120000e+06
7   FS20  504       64     9  8.474763e+15  3.794670e+14
8   FS20  345       28     9  1.966604e+09  1.549447e+08
Units:  [ 9 12]

Detection Limit

The provider does not report a detection level, but MARIS requires this field. The available MARIS categories are:

get_lut('DL')
{'Not applicable': -1,
 'Not available': 0,
 'Detected value': 1,
 'Detection limit': 2,
 'Not detected': 3,
 'Derived': 4}

source

AddDetectionLimitCB

def AddDetectionLimitCB(
    grps:list=None, # Groups to process; None = all groups in `tfm.dfs`
):

Assign missing Detection Limit column to MARIS ‘Detected value: 1’ category

Standardise final columns

SanitizeLonLatCB, EncodeTimeCB, and AddSampleIDCB need no FS-specific callback. SanitizeLonLatCB validates lat/lon ranges and corrects sign convention. EncodeTimeCB encodes TIME into the NetCDF numeric representation. AddSampleIDCB assigns sequential SMP_ID and preserves SMP_ID_PROVIDER. All three come from marisco.callbacks.

Cast STATION to string before encoding

FS’s Station column is pure numeric, so pandas infers int64. But STATION maps to a string-typed NetCDF variable, so FormatStationCB casts it to str as the last step before encoding. This callback is defined locally in this notebook only.


source

FormatStationCB

def FormatStationCB(
    grps:list=None, # Groups to process; None = all groups in `tfm.dfs`
):

Cast STATION to str for the NetCDF4 string-typed station variable

# Verify FormatStationCB casts STATION to str
dfs_mock = {'SEAWATER': pd.DataFrame({'STATION': [341, 415]})}
tfm = Transformer(dfs_mock, cbs=[FormatStationCB()])
tfm()
out = tfm.dfs['SEAWATER']
test_eq(out['STATION'].tolist(), ['341', '415'])
test_eq(all(isinstance(v, str) for v in out['STATION']), True)  # what the encoder's per-element NetCDF write actually needs
print("FormatStationCB: STATION cast to str. ✓")
FormatStationCB: STATION cast to str. ✓

get_cbs returns the callbacks of the pipeline, in order. The cell below and encode both use it, so the pipeline is defined once. It returns new instances on each call, so no state carries over between runs.


source

get_cbs

def get_cbs()->list:

Callbacks, in pipeline order, that turn raw Fram Strait data into MARIS-standard DataFrames

Exported source
def get_cbs() -> list:
    "Callbacks, in pipeline order, that turn raw Fram Strait data into MARIS-standard DataFrames"
    return [
        RenameColsCB(),
        ParseDateTimeCB(),
        AddDepthCB(),
        MeltFramStraitCB(),
        ConvertU238CB(),
        RemapCB(lut=NUCLIDE_LUT, col_remap='NUCLIDE', col_src='NUCLIDE'),
        RemapCB(lut=UNIT_LUT, col_remap='UNIT', col_src='UNIT'),
        RemapCB(lut=LAB_LUT, col_remap='LAB', col_src='LAB'),
        AddDetectionLimitCB(),
        SanitizeLonLatCB(),
        EncodeTimeCB(),
        AddSampleIDCB(col_provider="SMP_ID_PROVIDER"),
        FormatStationCB()
        ]
tfm = Transformer(dfs, cbs=get_cbs())
tfm()
out = tfm.dfs['SEAWATER']
print(f"Final shape: {out.shape}")
print("Columns:", out.columns.tolist())
print(out[['SMP_ID', 'SMP_ID_PROVIDER', 'NUCLIDE', 'UNIT', 'LAB']].head(4).to_string())
Final shape: (1733, 18)
Columns: ['Cruise', 'STATION', 'LAT', 'LON', 'Niskin', 'Pressure_dbar', 'SAL', 'TEMP', 'SMP_ID_PROVIDER', 'TIME', 'SMP_DEPTH', 'LAB', 'NUCLIDE', 'UNIT', 'UNC', 'VALUE', 'DL', 'SMP_ID']
   SMP_ID SMP_ID_PROVIDER  NUCLIDE  UNIT  LAB
0       1               1       28     9  345
1       2               1       28    12  345
2       3               1      108     9  504
3       4               1      108    12  504
print("Final data summary (uppercase columns only):")
upper_cols = [c for c in out.columns if c.isupper()]
print(out[upper_cols].describe().to_string())
Final data summary (uppercase columns only):
               LAT          LON          SAL         TEMP          TIME    SMP_DEPTH          LAB      NUCLIDE         UNIT           UNC         VALUE      DL       SMP_ID
count  1733.000000  1733.000000  1723.000000  1728.000000  1.733000e+03  1733.000000  1733.000000  1733.000000  1733.000000  1.733000e+03  1.733000e+03  1733.0  1733.000000
mean     78.846159    -3.794303    33.565289     0.622876  1.648324e+09   165.295788   387.663012    47.452972    10.807271  2.694743e+13  8.153272e+14     1.0   867.000000
std       0.139003     5.595021     1.598968     2.359036  5.075129e+07   277.559678    70.470930    31.956175     1.468615  9.420889e+13  2.462455e+15     0.0   500.418325
min      78.673500   -16.999833    28.504999    -1.815000  1.598486e+09     2.000000   345.000000    28.000000     9.000000  3.700000e+05  4.548323e+06     1.0     1.000000
25%      78.833000    -7.999333    32.363700    -1.419200  1.598918e+09    25.300000   345.000000    28.000000     9.000000  3.906112e+07  1.808362e+09     1.0   434.000000
50%      78.833333    -4.005833    34.243200    -0.106200  1.628208e+09    99.100000   345.000000    28.000000    12.000000  7.906752e+07  2.989910e+09     1.0   867.000000
75%      78.834167    -1.000000    34.902600     2.294700  1.663373e+09   197.800000   504.000000    64.000000    12.000000  1.896498e+08  4.289536e+09     1.0  1300.000000
max      80.408500     8.005167    35.105500     9.699700  1.755043e+09  2658.000000   504.000000   108.000000    12.000000  6.830406e+14  9.107208e+15     1.0  1733.000000

NetCDF encoder

The encoder wraps the full pipeline and writes the standardised data to a NetCDF4 file. Global attributes are assembled via GlobAttrsFeeder with BboxCB, DepthRangeCB, TimeRangeCB, plus keywords and processing logs.

The resulting file contains spatial, depth, and time coverage derived from the transformed seawater data, together with FS keywords and the recorded processing steps.


source

get_attrs

def get_attrs(
    tfm
):

Retrieve global attributes for Fram Strait

Exported source
FS_KEYWORDS = [
    "Fram Strait","Greenland Sea","I-129","U-236","U-238","radionuclides","seawater","Arctic Ocean",
]

def get_attrs(tfm):
    "Retrieve global attributes for Fram Strait"
    return GlobAttrsFeeder(tfm.dfs, cbs=[
        BboxCB(),
        DepthRangeCB(),
        TimeRangeCB(),
        KeyValuePairCB("keywords", ", ".join(FS_KEYWORDS)),
        KeyValuePairCB("publisher_postprocess_logs", ", ".join(tfm.logs)),
    ])()

source

encode

def encode(
    dest:NoneType=None, # Output NetCDF file path
    src:NoneType=None, # Unused; Fram Strait fetches its data from RECORDS
    **kwargs
):

Fram Strait 2020-2025 I-129, U-236, U-238 seawater radionuclide data

Exported source
def encode(
        dest=None,   # Output NetCDF file path
        src=None,    # Unused; Fram Strait fetches its data from RECORDS
        **kwargs     # Additional arguments
        ):
    "Fram Strait 2020-2025 I-129, U-236, U-238 seawater radionuclide data"
    dfs = load_data()
    tfm = Transformer(dfs, cbs=get_cbs())
    tfm()
    encoder = NetCDFEncoder(tfm.dfs, dest_fname=dest,
                            global_attrs=get_attrs(tfm))
    encoder.encode()
# Encode to NetCDF
encode(dest="../../_data/output/fram_strait_2020_2025.nc")
print("Fram Strait NetCDF written.")
Fram Strait NetCDF written.
to_csv("../../_data/output/fram_strait_2020_2025.nc")
[Path('../../_data/output/fram_strait_2020_2025_SEAWATER.csv')]
df = pd.read_csv("../../_data/output/fram_strait_2020_2025_SEAWATER.csv")
print(f"Shape: {df.shape}")
print(f"Columns: {df.columns.tolist()}")
print(f"\nNuclide IDs: {df.nuclide_id.unique()}")
print(f"Unit IDs: {df.unit_id.unique()}")
print(f"Sample type IDs: {df.samptype_id.unique()}")
print(f"\nStation range: {df.station.min()}–{df.station.max()}")
print(f"Date range: {df.begperiod.min()} to {df.begperiod.max()}")
print(f"Sample depth range: {df.sampdepth.min()} to {df.sampdepth.max()}m")
Shape: (1733, 15)
Columns: ['detection', 'lab_id', 'latitude', 'longitude', 'nuclide_id', 'salinity', 'sampdepth', 'samplabcode', 'station', 'temperatur', 'begperiod', 'uncertaint', 'unit_id', 'activity', 'samptype_id']

Nuclide IDs: [ 28 108  64]
Unit IDs: [ 9 12]
Sample type IDs: [1]

Station range: 1–415
Date range: 2020-08-27 to 2025-08-13
Sample depth range: 2.0 to 2658.0m
print(df.head())
  detection  lab_id  latitude  longitude  nuclide_id  salinity  sampdepth  \
0         =     345  78.83317  -0.003667          28   34.9311      395.8   
1         =     345  78.83317  -0.003667          28   34.9311      395.8   
2         =     504  78.83317  -0.003667         108   34.9311      395.8   
3         =     504  78.83317  -0.003667         108   34.9311      395.8   
4         =     504  78.83317  -0.003667          64   34.9311      395.8   

   samplabcode  station  temperatur   begperiod    uncertaint  unit_id  \
0            1       47      1.7576  2020-08-27  1.341982e+08        9   
1            1       47      1.7576  2020-08-27  1.375192e+08       12   
2            1       47      1.7576  2020-08-27  1.087568e+06        9   
3            1       47      1.7576  2020-08-27  1.120000e+06       12   
4            1       47      1.7576  2020-08-27  3.794670e+14        9   

       activity  samptype_id  
0  1.702534e+09            1  
1  1.744666e+09            1  
2  1.317705e+07            1  
3  1.357000e+07            1  
4  8.474763e+15            1