Exported source
src_dir = 'https://raw.githubusercontent.com/franckalbinet/maris-crawlers/refs/heads/main/data/processed/OSPAR'
zotero_key ='LQRA4MMK' # OSPAR MORS zotero key
status = 'Under review'This handler ingests OSPAR (Convention for the Protection of the Marine Environment of the North-East Atlantic) radioactive substances monitoring data for seawater and biota, as submitted by the contracting parties to ODIMS. It transforms the data into the MARIS NetCDF format through a pipeline that standardises nomenclatures, parses time and coordinates, and converts values, units, uncertainties and detection-limit flags to MARIS conventions.
For detailed guidance on the reconciliation workflow used throughout this handler, see the writing-a-handler and reconcile-nomenclature how-to guides.
For the MARIS data model and field conventions, see the reference guide and field definitions.
src_dir: path or URL to the maris-crawlers folder containing the OSPAR data in CSV format.
Zotero key: used to retrieve attributes related to the dataset from Zotero. The MARIS datasets include a library available on Zotero.
OSPAR data is provided as a zipped Microsoft Access database. We automatically fetch and convert this dataset with database tables exported as .csv files using a Github action here: maris-crawlers.
The dataset is then accessible in an amenable format for the marisco data pipeline, as one file per sample type: Seawater data.csv and Biota data.csv.
Load OSPAR data; returns dict of DataFrames keyed by sample type
dfs is a dictionary of dataframes created from the OSPAR dataset located at the path src_dir, with one entry per sample type. Column names are lowercased on load.
BIOTA: 15951 rows, 27 cols
['id', 'contracting party', 'rsc sub-division', 'station id', 'sample id', 'latd', 'latm', 'lats', 'latdir', 'longd', 'longm', 'longs', 'longdir', 'sample type', 'biological group', 'species', 'body part', 'sampling date', 'nuclide', 'value type', 'activity or mda', 'uncertainty', 'unit', 'data provider', 'measurement comment', 'sample comment', 'reference comment']
SEAWATER: 19193 rows, 25 cols
['id', 'contracting party', 'rsc sub-division', 'station id', 'sample id', 'latd', 'latm', 'lats', 'latdir', 'longd', 'longm', 'longs', 'longdir', 'sample type', 'sampling depth', 'sampling date', 'nuclide', 'value type', 'activity or mda', 'uncertainty', 'unit', 'data provider', 'measurement comment', 'sample comment', 'reference comment']
Some OSPAR seawater records have no sampling date and no measurement value. MARIS cannot use a record without a date or a value. The handler drops these records.
10 SEAWATER records have no sampling date and no activity or mda value. Eight of them are Irish records (IDs 120363 to 120370) with no nuclide, value type, unit or sample ID either. Only their station is filled in. The other two are Swedish tritium (3H) records (IDs 97948 and 97952) with no date, value or value type. These records should be completed or removed at source.
id contracting party station id nuclide sampling date activity or mda unit
14776 97948 Sweden SW7 3H NaN NaN Bq/l
14780 97952 Sweden Ringhals (R35) 3H NaN NaN Bq/l
16161 120369 Ireland Salthill NaN NaN NaN NaN
16162 120370 Ireland Woodstown NaN NaN NaN NaN
16586 120363 Ireland N1 NaN NaN NaN NaN
19188 120364 Ireland N2 NaN NaN NaN NaN
19189 120365 Ireland N3 NaN NaN NaN NaN
19190 120366 Ireland N8 NaN NaN NaN NaN
19191 120367 Ireland N9 NaN NaN NaN NaN
19192 120368 Ireland N10 NaN NaN NaN NaN
RemoveAllNAValuesCB drops the rows where every column listed in nan_cols_to_check is empty. In the current data, every record without a sampling date also has no value, and every record without a value also has no sampling date.
Nuclide names are written in several formats. Caesium-137 appears as 137Cs, Cs-137 and CS-137. Plutonium-239+240 appears as 239, 240 Pu and 239,240Pu. 599 rows have trailing spaces, for example 137Cs and 99Tc. A standardised nuclide pick-list at the point of entry would prevent these issues.
599 rows with trailing spaces. Distinct raw names:
['137Cs', '137Cs ', '210Pb', '210Po', '210Po ', '226Ra', '228Ra', '238Pu', '239, 240 Pu', '239,240Pu', '241Am', '3H', '99Tc', '99Tc ', '99Tc ', 'Cs-137', 'CS-137']
LowerStripNameCB lowercases and strips them into a standardised NUCLIDE column.
tfm = Transformer(dfs, cbs=[
RemoveAllNAValuesCB(nan_cols_to_check),
LowerStripNameCB(col_src='nuclide', col_dst='NUCLIDE')
])
dfs_out = tfm()
for df in dfs_out.values():
test_eq(df['NUCLIDE'], df['NUCLIDE'].str.lower().str.strip())
print(f"All nuclide names normalised across {len(dfs_out)} sample groups.")All nuclide names normalised across 2 sample groups.
OSPAR writes most nuclide names with the mass number first, as in 137cs and 241am. MARIS writes the element first, as in cs137 and am241. Fuzzy matching cannot recover from this reordering. We reconcile the names with the semi-automated workflow used across marisco handlers: try an automatic mapping, fix what it got wrong, and check the result.
Try an automatic mapping
Derive unique provider values and fuzzy-match against MARIS reference.
provider_lut: ['value']
maris_ref: ['nuclide_id', 'nc_name']
Inspect the borderline matches
Review non-exact matches to identify cases the fuzzy matcher could not resolve.
value nuclide_id nc_name score
6 239, 240 pu 69 pu240 8
7 239,240pu 69 pu240 6
0 137cs 1 h3 4
1 210pb 3 c14 4
2 210po 3 c14 4
3 226ra 9 co60 4
4 228ra 11 sr89 4
8 241am 3 c14 4
5 238pu 64 u238 3
10 99tc 137 tu 3
9 3h 1 h3 2
11 cs-137 33 cs137 1
Only cs-137 matches the right nuclide, with a score of 1 for the hyphen. It needs no override. Every other name matches a wrong nuclide: 137cs matches h3, and 239,240pu matches pu240. OSPAR reports plutonium-239 and plutonium-240 as one combined value, which maps to the MARIS total pu239_240_tot.
Fix what it got wrong
Apply expert overrides for cases the fuzzy match could not resolve correctly.
The dictionary below records our expert decisions for every case the fuzzy matcher got wrong. Each entry maps a provider nuclide value to its correct MARIS nc_name. The fix_lut function applies these overrides and resets the score to 0.
fixed = fix_lut(merged, fixes_nuclide_names, maris_ref,
left_on='value', right_on='nc_name', id_col='nuclide_id')
# Verify: no unresolved matches remain apart from cs-137, which is correct
unresolved = fixed[(fixed['score'] > 0) & (fixed['value'] != 'cs-137')]
test_eq(len(unresolved), 0)
print(fixed.sort_values('value').to_string(index=False)) value nuclide_id nc_name score
137cs 33 cs137 0
210pb 41 pb210 0
210po 47 po210 0
226ra 53 ra226 0
228ra 54 ra228 0
238pu 67 pu238 0
239, 240 pu 77 pu239_240_tot 0
239,240pu 77 pu239_240_tot 0
241am 72 am241 0
3h 1 h3 0
99tc 15 tc99 0
cs-137 33 cs137 1
Assemble the final mapping
The steps above (unique values, fuzzy match, expert overrides, verification) told us what the correct MARIS translations are. The make_lut function packages the expert fixes and the MARIS reference table into a single function. The Transformer calls it later, when it processes the data through the pipeline.
The nuclide_lut lookup table is passed to the generic RemapCB callback, which looks up the MARIS nuclide reference table when the Transformer runs. It translates NUCLIDE from the provider string (after lowercasing and stripping) into the MARIS integer nuclide_id, across all sample-type groups.
Let’s verify the full pipeline works:
tfm = Transformer(dfs, cbs=[
RemoveAllNAValuesCB(nan_cols_to_check),
LowerStripNameCB(col_src='nuclide', col_dst='NUCLIDE'),
RemapCB(lut=nuclide_lut, col_remap='NUCLIDE', col_src='NUCLIDE')
])
dfs_out = tfm()
for grp, df in dfs_out.items():
test_eq(df['NUCLIDE'].dtype, 'int64')
test_eq((df['NUCLIDE'] == 0).sum(), 0)
print(f"NUCLIDE is a MARIS integer ID in every row of all {len(dfs_out)} groups.")NUCLIDE is a MARIS integer ID in every row of all 2 groups.
Rows per MARIS nuclide, to check that every provider name reached the expected nuclide:
BIOTA {'cs137': 8822, 'tc99': 2592, 'pu239_240_tot': 2028, 'ra226': 983, 'ra228': 729, 'po210': 345, 'pb210': 331, 'h3': 101, 'pu238': 10, 'am241': 10}
SEAWATER {'cs137': 7572, 'h3': 6109, 'pu239_240_tot': 1876, 'ra226': 1420, 'tc99': 1398, 'ra228': 748, 'po210': 57, 'pb210': 3}
OSPAR provides the sampling date in a sampling date column, in the format MM/DD/YY HH:MM:SS. Most records have a time of 00:00:00, meaning that no time of day was recorded. 2,890 records have an actual sampling time, mostly from the Netherlands, Ireland and Belgium. ParseTimeCB keeps the time of day.
Years have two digits. pandas reads 69 to 99 as 1969 to 1999, and 00 to 68 as 2000 to 2068. The OSPAR data covers 1995 to 2022, which falls inside that window.
Sampling dates use two-digit years (MM/DD/YY). A two-digit year is ambiguous once a dataset spans more than a century of possible dates. ISO 8601 dates (YYYY-MM-DD) would remove the ambiguity.
Most records give 00:00:00 as the time of day. This value cannot be told apart from a sample taken at midnight. Leaving the time empty when it was not recorded would make the distinction clear.
['11/17/21 00:00:00', '10/10/14 00:00:00', '10/15/20 00:00:00']
Time of day other than 00:00:00: 2890 rows
ParseTimeCB parses sampling date into a TIME column. A date it cannot parse becomes NaT.
Parse OSPAR sampling date (MM/DD/YY HH:MM:SS) into TIME
Verify ParseTimeCB on mock data. The third row has no date and becomes NaT:
dfs_mock = {
'SEAWATER': pd.DataFrame({'sampling date': ['06/13/95 00:00:00', '12/31/22 00:00:00', None]}),
'BIOTA': pd.DataFrame({'sampling date': ['01/03/05 00:00:00']}),
}
tfm = Transformer(dfs_mock, cbs=[ParseTimeCB()])
tfm()
test_eq(tfm.dfs['SEAWATER']['TIME'].iloc[:2].to_list(), [pd.Timestamp('1995-06-13'), pd.Timestamp('2022-12-31')])
test_eq(tfm.dfs['SEAWATER']['TIME'].isna().to_list(), [False, False, True])
test_eq(tfm.dfs['BIOTA']['TIME'].to_list(), [pd.Timestamp('2005-01-03')])Applying ParseTimeCB to the real data, after dropping the records with no date:
BIOTA: 1995-01-01 to 2022-12-31
SEAWATER: 1995-01-03 to 2022-12-25
NetCDF stores time as seconds since 1970-01-01, as defined in the template’s CDL. EncodeTimeCB converts the parsed TIME column to this integer format. It drops any row without a TIME. No such row remains after RemoveAllNAValuesCB.
Applying ParseTimeCB and EncodeTimeCB together:
tfm = Transformer(dfs, cbs=[RemoveAllNAValuesCB(nan_cols_to_check), ParseTimeCB(), EncodeTimeCB()])
dfs_out = tfm()
for grp, df in dfs_out.items():
test_eq(df['TIME'].dtype, 'int64')
test_eq(len(df), len(dfs[grp].dropna(subset=nan_cols_to_check, how='all')))
print(f"TIME encoded as int64 in all {len(dfs_out)} groups, with no row dropped.")TIME encoded as int64 in all 2 groups, with no row dropped.
OSPAR reports each measurement in an activity or mda column, in both sample types. The column holds a measured activity when value type is =, and a minimum detectable activity (MDA) when value type is <. SanitizeValueCB copies the column into the MARIS VALUE column and drops any row without a value. No such row remains after RemoveAllNAValuesCB.
Three Norwegian 99Tc BIOTA records (IDs 89894 to 89896) report a measured activity (value type =) of exactly 0, with an uncertainty of 0. A measured activity of 0 with no uncertainty has no physical meaning. If the activity was below detection, the record should give value type < and the MDA.
id contracting party nuclide species value type activity or mda uncertainty
13860 89894 Norway 99Tc Fucus vesiculosus = 0.0 0.0
13861 89895 Norway 99Tc Fucus vesiculosus = 0.0 0.0
13862 89896 Norway 99Tc Ascophyllum nodosum = 0.0 0.0
The three Norwegian 99Tc BIOTA records with a measured activity of 0 and an uncertainty of 0 (IDs 89894 to 89896) are kept as reported. Please decide whether to keep them, drop them, or record them as below the MDA (DL 2).
Sanitize measurement values by removing blanks and standardizing to use the VALUE column
Verify SanitizeValueCB on mock data. The SEAWATER row without a value is dropped:
dfs_mock = {
'SEAWATER': pd.DataFrame({'activity or mda': [1.5, None, 0.02]}),
'BIOTA': pd.DataFrame({'activity or mda': [0.2]}),
}
tfm = Transformer(dfs_mock, cbs=[SanitizeValueCB(coi_val)])
tfm()
test_eq(tfm.dfs['SEAWATER']['VALUE'].to_list(), [1.5, 0.02])
test_eq(tfm.dfs['BIOTA']['VALUE'].to_list(), [0.2])Applying SanitizeValueCB to the real data:
tfm = Transformer(dfs, cbs=[RemoveAllNAValuesCB(nan_cols_to_check), SanitizeValueCB(coi_val)])
dfs_out = tfm()
for grp, df in dfs_out.items():
test_eq(df['VALUE'].isna().sum(), 0)
test_eq(len(df), len(dfs[grp].dropna(subset=nan_cols_to_check, how='all')))
print(f"VALUE column created across all {len(dfs_out)} groups, with no row dropped.")VALUE column created across all 2 groups, with no row dropped.
OSPAR reports the uncertainty of each measurement as an expanded uncertainty with a coverage factor of 2 (k=2), in the uncertainty column. See the OSPAR reporting guidelines. MARIS stores the standard uncertainty (k=1). NormalizeUncCB divides the reported uncertainty by the coverage factor and writes the result to the UNC column.
Records below the MDA (value type <) carry no uncertainty. Their UNC stays empty.
Convert expanded uncertainty (coverage factor k) to standard uncertainty UNC
Verify NormalizeUncCB on mock data. A missing uncertainty stays missing:
dfs_mock = {
'SEAWATER': pd.DataFrame({'VALUE': [5.3, 0.02], 'uncertainty': [0.6, None]}),
'BIOTA': pd.DataFrame({'VALUE': [135.3], 'uncertainty': [9.0]}),
}
tfm = Transformer(dfs_mock, cbs=[NormalizeUncCB()])
tfm()
test_eq(tfm.dfs['SEAWATER']['UNC'].iloc[0], 0.3)
test_eq(tfm.dfs['SEAWATER']['UNC'].isna().to_list(), [False, True])
test_eq(tfm.dfs['BIOTA']['UNC'].to_list(), [4.5])Applying NormalizeUncCB to the real data:
tfm = Transformer(dfs, cbs=[
RemoveAllNAValuesCB(nan_cols_to_check),
SanitizeValueCB(coi_val),
NormalizeUncCB()
])
dfs_out = tfm()
for grp, df in dfs_out.items():
pd.testing.assert_series_equal(df['UNC'], df['uncertainty'] / 2, check_names=False)
no_unc = df['UNC'].isna()
print(f"{grp}: {no_unc.sum()} rows without UNC, of which {(df.loc[no_unc, 'value type'] == '<').sum()} are below the MDA")BIOTA: 4881 rows without UNC, of which 4873 are below the MDA
SEAWATER: 4820 rows without UNC, of which 4818 are below the MDA
99 measured BIOTA records and 95 measured SEAWATER records report an uncertainty larger than the value itself. In 67 of them the uncertainty is more than 10 times the value. For example, a Norwegian plutonium-239+240 seawater record (ID 15018) reports an activity of 2.4e-12 Bq/L with an uncertainty of 4e-7 Bq/L. Ratios this large suggest that the value or the uncertainty was reported in a different unit, or that the uncertainty was reported as a percentage. These records should be checked at source.
Also, 10 measured records have no uncertainty, and 13 records below the MDA report one.
The handler keeps these values as reported. Records with an uncertainty more than 10 times the value, largest ratio first:
cols = ['id', 'contracting party', 'nuclide', 'activity or mda', 'uncertainty', 'unit']
for grp, df in dfs_out.items():
ratio = (df['uncertainty'] / df['activity or mda']).round(0)
big = df[(df['value type'] == '=') & (ratio > 10)].assign(ratio=ratio)
print(f"{grp}: {len(big)} records")
print(big.sort_values('ratio', ascending=False)[cols + ['ratio']].head(10).to_string(), '\n')BIOTA: 3 records
id contracting party nuclide activity or mda uncertainty unit ratio
3027 35011 Belgium 137Cs 0.1619 66.0 Bq/kg f.w. 408.0
2693 29984 Belgium 137Cs 0.1690 27.0 Bq/kg f.w. 160.0
2338 23895 Belgium 226Ra 1.4000 118.0 Bq/kg f.w. 84.0
SEAWATER: 64 records
id contracting party nuclide activity or mda uncertainty unit ratio
1379 15018 Norway 239,240Pu 2.400000e-12 4.000000e-07 Bq/l 166667.0
6462 55488 United Kingdom 3H 1.110910e+01 9.716400e+04 Bq/l 8746.0
14237 94695 United Kingdom 3H 1.554393e-01 5.528771e+02 Bq/l 3557.0
10230 75829 Norway 99Tc 1.000000e-04 6.000000e-02 Bq/l 600.0
10231 75830 Norway 99Tc 8.000000e-05 4.000000e-02 Bq/l 500.0
7739 61137 Norway 99Tc 5.121716e-05 2.262377e-02 Bq/l 442.0
7740 61138 Norway 99Tc 5.266393e-05 2.183357e-02 Bq/l 415.0
16555 121584 United Kingdom 137Cs 1.690000e-03 5.120700e-01 Bq/l 303.0
10229 75828 Norway 99Tc 1.400000e-04 4.000000e-02 Bq/l 286.0
7738 61136 Norway 99Tc 6.402726e-05 1.747559e-02 Bq/l 273.0
Two decisions need review:
OSPAR reports seawater activities in Bq/L and biota activities in Bq/kg fresh weight (Bq/kg f.w.). MARIS stores seawater in Bq/m³. RemapUnitCB sets the MARIS UNIT column and multiplies seawater VALUE and UNC by 1000. For records below the MDA, VALUE holds the MDA, which is converted the same way. Biota values need no conversion. MARIS unit 5 (Bq per kgw, wet weight) covers fresh weight.
The seawater unit is spelled three ways: Bq/l (18,761 records), Bq/L (376) and BQ/L (48). A single spelling would simplify processing. The 8 records with no unit are among the empty records removed in “Remove missing values”.
BIOTA {'Bq/kg f.w.': 15951}
SEAWATER {'Bq/l': 18761, 'Bq/L': 376, 'BQ/L': 48, nan: 8}
The MARIS units:
unit_id unit_sanitized
0 -1 Not applicable
1 0 NOT AVAILABLE
2 1 Bq per m3
3 2 Bq per m2
4 3 Bq per kg
5 4 Bq per kgd
6 5 Bq per kgw
lut_units maps each OSPAR unit, lowercased, to its MARIS unit ID and the factor that converts the value to that unit. A unit missing from lut_units gets UNIT 0 (not available), and its values stay as reported.
Set UNIT from the reported unit and convert VALUE and UNC to it (seawater Bq/L to Bq/m³)
Verify RemapUnitCB on mock data. The three seawater spellings convert to Bq/m³, and an unknown unit gets UNIT 0 with its value unchanged:
dfs_mock = {
'SEAWATER': pd.DataFrame({'unit': ['Bq/l', 'Bq/L', 'BQ/L', 'pCi/l'],
'VALUE': [0.005, 2.8, 0.01, 7.0], 'UNC': [0.0005, None, 0.001, 1.0]}),
'BIOTA': pd.DataFrame({'unit': ['Bq/kg f.w.'], 'VALUE': [0.2], 'UNC': [0.02]}),
}
tfm = Transformer(dfs_mock, cbs=[RemapUnitCB()])
tfm()
sw = tfm.dfs['SEAWATER']
test_eq(sw['UNIT'].to_list(), [1, 1, 1, 0])
test_close(sw['VALUE'].to_list(), [5.0, 2800.0, 10.0, 7.0])
test_close(sw['UNC'].iloc[[0, 2, 3]].to_list(), [0.5, 1.0, 1.0])
test_eq(sw['UNC'].isna().to_list(), [False, True, False, False])
test_eq(tfm.dfs['BIOTA'][['UNIT', 'VALUE', 'UNC']].values.tolist(), [[5, 0.2, 0.02]])Applying RemapUnitCB to the real data:
tfm = Transformer(dfs, cbs=[
RemoveAllNAValuesCB(nan_cols_to_check),
SanitizeValueCB(coi_val),
NormalizeUncCB(),
RemapUnitCB()
])
dfs_out = tfm()
for grp, df in dfs_out.items(): print(f"{grp}: UNIT values = {df['UNIT'].unique()}")
test_eq(set(dfs_out['SEAWATER']['UNIT']), {1})
test_eq(set(dfs_out['BIOTA']['UNIT']), {5})
sw = dfs_out['SEAWATER']
test_close(sw['VALUE'], sw['activity or mda'] * 1000)
print(sw[['nuclide', 'activity or mda', 'unit', 'VALUE', 'UNC', 'UNIT']].head(3).to_string())BIOTA: UNIT values = [5]
SEAWATER: UNIT values = [1]
nuclide activity or mda unit VALUE UNC UNIT
0 137Cs 0.20 Bq/l 200.0 NaN 1
1 137Cs 0.27 Bq/l 270.0 NaN 1
2 137Cs 0.26 Bq/l 260.0 NaN 1
Biota activities reported per kg fresh weight (Bq/kg f.w.) map to MARIS unit 5, Bq per kgw (wet weight), as in the previous version of this handler. Please confirm that fresh weight and wet weight are equivalent for MARIS.
OSPAR records whether a value is a measured activity or a minimum detectable activity (MDA) in the value type column: = for a measured activity, < for an MDA. MARIS uses the following integer codes for this distinction:
id name_sanitized
0 -1 Not applicable
1 0 Not available
2 1 Detected value
3 2 Detection limit
4 3 Not detected
5 4 Derived
77 records have no value type: 23 Belgian BIOTA records, and 52 UK and 2 Belgian SEAWATER records, all sampled in 2020 and 2021. Every one of them reports an uncertainty, which suggests a measured activity. The value type should be filled in at source.
cols = ['id', 'contracting party', 'nuclide', 'sampling date', 'activity or mda', 'uncertainty']
for grp, df in dfs.items():
na = df[df['value type'].isna() & df['activity or mda'].notna()]
print(f"{grp}: {len(na)} records without value type, {na['uncertainty'].notna().sum()} of them with an uncertainty")
print(na[cols].head(3).to_string(), '\n')BIOTA: 23 records without value type, 23 of them with an uncertainty
id contracting party nuclide sampling date activity or mda uncertainty
15508 95221 Belgium 226Ra 11/22/21 00:00:00 0.509549 0.162129
15583 95278 Belgium 99Tc 11/26/21 00:00:00 2.527464 0.229769
15587 95282 Belgium 226Ra 03/03/21 00:00:00 0.477137 0.458052
SEAWATER: 54 records without value type, 54 of them with an uncertainty
id contracting party nuclide sampling date activity or mda uncertainty
2880 119105 Belgium 137Cs 01/27/21 00:00:00 0.00130 0.000300
3245 119147 Belgium 228Ra 03/24/21 00:00:00 0.29000 0.190000
16005 118947 United Kingdom 3H 09/23/20 00:00:00 2.37474 1.099254
The 77 records without a value type are treated as measured activities (DL 1), because each reports an uncertainty. This is the rule of the previous version of this handler. Please confirm it, or ask the provider for the missing values first.
lut_dl maps each value type to its MARIS code. RemapDetectionLimitCB writes the code to the DL column. A record with no value type but with an uncertainty is treated as a measured activity (code 1). Any other record with no value type gets code 0 (not available).
Map OSPAR value type (= / <) to MARIS detection-limit codes, treating a missing type with an uncertainty as detected
Verify RemapDetectionLimitCB on mock data. The third row has no value type but an uncertainty, and the fourth has neither:
dfs_mock = {
'SEAWATER': pd.DataFrame({'value type': ['=', '<', None, None], 'UNC': [0.1, None, 0.2, None]}),
'BIOTA': pd.DataFrame({'value type': ['<', '='], 'UNC': [None, 0.5]}),
}
tfm = Transformer(dfs_mock, cbs=[RemapDetectionLimitCB()])
tfm()
test_eq(tfm.dfs['SEAWATER']['DL'].to_list(), [1, 2, 1, 0])
test_eq(tfm.dfs['BIOTA']['DL'].to_list(), [2, 1])Applying RemapDetectionLimitCB to the real data:
BIOTA: DL counts = {1: 11073, 2: 4878}
SEAWATER: DL counts = {1: 14357, 2: 4826}
OSPAR records each biota sample’s species by scientific name, in the species column. OSPAR supplies no species lookup table. We derive the unique names from the data and reconcile them with the MARIS species reference, following the “no provider lookup table” route of the reconcile-nomenclature how-to.
Species names are not standardised:
Fish biological group, have no species.Gadus morhua and MERLUCCIUS MERLUCCIUS.Solea solea (S.vulgaris) and SOLEA SOLEA (S.VULGARIS). After lowercasing and trimming, 166 distinct names reduce to 112.Cerastoderma (Cardium) edule and Dicentrarchus (Morone) labrax.PLUERONECTES PLATESSA, MERLANGUIS MERLANGUIS, ASCOPHYLLUN NODOSUM, Sebastes vivipares, Trisopterus esmarki and Gaidropsarus argenteus.Monodonta lineata is an outdated name. The accepted name is Phorcus lineatus.species column also holds placeholders (Unknown), common names (Flatfish) and mixtures (Mixture of green, red and brown algae).Reporting species with their WoRMS accepted name, or their AphiaID, would remove these issues.
species = dfs['BIOTA']['species']
print(f"Records without species: {species.isna().sum()}")
print(f"Records with leading or trailing spaces: {(species.dropna() != species.dropna().str.strip()).sum()}")
print(f"Distinct names: {species.nunique()} raw, {species.str.strip().str.lower().nunique()} after lowercasing and trimming")Records without species: 2198
Records with leading or trailing spaces: 193
Distinct names: 166 raw, 112 after lowercasing and trimming
LowerStripNameCB lowercases and trims the BIOTA names into a SPECIES column. The fuzzy matching ignores case, and lowercasing merges the case variants of the same name into one entry. grps=['BIOTA'] restricts it to BIOTA, the only group with a species column.
Try an automatic mapping
Derive unique provider values and fuzzy-match against MARIS reference.
tfm = Transformer(dfs, cbs=[
RemoveAllNAValuesCB(nan_cols_to_check),
LowerStripNameCB(col_src='species', col_dst='SPECIES', grps=['BIOTA'])
])
dfs_out = tfm()
# lut_from skips records without species. The next section fills them in.
provider_lut = lut_from(dfs_out, 'SPECIES')
maris_ref = get_lut('SPECIES', as_df=True)
merged = fuzzy_merge(provider_lut, maris_ref, left_on='value', right_on='species')
print(f"{len(merged)} distinct names: {(merged.score == 0).sum()} exact matches, {(merged.score > 0).sum()} to review")112 distinct names: 83 exact matches, 29 to review
Inspect the borderline matches
Review non-exact matches to identify cases the fuzzy matcher could not resolve.
value species species_id score
rhodymenia pseudopalamata & palmaria palmata Hoplobrotula armata 137 31
mixture of green, red and brown algae Strongylura anastomella 541 26
solea solea (s.vulgaris) Loligo vulgaris 161 12
cerastoderma (cardium) edule Cerastoderma edule 274 10
dicentrarchus (morone) labrax Dicentrarchus labrax 424 9
rajidae/batoidea Batoidea 992 8
pleuronectiformes [order] Pleuronectiformes 411 8
palmaria palmata Alaria marginata 1104 7
gadiculus argenteus Pampus argenteus 223 6
monodonta lineata Monodonta labio 1684 6
raja dipturus batis Dipturus batis 436 5
unknown Plankton 280 5
rhodymenia spp. Rhodymenia 396 5
fucus spp. Fucus 395 5
flatfish Lambia 148 5
sepia spp. Sepia 408 5
tapes sp. Tapes 431 4
thunnus sp. Thunnus 442 4
rhodymenia spp Rhodymenia 396 4
patella sp. Patella 413 4
gadus sp. Gadus 409 4
fucus spp Fucus 395 4
fucus sp. Fucus 395 4
merlanguis merlanguis Merlangius merlangus 139 3
plueronectes platessa Pleuronectes platessa 192 2
gaidropsarus argenteus Gaidropsarus argentatus 444 2
sebastes vivipares Sebastes viviparus 390 1
trisopterus esmarki Trisopterus esmarkii 385 1
ascophyllun nodosum Ascophyllum nodosum 401 1
Most of the non-exact matches are right. The fuzzy matcher recovers misspellings (plueronectes platessa), names with a bracketed synonym (cerastoderma (cardium) edule), and genus-level names (fucus spp. to Fucus, thunnus sp. to Thunnus). The matches it gets wrong are overridden below.
Fix what it got wrong
Apply expert overrides for cases the fuzzy match could not resolve correctly.
The overrides below include judgement calls. Please review them.
solea solea (s.vulgaris): the fuzzy matcher picked the squid Loligo vulgaris from the bracketed synonym. The fish is Solea solea.monodonta lineata: the fuzzy matcher picked Monodonta labio, a different species. Monodonta lineata is a synonym of Phorcus lineatus, which is in the MARIS reference.flatfish: the common name for the order Pleuronectiformes.unknown: no species information. It maps to NOT AVAILABLE, and the next section fills it in from the biological group.palmaria palmata (dulse): a red alga with no entry in the MARIS reference. It maps to the phylum Rhodophyta, the most specific MARIS entry that contains it. The fuzzy matcher picked the brown alga Alaria marginata. Please consider adding Palmaria palmata to the MARIS species reference.rhodymenia pseudopalamata & palmaria palmata: a mixture of two red algae. It maps to Rhodophyta, which contains both.mixture of green, red and brown algae: a mixture spanning three algal groups. It maps to Marine algae, the MARIS entry that covers all three.gadiculus argenteus: the fuzzy matcher picked the pomfret Pampus argenteus. The MARIS reference has only Gadiculus argenteus thori, the North-East Atlantic form, which matches the OSPAR area.Two non-exact matches are kept without an override:
rajidae/batoidea matches Batoidea. The record does not say which of the two groups applies, and Batoidea contains Rajidae.gadus sp. matches the genus Gadus. The previous version of this handler mapped it to not available, before the genus was in the MARIS reference.fixes_species = {
'solea solea (s.vulgaris)': 'Solea solea', # Fuzzy match picked Loligo vulgaris from the synonym
'monodonta lineata': 'Phorcus lineatus', # Accepted name of the same species
'flatfish': 'Pleuronectiformes', # Common name of the order
'unknown': 'NOT AVAILABLE', # Filled in later from the biological group
'palmaria palmata': 'Rhodophyta', # Not in the MARIS reference, mapped to its phylum
'rhodymenia pseudopalamata & palmaria palmata': 'Rhodophyta', # Mixture of two red algae
'mixture of green, red and brown algae': 'Marine algae', # Mixture of algal groups
'gadiculus argenteus': 'Gadiculus argenteus thori', # North-East Atlantic form
}fixed = fix_lut(merged, fixes_species, maris_ref,
left_on='value', right_on='species', id_col='species_id')
# The remaining non-exact matches are the ones reviewed above and accepted as correct
remaining = fixed[fixed.score > 0].sort_values('value')
print(remaining[['value', 'species', 'score']].to_string(index=False))
test_eq(len(remaining), 21) value species score
ascophyllun nodosum Ascophyllum nodosum 1
cerastoderma (cardium) edule Cerastoderma edule 10
dicentrarchus (morone) labrax Dicentrarchus labrax 9
fucus sp. Fucus 4
fucus spp Fucus 4
fucus spp. Fucus 5
gadus sp. Gadus 4
gaidropsarus argenteus Gaidropsarus argentatus 2
merlanguis merlanguis Merlangius merlangus 3
patella sp. Patella 4
pleuronectiformes [order] Pleuronectiformes 8
plueronectes platessa Pleuronectes platessa 2
raja dipturus batis Dipturus batis 5
rajidae/batoidea Batoidea 8
rhodymenia spp Rhodymenia 4
rhodymenia spp. Rhodymenia 5
sebastes vivipares Sebastes viviparus 1
sepia spp. Sepia 5
tapes sp. Tapes 4
thunnus sp. Thunnus 4
trisopterus esmarki Trisopterus esmarkii 1
Assemble the final mapping
The steps above (unique values, fuzzy match, expert overrides, verification) told us what the correct MARIS translations are. make_lut packages the expert fixes and the MARIS reference table into a single function. The Transformer calls it later, when it processes the data through the pipeline. Records without species are left out of the matching, and RemapCB gives them SPECIES 0.
Verify the species lookup on mock data. Case, spaces and a bracketed synonym are resolved. A missing species and Unknown both get SPECIES 0:
dfs_mock = {'BIOTA': pd.DataFrame({'species': ['SOLEA SOLEA (S.VULGARIS) ', 'Fucus vesiculosus', None, 'Unknown']})}
tfm = Transformer(dfs_mock, cbs=[
LowerStripNameCB(col_src='species', col_dst='SPECIES', grps=['BIOTA']),
RemapCB(lut=species_lut, col_remap='SPECIES', col_src='SPECIES', grps=['BIOTA'])
])
tfm()
test_eq(tfm.dfs['BIOTA']['SPECIES'].to_list(), [397, 96, 0, 0])Map species on the real OSPAR BIOTA data:
tfm = Transformer(dfs, cbs=[
RemoveAllNAValuesCB(nan_cols_to_check),
LowerStripNameCB(col_src='species', col_dst='SPECIES', grps=['BIOTA']),
RemapCB(lut=species_lut, col_remap='SPECIES', col_src='SPECIES', grps=['BIOTA'])
])
dfs_out = tfm()
biota = dfs_out['BIOTA']
no_species = biota['species'].isna() | (biota['species'].str.strip().str.lower() == 'unknown')
print(f"SPECIES 0 (not available): {(biota['SPECIES'] == 0).sum()} rows, all without species information")
test_eq(biota.loc[biota['SPECIES'] == 0].index.tolist(), biota[no_species].index.tolist())SPECIES 0 (not available): 2234 rows, all without species information
After the species remap, 2,234 BIOTA records have SPECIES 0: the 2,198 records without species and the 36 marked unknown. OSPAR also records a broad biological group for every biota record: fish, molluscs or seaweed. EnhanceSpeciesCB uses it to give these records a broad MARIS species entry instead of 0. Records that already have a species keep it.
The biological group column spells three groups in ten ways: Fish, FISH and fish; Molluscs, MOLLUSCS and molluscs; Seaweed, SEAWEED, seaweed and Seaweeds. A fixed pick-list would prevent this.
biota = dfs['BIOTA']
print(biota['biological group'].value_counts(dropna=False).to_dict())
no_species = biota['species'].isna() | (biota['species'].str.strip().str.lower() == 'unknown')
print(f"Records without species, by biological group: {biota.loc[no_species, 'biological group'].value_counts().to_dict()}"){'Seaweed': 5705, 'Fish': 5000, 'Molluscs': 3791, 'SEAWEED': 831, 'FISH': 459, 'Seaweeds': 48, 'MOLLUSCS': 46, 'fish': 28, 'molluscs': 23, 'seaweed': 20}
Records without species, by biological group: {'Fish': 2208, 'Seaweed': 26}
With three groups to map, a plain dict is enough. lut_biogroup_species maps each lowercased, trimmed group to the broad MARIS species entry that covers it:
{'fish': 'Pisces', 'molluscs': 'Mollusca', 'seaweed': 'Seaweed', 'seaweeds': 'Seaweed'}
Seaweed records without a species map to the MARIS entry Seaweed (1059). Marine algae (734) is an alternative. Please confirm the choice.
Replace SPECIES 0 (not available) with the broad MARIS entry of the record’s biological group
Verify EnhanceSpeciesCB on mock data. Only the records with SPECIES 0 change, and an unknown group leaves SPECIES at 0:
Apply the species remap and EnhanceSpeciesCB to the real OSPAR BIOTA data:
tfm = Transformer(dfs, cbs=[
RemoveAllNAValuesCB(nan_cols_to_check),
LowerStripNameCB(col_src='species', col_dst='SPECIES', grps=['BIOTA']),
RemapCB(lut=species_lut, col_remap='SPECIES', col_src='SPECIES', grps=['BIOTA']),
EnhanceSpeciesCB()
])
biota = tfm()['BIOTA']
test_eq((biota['SPECIES'] == 0).sum(), 0)
print(f"No BIOTA record has SPECIES 0. Records filled from the biological group: "
f"{biota.loc[no_species, 'SPECIES'].map(names).value_counts().to_dict()}")No BIOTA record has SPECIES 0. Records filled from the biological group: {'Pisces': 2208, 'Seaweed': 26}
OSPAR records the analysed tissue of each biota sample in the body part column. OSPAR supplies no body-part lookup table. We derive the unique values from the data and reconcile them with the MARIS body-part reference, following the same workflow as for species.
Body-part values are not standardised:
WHOLE FISH and Whole fish. After lowercasing and trimming, 29 distinct values reduce to 19.Whole fisk is a typo for Whole fish, and FLESH WITHOUT BONE a variant of FLESH WITHOUT BONES.WHOLE and Whole do not say whether the sample is a whole animal or a whole plant. We resolve them from the biological group.FLESH and Flesh (226 Danish and German fish records) do not say whether bones were included.UNKNOWN gives no information.Tail and claws, are in the Fish biological group.Soft parts, a term used for molluscs.FLESH WITHOUT BONES.Records with leading or trailing spaces: 34
Distinct values: 29 trimmed, 19 after lowercasing
NormalizeBodyPartCB lowercases and trims body part into a BODY_PART column. It also resolves a plain whole: to whole plant for seaweed, and to whole animal for fish and molluscs.
Lowercase and trim body part into BODY_PART, resolving plain whole to whole animal or whole plant from the biological group
dfs_mock = {'BIOTA': pd.DataFrame({'body part': ['WHOLE ', 'Whole', 'Flesh without bones', 'whole plant'],
'biological group': ['Fish', 'SEAWEED', 'Fish', 'Seaweeds']})}
tfm = Transformer(dfs_mock, cbs=[NormalizeBodyPartCB()])
tfm()
test_eq(tfm.dfs['BIOTA']['BODY_PART'].to_list(), ['whole animal', 'whole plant', 'flesh without bones', 'whole plant'])Try an automatic mapping
Derive unique provider values and fuzzy-match against MARIS reference.
tfm = Transformer(dfs, cbs=[RemoveAllNAValuesCB(nan_cols_to_check), NormalizeBodyPartCB()])
dfs_out = tfm()
provider_lut = lut_from(dfs_out, 'BODY_PART')
maris_ref = get_lut('BODY_PART', as_df=True)
merged = fuzzy_merge(provider_lut, maris_ref, left_on='value', right_on='bodypar')
print(f"{len(merged)} distinct values: {(merged.score == 0).sum()} exact matches, {(merged.score > 0).sum()} to review")18 distinct values: 9 exact matches, 9 to review
Inspect the borderline matches
Review non-exact matches to identify cases the fuzzy matcher could not resolve.
value bodypar bodypar_id score
mix of muscle and whole fish without liver Flesh without bones 52 27
tail and claws Whole animal 1 10
whole without head Whole macro alga 49 10
cod medallion Skeleton 6 9
unknown Skin 12 5
whole fish Whole animal 1 5
whole fisk Whole animal 1 5
flesh Fins 16 3
flesh without bone Flesh without bones 52 1
Three non-exact matches are right: whole fish and the typo whole fisk match Whole animal, and flesh without bone matches Flesh without bones. The other six are wrong. For example, cod medallion matches Skeleton and flesh matches Fins. They are overridden below.
Fix what it got wrong
Apply expert overrides for cases the fuzzy match could not resolve correctly.
The overrides below include judgement calls. Please review them.
flesh (226 fish records): the provider does not say whether bones were included. It maps to Flesh with bones, the choice made by the previous version of this handler.cod medallion (4 Norwegian cod records): a medallion is a boneless cut of fillet. It maps to Flesh without bones.tail and claws (8 Norwegian lobster records): the edible meat of the tail and claws of a crustacean, which has no bones. It maps to Muscle.whole without head (5 Norwegian fish records): MARIS has Whole animal eviscerated without head, but these fish are not reported as eviscerated. It maps to (Not available) rather than to a body part that states a different preparation. Please consider adding a Whole animal without head entry to the MARIS body-part reference.mix of muscle and whole fish without liver (1 record): a mixture of two preparations with no MARIS equivalent. It maps to (Not available).unknown (5 records): no information. It maps to (Not available).whole (43 records) is resolved from the biological group before matching: Whole plant for seaweed, Whole animal for fish.fixes_body_part = {
'flesh': 'Flesh with bones', # Bones not specified; choice of the previous handler version
'cod medallion': 'Flesh without bones', # Boneless cut of fillet
'tail and claws': 'Muscle', # Lobster meat; crustaceans have no bones
'whole without head': '(Not available)', # No MARIS entry for a whole animal without head only
'mix of muscle and whole fish without liver': '(Not available)', # Mixture of preparations
'unknown': '(Not available)',
}fixed = fix_lut(merged, fixes_body_part, maris_ref,
left_on='value', right_on='bodypar', id_col='bodypar_id')
# The remaining non-exact matches are the ones reviewed above and accepted as correct
remaining = fixed[fixed.score > 0].sort_values('value')
print(remaining[['value', 'bodypar', 'score']].to_string(index=False))
test_eq(remaining['value'].to_list(), ['flesh without bone', 'whole fish', 'whole fisk'])Assemble the final mapping
make_lut packages the expert fixes and the MARIS reference table into a single function. The Transformer calls it later, when it processes the data through the pipeline.
Verify the body-part lookup on mock data:
dfs_mock = {'BIOTA': pd.DataFrame({'body part': ['WHOLE FISH', 'Whole', 'Tail and claws', 'UNKNOWN'],
'biological group': ['Fish', 'SEAWEED', 'Fish', 'Fish']})}
tfm = Transformer(dfs_mock, cbs=[
NormalizeBodyPartCB(),
RemapCB(lut=body_part_lut, col_remap='BODY_PART', col_src='BODY_PART', grps=['BIOTA'])
])
tfm()
test_eq(tfm.dfs['BIOTA']['BODY_PART'].to_list(), [1, 40, 34, 0])Map body parts on the real OSPAR BIOTA data:
tfm = Transformer(dfs, cbs=[
RemoveAllNAValuesCB(nan_cols_to_check),
NormalizeBodyPartCB(),
RemapCB(lut=body_part_lut, col_remap='BODY_PART', col_src='BODY_PART', grps=['BIOTA'])
])
biota = tfm()['BIOTA']
names = get_lut('BODY_PART', as_df=True).set_index('bodypar_id')['bodypar']
print(biota['BODY_PART'].map(names).value_counts().to_string())
test_eq((biota['BODY_PART'] == 0).sum(), 11)BODY_PART
Whole plant 5123
Whole animal 3397
Soft parts 3235
Flesh without bones 1976
Growing tips 1480
Muscle 457
Flesh with bones 226
Flesh with scales 23
Liver 12
Head 11
(Not available) 11
The MARIS SPECIES lookup table includes a biogroup_id column. Every OSPAR BIOTA record now has a MARIS SPECIES ID, so the biological group is a direct lookup. No fuzzy matching is needed.
Verify the biological-group lookup on mock species IDs: Pisces, Mollusca and Seaweed, the three entries used to fill in missing species:
Apply to the real OSPAR BIOTA data, chained after the species remap:
tfm = Transformer(dfs, cbs=[
RemoveAllNAValuesCB(nan_cols_to_check),
LowerStripNameCB(col_src='species', col_dst='SPECIES', grps=['BIOTA']),
RemapCB(lut=species_lut, col_remap='SPECIES', col_src='SPECIES', grps=['BIOTA']),
EnhanceSpeciesCB(),
RemapCB(lut=lut_biogroup, col_remap='BIO_GROUP', col_src='SPECIES', grps=['BIOTA'])
])
biota = tfm()['BIOTA']
test_eq((biota['BIO_GROUP'] == 0).sum(), 0)
print(f"BIO_GROUP counts: {biota['BIO_GROUP'].value_counts().to_dict()}")BIO_GROUP counts: {11: 6604, 4: 5478, 14: 2036, 13: 1788, 2: 43, 12: 1, 5: 1}
MARIS requires two identifier columns:
SMP_ID is an internal unique identifier for each recordSMP_ID_PROVIDER is the sample identifier given by the data providerOSPAR gives the sample identifier in the sample id column. Several measurements of the same sample share it. AddSampleIDCB generates the sequential SMP_ID and copies sample id into SMP_ID_PROVIDER. A missing sample id becomes an empty string.
sample id is missing for 5,578 BIOTA records and 6,809 SEAWATER records, about 35% of each group. Every contracting party has records without it. Without a sample identifier, measurements from the same sample cannot be linked.
BIOTA: 5578 of 15951 records without sample id
SEAWATER: 6809 of 19183 records without sample id
SMP_ID_PROVIDER holds OSPAR’s sample id, which traces a MARIS record back to the provider’s sample when it is given. For the 35% of records without one, there are two options for a future version:
id when sample id is missing. id is present and unique for every record, but it identifies a measurement, not a sample.id in a separate provider field, if MARIS adds one, and keep SMP_ID_PROVIDER for the sample identifier.Assign internal sequential SMP_ID and preserve OSPAR sample id as SMP_ID_PROVIDER
# Verify sample IDs on mock data
dfs_mock = {'BIOTA': pd.DataFrame({'sample id': ['FNZ14007', None, 'FNZ14007']})}
tfm = Transformer(dfs_mock, cbs=[AddSampleIDCB()])
tfm()
test_eq(tfm.dfs['BIOTA']['SMP_ID'].to_list(), [1, 2, 3])
test_eq(tfm.dfs['BIOTA']['SMP_ID_PROVIDER'].to_list(), ['FNZ14007', '', 'FNZ14007']) SMP_ID SMP_ID_PROVIDER
0 1 WNZ 01
1 2 WNZ 02
2 3 WNZ 03
3 4 WNZ 04
4 5 WNZ 05
OSPAR gives the sampling depth, in metres, in a sampling depth column for SEAWATER only. AddDepthCB copies it into the MARIS SMP_DEPTH column as a float. 43 SEAWATER records have no sampling depth. Their SMP_DEPTH stays empty.
Copy OSPAR sampling depth (SEAWATER) to MARIS-standard SMP_DEPTH as float
dfs_mock = {
'SEAWATER': pd.DataFrame({'sampling depth': [5, None]}),
'BIOTA': pd.DataFrame({'species': ['Fucus vesiculosus']}),
}
tfm = Transformer(dfs_mock, cbs=[AddDepthCB()])
tfm()
test_eq(tfm.dfs['SEAWATER']['SMP_DEPTH'].iloc[0], 5.0)
test_eq(tfm.dfs['SEAWATER']['SMP_DEPTH'].isna().to_list(), [False, True])
test_eq('SMP_DEPTH' in tfm.dfs['BIOTA'].columns, False)SMP_DEPTH: 0.0 to 1850.0 m, 43 records without depth
The 43 SEAWATER records without a sampling depth keep an empty SMP_DEPTH, as in the HELCOM handler. The curation rules in the writing-a-handler guide ask for -1 instead. Please decide which convention applies, for all handlers.
OSPAR gives coordinates in degrees, minutes and seconds (latd, latm, lats, longd, longm, longs), with the hemisphere in latdir (N or S) and longdir (E or W). MARIS stores decimal degrees in LAT and LON. dms_to_dd converts them, and ParseCoordinatesCB applies it to every group. Some records break the format. The conversion handles them as follows:
-1 means that seconds were not recorded. It counts as 0.-1 above, places the point west or south, whatever the hemisphere column says. For example, Jan Mayen is recorded as -9° 8' 3" E, and lies at about 9° W.ParseCoordinatesCB drops the record.Some coordinates fall outside the degree-minute-second format:
-1 for missing seconds.-9° or -8° with hemisphere E, for a position at about 8° to 9° W. Two Norwegian records at station St.685 OF give 0° -29' -56" with hemisphere E. One UK record at Sellafield gives -3°.latdir (N).Reporting coordinates in decimal degrees, with a sign for the hemisphere, would remove these issues.
cols = ['id', 'contracting party', 'station id', 'latd', 'latm', 'lats', 'latdir', 'longd', 'longm', 'longs', 'longdir']
for grp, df in dfs.items():
parts = df[['latd', 'latm', 'lats', 'longd', 'longm', 'longs']]
odd = (parts < 0).any(axis=1) | (parts[['latm', 'lats', 'longm', 'longs']] > 60).any(axis=1)
print(f"{grp}: {odd.sum()} records with negative parts, or minutes or seconds above 60")
print(df.loc[odd, cols].head(5).to_string(), '\n')BIOTA: 28 records with negative parts, or minutes or seconds above 60
id contracting party station id latd latm lats latdir longd longm longs longdir
9302 72711 Norway Utsira Nord 60 19.0 47.0 N 2 39.0 -1.0 E
10491 80487 Norway Eggakanten 68 30.0 0.0 N 11 23.0 -1.0 E
10494 80490 Norway Eggakanten 66 19.0 59.0 N 6 17.0 -1.0 E
10502 80498 Norway Eggakanten 66 34.0 59.0 N 6 49.0 -1.0 E
10505 80501 Norway Eggakanten 66 40.0 0.0 N 5 35.0 -1.0 E
SEAWATER: 87 records with negative parts, or minutes or seconds above 60
id contracting party station id latd latm lats latdir longd longm longs longdir
3255 99103 Faroes Kirkeby 61 57.0 18.0 N 6 47.0 66.0 W
3256 99104 Faroes Kirkeby 61 57.0 18.0 N 6 47.0 66.0 W
3257 99105 Faroes Kirkeby 61 57.0 18.0 N 6 47.0 66.0 W
7735 61133 Norway Jan Mayen 70 50.0 48.0 N -9 8.0 3.0 E
11354 85454 Norway Oksø-Hanstholm (56 n.m.) (St. 106) 57 10.0 59.0 N 8 34.0 -1.0 E
The coordinate rules above include judgement calls. Please review them:
Convert OSPAR degree, minute, second and hemisphere columns to signed decimal degrees
Convert OSPAR degree-minute-second coordinates to decimal LAT and LON, dropping records whose coordinates cannot be interpreted
Verify ParseCoordinatesCB on mock data. The rows cover a plain position, -1 seconds, a negative degree with hemisphere E, a trailing space in the hemisphere, and 441 minutes:
dfs_mock = {'SEAWATER': pd.DataFrame({
'latd': [54, 60, 70, 55, 54], 'latm': [30., 19., 50., 60., 441.], 'lats': [0., 47., 48., 0., 42.], 'latdir': ['N', 'N', 'N', 'N ', 'N'],
'longd': [3, 2, -9, 2, 4], 'longm': [15., 39., 8., 59., 21.], 'longs': [0., -1., 3., 59., 34.], 'longdir': ['W', 'E', 'E', 'E', 'W'],
})}
tfm = Transformer(dfs_mock, cbs=[ParseCoordinatesCB()])
tfm()
sw = tfm.dfs['SEAWATER']
test_eq(len(sw), 4)
test_close(sw['LAT'].to_list(), [54.5, 60 + 19/60 + 47/3600, 70 + 50/60 + 48/3600, 56.0])
test_close(sw['LON'].to_list(), [-3.25, 2 + 39/60, -(9 + 8/60 + 3/3600), 2 + 59/60 + 59/3600])Apply ParseCoordinatesCB and the generic SanitizeLonLatCB to the real data. SanitizeLonLatCB drops records at (0, 0) or outside the valid range:
tfm = Transformer(dfs, cbs=[RemoveAllNAValuesCB(nan_cols_to_check), ParseCoordinatesCB(), SanitizeLonLatCB()])
dfs_out = tfm()
for grp, df in dfs_out.items():
n_in = len(dfs[grp].dropna(subset=nan_cols_to_check, how='all'))
print(f"{grp}: {n_in - len(df)} records dropped; LAT {df['LAT'].min():.2f} to {df['LAT'].max():.2f}, LON {df['LON'].min():.2f} to {df['LON'].max():.2f}")BIOTA: 0 records dropped; LAT 43.46 to 79.93, LON -39.63 to 49.43
SEAWATER: 1 records dropped; LAT 36.18 to 81.27, LON -58.23 to 43.03
OSPAR identifies each sampling location in a station id column. AddStationCB copies it, trimmed, into the MARIS STATION column. 110 records have trailing spaces, and a missing station becomes an empty string.
Copy OSPAR station id, trimmed, to MARIS-standard STATION
dfs_mock = {
'SEAWATER': pd.DataFrame({'station id': ['Sellafield ', None]}),
'BIOTA': pd.DataFrame({'station id': ['Utsira Nord']}),
}
tfm = Transformer(dfs_mock, cbs=[AddStationCB()])
tfm()
test_eq(tfm.dfs['SEAWATER']['STATION'].to_list(), ['Sellafield', ''])
test_eq(tfm.dfs['BIOTA']['STATION'].to_list(), ['Utsira Nord'])BIOTA: 572 stations, 398 records without station
SEAWATER: 1315 stations, 112 records without station
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. AddSampleIDCB runs last, after every step that can drop a record, so that SMP_ID numbers the records without gaps.
Callbacks, in pipeline order, that turn raw OSPAR data into MARIS-standard DataFrames
def get_cbs() -> list:
"Callbacks, in pipeline order, that turn raw OSPAR data into MARIS-standard DataFrames"
return [
RemoveAllNAValuesCB(nan_cols_to_check),
# Nuclide normalisation and mapping
LowerStripNameCB(col_src='nuclide', col_dst='NUCLIDE'),
RemapCB(lut=nuclide_lut, col_remap='NUCLIDE', col_src='NUCLIDE'),
# Time
ParseTimeCB(),
EncodeTimeCB(),
# Value, uncertainty, unit and detection limit
SanitizeValueCB(coi_val),
NormalizeUncCB(),
RemapUnitCB(),
RemapDetectionLimitCB(),
# BIOTA lookups: species, biological group, body part
LowerStripNameCB(col_src='species', col_dst='SPECIES', grps=['BIOTA']),
RemapCB(lut=species_lut, col_remap='SPECIES', col_src='SPECIES', grps=['BIOTA']),
EnhanceSpeciesCB(),
RemapCB(lut=lut_biogroup, col_remap='BIO_GROUP', col_src='SPECIES', grps=['BIOTA']),
NormalizeBodyPartCB(),
RemapCB(lut=body_part_lut, col_remap='BODY_PART', col_src='BODY_PART', grps=['BIOTA']),
# Depth, coordinates, station
AddDepthCB(),
ParseCoordinatesCB(),
SanitizeLonLatCB(),
AddStationCB(),
# Sample identifiers, once all record drops are done
AddSampleIDCB()
]BIOTA: 15951 records
NUCLIDE TIME VALUE UNC UNIT DL SPECIES BIO_GROUP BODY_PART LAT LON STATION SMP_ID SMP_ID_PROVIDER
0 33 1267574400 0.326416 NaN 5 2 377 14 1 51.393333 4.031111 Kloosterzande-Schelde 1 DA 17531
1 33 1276473600 0.442704 NaN 5 2 377 14 1 51.393333 4.031111 Kloosterzande-Schelde 2 DA 17534
2 33 1285545600 0.412989 NaN 5 2 377 14 1 51.393333 4.031111 Kloosterzande-Schelde 3 DA 17537
SEAWATER: 19182 records
NUCLIDE TIME VALUE UNC UNIT DL SMP_DEPTH LAT LON STATION SMP_ID SMP_ID_PROVIDER
0 33 1264550400 200.0 NaN 1 2 3.0 51.375278 3.188056 Belgica-W01 1 WNZ 01
1 33 1264550400 270.0 NaN 1 2 3.0 51.223611 2.859444 Belgica-W02 2 WNZ 02
2 33 1264550400 260.0 NaN 1 2 3.0 51.184444 2.713611 Belgica-W03 3 WNZ 03
['Remove rows with all NA values in specified columns',
"Convert 'nuclide' column values to lowercase, strip spaces, and store in 'NUCLIDE' column.",
"Remap values from 'NUCLIDE' to 'NUCLIDE' for groups: all.",
'Parse OSPAR `sampling date` (MM/DD/YY HH:MM:SS) into TIME',
'Encode time as seconds since epoch',
'Sanitize measurement values by removing blanks and standardizing to use the `VALUE` column',
'Convert expanded uncertainty (coverage factor k) to standard uncertainty UNC',
'Set UNIT from the reported unit and convert VALUE and UNC to it (seawater Bq/L to Bq/m³)',
'Map OSPAR `value type` (`=` / `<`) to MARIS detection-limit codes, treating a missing type with an uncertainty as detected',
"Convert 'species' column values to lowercase, strip spaces, and store in 'SPECIES' column.",
"Remap values from 'SPECIES' to 'SPECIES' for groups: BIOTA.",
"Replace SPECIES 0 (not available) with the broad MARIS entry of the record's biological group",
"Remap values from 'SPECIES' to 'BIO_GROUP' for groups: BIOTA.",
'Lowercase and trim `body part` into BODY_PART, resolving plain `whole` to whole animal or whole plant from the biological group',
"Remap values from 'BODY_PART' to 'BODY_PART' for groups: BIOTA.",
'Copy OSPAR `sampling depth` (SEAWATER) to MARIS-standard SMP_DEPTH as float',
'Convert OSPAR degree-minute-second coordinates to decimal LAT and LON, dropping records whose coordinates cannot be interpreted',
'Drop rows with invalid longitude & latitude values. Convert `,` separator to `.` separator',
'Copy OSPAR `station id`, trimmed, to MARIS-standard STATION',
'Assign internal sequential SMP_ID and preserve OSPAR `sample id` as SMP_ID_PROVIDER']
def get_attrs(
tfm:marisco.callbacks.Transformer, # Transformer object
zotero_key:str, # Zotero dataset record key
kw:list=['oceanography', 'Earth Science > Oceans > Ocean Chemistry> Radionuclides', 'Earth Science > Human Dimensions > Environmental Impacts > Nuclear Radiation Exposure', 'Earth Science > Oceans > Ocean Chemistry > Ocean Tracers', 'Earth Science > Oceans > Ocean Chemistry, Earth Science > Oceans > Sea Ice > Isotopes', 'Earth Science > Oceans > Water Quality > Ocean Contaminants', 'Earth Science > Biological Classification > Animals/Vertebrates > Fish', 'Earth Science > Biosphere > Ecosystems > Marine Ecosystems', 'Earth Science > Biological Classification > Animals/Invertebrates > Mollusks', 'Earth Science > Biological Classification > Animals/Invertebrates > Arthropods > Crustaceans', 'Earth Science > Biological Classification > Plants > Macroalgae (Seaweeds)'], # List of keywords
)->dict: # Global attributesRetrieve all global attributes
def get_attrs(
tfm: Transformer, # Transformer object
zotero_key: str, # Zotero dataset record key
kw: list = kw # List of keywords
) -> dict: # Global attributes
"Retrieve all global attributes"
return GlobAttrsFeeder(tfm.dfs, cbs=[
BboxCB(),
DepthRangeCB(),
TimeRangeCB(),
ZoteroCB(zotero_key),
KeyValuePairCB('keywords', ', '.join(kw)),
KeyValuePairCB('publisher_postprocess_logs', ', '.join(tfm.logs))
])(){'geospatial_lat_min': '36.181666666666665',
'geospatial_lat_max': '81.26805555555555',
'geospatial_lon_min': '-58.23166666666667',
'geospatial_lon_max': '49.43222222222222',
'geospatial_bounds': 'POLYGON ((-58.23166666666667 36.181666666666665, 49.43222222222222 36.181666666666665, 49.43222222222222 81.26805555555555, -58.23166666666667 81.26805555555555, -58.23166666666667 36.181666666666665))',
'geospatial_vertical_max': '1850.0',
'geospatial_vertical_min': '0.0',
'time_coverage_start': '1995-01-01T00:00:00',
'time_coverage_end': '2022-12-31T00:00:00',
'id': 'LQRA4MMK',
'title': 'OSPAR Environmental Monitoring of Radioactive Substances',
'summary': '',
'creator_name': '[{"creatorType": "author", "firstName": "", "lastName": "OSPAR Comission\'s Radioactive Substances Committee (RSC)"}]',
'keywords': 'oceanography, Earth Science > Oceans > Ocean Chemistry> Radionuclides, Earth Science > Human Dimensions > Environmental Impacts > Nuclear Radiation Exposure, Earth Science > Oceans > Ocean Chemistry > Ocean Tracers, Earth Science > Oceans > Ocean Chemistry, Earth Science > Oceans > Sea Ice > Isotopes, Earth Science > Oceans > Water Quality > Ocean Contaminants, Earth Science > Biological Classification > Animals/Vertebrates > Fish, Earth Science > Biosphere > Ecosystems > Marine Ecosystems, Earth Science > Biological Classification > Animals/Invertebrates > Mollusks, Earth Science > Biological Classification > Animals/Invertebrates > Arthropods > Crustaceans, Earth Science > Biological Classification > Plants > Macroalgae (Seaweeds)',
'publisher_postprocess_logs': "Remove rows with all NA values in specified columns, Convert 'nuclide' column values to lowercase, strip spaces, and store in 'NUCLIDE' column., Remap values from 'NUCLIDE' to 'NUCLIDE' for groups: all., Parse OSPAR `sampling date` (MM/DD/YY HH:MM:SS) into TIME, Encode time as seconds since epoch, Sanitize measurement values by removing blanks and standardizing to use the `VALUE` column, Convert expanded uncertainty (coverage factor k) to standard uncertainty UNC, Set UNIT from the reported unit and convert VALUE and UNC to it (seawater Bq/L to Bq/m³), Map OSPAR `value type` (`=` / `<`) to MARIS detection-limit codes, treating a missing type with an uncertainty as detected, Convert 'species' column values to lowercase, strip spaces, and store in 'SPECIES' column., Remap values from 'SPECIES' to 'SPECIES' for groups: BIOTA., Replace SPECIES 0 (not available) with the broad MARIS entry of the record's biological group, Remap values from 'SPECIES' to 'BIO_GROUP' for groups: BIOTA., Lowercase and trim `body part` into BODY_PART, resolving plain `whole` to whole animal or whole plant from the biological group, Remap values from 'BODY_PART' to 'BODY_PART' for groups: BIOTA., Copy OSPAR `sampling depth` (SEAWATER) to MARIS-standard SMP_DEPTH as float, Convert OSPAR degree-minute-second coordinates to decimal LAT and LON, dropping records whose coordinates cannot be interpreted, Drop rows with invalid longitude & latitude values. Convert `,` separator to `.` separator, Copy OSPAR `station id`, trimmed, to MARIS-standard STATION, Assign internal sequential SMP_ID and preserve OSPAR `sample id` as SMP_ID_PROVIDER"}
OSPAR radioactive substances monitoring data (seawater and biota)
def encode(
dest: str, # Output file name
src: str = None, # Path to raw OSPAR data; defaults to `src_dir`
**kwargs # Additional arguments
) -> None:
"OSPAR radioactive substances monitoring data (seawater and biota)"
dfs = load_data(src or src_dir)
# For a quick test file, keep 10 random records per sample type:
# dfs = {k: v.sample(10, random_state=42) for k, v in dfs.items()}
tfm = Transformer(dfs, cbs=get_cbs())
tfm()
encoder = NetCDFEncoder(tfm.dfs,
dest_fname=dest,
global_attrs=get_attrs(tfm, zotero_key=zotero_key, kw=kw),
verbose=kwargs.get('verbose', False),
)
encoder.encode()to_csv converts the MARIS NetCDF file into one CSV file per sample type, with the column names the MARIS master database import expects. The marisco-export command does the same from the command line.