Investigating PACE and OBIS as a data source for phytoplankton observations

Investigating PACE and OBIS as a data source for phytoplankton observations#

Created: 2026-08-25

The Plankton, Aerosol, Cloud, ocean Ecosystem (PACE) and the Ocean Biodiversity Information System (OBIS) both provide information about phytoplankton distribution on a global scale. PACE Chlorophyll is the near-surface concentration of the green pigment, chlorophyll a, at the ocean surface. Found in tiny photosynthetic organisms – phytoplankton – chlorophyll is a measure of ocean ecosystem health (link). On the other hand, OBIS is the world’s largest global open-access infrastructure for marine biodiversity data, providing occurrence records at species levels.

The former will have a much higher global coverage while the former will certainly be more accurate in its data. While researches can ue one to calibrate the other or mix-and-match these data to create a better final product, we wonder how easy it is to fetch them and display on a common grid.

We will use the pyobis and earthaccess libraries. Earthaccess requires a login to get download access the data. Please navigate to https://earthaccess.readthedocs.io/en/latest/user/ and follow the instructions there on how to create one.

import earthaccess

auth = earthaccess.login(persist=True)

We can not define our search bounding box, time span, and filter for the 1 degree dataset only, the 4 km results will also be fetched. Because it can be a lot of data, we will avoid the 4km high resolution for now with a simple filter to the results and get only the files that end with 1deg.nc string.

results = earthaccess.search_data(
    short_name="PACE_OCI_L3M_CHL",
    temporal=("2025-01-01", "2025-12-31"),
    bounding_box=(-90.0, 40.0, -75.0, 47.0),
)

fileset = earthaccess.open(results)
one_degree = [f for f in fileset if f.full_name.endswith("1deg.nc")]

The next loop downloads and save the data. We will re-download the data if we do not have it already. The local cache will save us some time and ensure we always have the data even if the server is down.

Local caching is always a good practice to preserver the data and avoid putting a lot of strain on servers with multiple identical requests.

from pathlib import Path

import pandas as pd
import xarray as xr

fname = "chlor_a.nc"

if not Path(fname).exists():

    def fake_time(ds):
        time = ds.attrs["product_name"].split(".")[1].split("_")
        time = pd.to_datetime(time).mean()
        ds = ds.assign_coords(time=time)
        return ds["chlor_a"].expand_dims(time=1)

    ds = xr.open_mfdataset(one_degree, preprocess=fake_time)
    chlor_a = ds["chlor_a"].where(
        (ds["chlor_a"] < ds["chlor_a"].display_max)
        & (ds["chlor_a"] > ds["chlor_a"].display_min)
    )

    chlor_a = chlor_a.mean(dim="time").compute()
else:
    chlor_a = xr.open_dataset(fname)


# DataArray for the chlor_a variable only.
chlor_a = chlor_a["chlor_a"]

Now we can query OBIS for appropriate phytoplankton taxonomic groupings. Here we list some generic phytoplankton groupings which would be equivalent to the chlorophyll satellite proxy data. Luckily OBIS allows users to use the WoRMS taxonomic backbone as a way to search for more exapansize taxonomic ranks, like family.

Below we do an initial test to ensure our search criterias are working with pyobis.

from pyobis import occurrences

# IDs from the table above.
aphia_ids = ["148899", "19542", "146537", "592906"]

query = occurrences.search(taxonid=aphia_ids, size=100)
data = query.execute()

data
2026-08-26 11:10:04 - pyobis.cache.cache - INFO - Cache initialized at C:\Users\Mathew.Biddle\AppData\Local\pyobis
2026-08-26 11:10:04 - pyobis.obisutils - INFO - 100 to be fetched. Estimated time =0.064834473133087160 seconds
2026-08-26 11:10:04 - pyobis.obisutils - INFO - Fetching: [████████████████████████████████████████████████████████████████████████████████████████████████████] 100/100
2026-08-26 11:10:04 - pyobis.cache.cache - INFO - Cache initialized at C:\Users\Mathew.Biddle\AppData\Local\pyobis
2026-08-26 11:10:04 - pyobis.obisutils - INFO - Fetched 100 records.
basisOfRecord bibliographicCitation brackish catalogNumber class classid collectionCode coordinateUncertaintyInMeters country date_end ... fieldNotes preparations verbatimLocality associatedOccurrences eventType identificationVerificationStatus identificationQualifier individualCount forma formaid
0 MaterialSample Brown, M. V. et al. Continental scale monitori... True 102.100.100/35552-EF9455B8FFF22EFC18B3DCB74655... Coccolithophyceae 592906.0 BioPlatforms Marine microbes NRS ROT 50.0 Australia 1.498349e+12 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
1 HumanObservation Continuous Plankton Recorder (CPR) data from t... True 23AC-43-172 Bacillariophyceae 148899.0 CPR NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
2 MaterialSample Focardi, Amaranta,Ostrowski, Martin,Goossen, K... True 102.100.100/35991-3012DC434CD10C331A5B06F1EAFB... Dinophyceae 19542.0 BioPlatforms Marine microbes in2016_v04 50.0 Australia 1.473984e+12 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
3 MATERIAL_SAMPLE NaN True NaN Dinophyceae 19542.0 NaN NaN NaN 1.676419e+12 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
4 MATERIAL_SAMPLE NaN True NaN Dinophyceae 19542.0 NaN NaN NaN 1.447891e+12 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
95 Occurrence NaN True d2ec2d5e06a7683d488c60683531_ARVAM_60683531_AR... Dinophyceae 19542.0 QUADRIGE - Coastal monitoring database and pro... NaN NaN 1.521072e+12 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
96 MATERIAL_SAMPLE NaN True NaN Dinophyceae 19542.0 NaN NaN NaN 1.558138e+12 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
97 MATERIAL_SAMPLE NaN True NaN Cyanophyceae 146542.0 NaN NaN NaN 1.475712e+12 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
98 MaterialSample Brown, M. V. et al. Continental scale monitori... True 102.100.100/34175-9BDF23CB027253367726EAD57B18... Coccolithophyceae 592906.0 BioPlatforms Marine microbes NRS ROT 50.0 Australia 1.458691e+12 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
99 MATERIAL_SAMPLE NaN True NaN Coccolithophyceae 592906.0 NaN NaN NaN 1.486080e+12 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN

100 rows × 122 columns

pd.to_datetime(data.date_year, format="%Y").describe()
count                            91
mean     2014-05-21 06:35:36.263736
min             1972-01-01 00:00:00
25%             2013-01-01 00:00:00
50%             2016-01-01 00:00:00
75%             2019-01-01 00:00:00
max             2024-01-01 00:00:00
Name: date_year, dtype: object

The next step is to query for gridded occurrence data for the aphia_ids we selected above. We search for gridded occurrences because this reduces the amount of data sent across the web. We could collect specific point observations, but that would take a lot of bandwidth and processing power to ingest and convert to appropriate georeferenced data. The .grid() response also provides total occurrence counts per cell, which is what we want to collect anyways.

features = occurrences.grid(3, taxonid=aphia_ids).execute()
2026-08-26 11:12:20 - pyobis.cache.cache - INFO - Cache initialized at C:\Users\Mathew.Biddle\AppData\Local\pyobis
2026-08-26 11:12:20 - pyobis.cache.cache - INFO - Cache initialized at C:\Users\Mathew.Biddle\AppData\Local\pyobis

In order to match with PACE data later, we need to organize the data into geo-aware constructs. We will use geopandas.GeoDataFrames as the unified construct to align our data into.

import geopandas as gpd

gdf = gpd.GeoDataFrame.from_features(features)
gdf
geometry n
0 POLYGON ((150.46875 -35.15625, 151.875 -35.156... 1517546
1 POLYGON ((153.28125 -28.125, 154.6875 -28.125,... 1090666
2 POLYGON ((147.65625 -43.59375, 149.0625 -43.59... 980360
3 POLYGON ((115.3125 -32.34375, 116.71875 -32.34... 911524
4 POLYGON ((146.25 -19.6875, 147.65625 -19.6875,... 831335
... ... ...
8994 POLYGON ((-150.46875 -70.3125, -149.0625 -70.3... 1
8995 POLYGON ((-146.25 -87.1875, -144.84375 -87.187... 1
8996 POLYGON ((-167.34375 -78.75, -165.9375 -78.75,... 1
8997 POLYGON ((-178.59375 -73.125, -177.1875 -73.12... 1
8998 POLYGON ((-175.78125 -75.9375, -174.375 -75.93... 1

8999 rows × 2 columns

Note that the occurence rages from 1 to very large numbers (~10e6). We need to log-normalize the colors for better visualization. After that, we can plot the two side.

import cartopy.crs as ccrs
import matplotlib.colors as colors
import matplotlib.pyplot as plt

norm = colors.LogNorm()

projection = ccrs.PlateCarree()
fig, (ax0, ax1) = plt.subplots(
    nrows=2,
    subplot_kw={"projection": projection},
    sharex=True,
    sharey=True,
    figsize=(11, 11),
)

ax = gdf.plot(
    column="n",
    norm=colors.LogNorm(),
    legend=True,
    legend_kwds={"label": "OBIS Occurrences"},
    ax=ax0,
)


cs = chlor_a.plot(norm=colors.LogNorm(), ax=ax1)
../../../_images/be6b7f0329987aaf3a2803963286e22cecf74b59c122a231596842ddf73942d1.png

We can see some features, like a higher density near the coast, currents, and the oligotrophic areas at the gyres centers. As expected, OBIS data density falls off quickly outside of the North Atlantic. However, there is a clear “Gulf Stream signal” in the dataset that is absent in the PACE data. We can envison some sort of interpolation that will use the OBIS data to increase precision and PACE to augument the spatial resolution.

We won’t be doing any of that here though. Our goal is to demostrate how to fetch matching data and display them side-by-side. In order to test a better “qualitative” way to compare the data we will average out the PACE data to the OBIS occurrences geometry, and put the data on an hexagonal hierarchical geospatial indexing system (H3) for plotting and comparing each hexagon.

def average_chlor_a(geom):
    xmin, ymin, xmax, ymax = geom

    c = chlor_a.where(
        (xmin <= chlor_a["lon"])
        & (chlor_a["lon"] <= xmax)
        & (ymin <= chlor_a["lat"])
        & (chlor_a["lat"] <= ymax),
        drop=True,
    )
    return float(c.mean().to_numpy())

We will use joblib’s Parallel to speed up the averages.

import multiprocessing

from joblib import Parallel, delayed

num_cores = multiprocessing.cpu_count()

bounds = gdf["geometry"].bounds.to_numpy()

chlor_avg = Parallel(n_jobs=num_cores)(
    delayed(average_chlor_a)(geom) for geom in bounds
)

gdf["chlor_avg"] = chlor_avg

Now make the H3 index for both datasets.

import h3pandas  # noqa I001

gdf["x"] = gdf["geometry"].centroid.x
gdf["y"] = gdf["geometry"].centroid.y

# The resolution goes from 0-15, using 2 b/c that is the closest to the OBIS squares.
resolution = 2

df = gdf[["x", "y", "n", "chlor_avg"]]

dfh3 = df.h3.geo_to_h3(
    resolution=resolution,
    lat_col="y",
    lng_col="x",
    set_index=True,
)


dfh3 = dfh3.drop(columns=["x", "y"]).groupby(dfh3.index.name).sum()
gdfh3 = dfh3.h3.h3_to_geo_boundary()

We need to correct the longitudes for better plotting at the dateline.

import numpy as np


def fix_geom(geom):
    from shapely import Polygon

    lons, lats = map(list, geom.boundary.coords.xy)
    if max(abs(np.diff(lons))) > 180:
        xx = [x + 360 if x < 0 else x for x in lons]
        return Polygon(zip(xx, lats))
    else:
        return geom


gdfh3["geometry"] = gdfh3["geometry"].apply(fix_geom)
gdfh3["log_n"] = np.log(gdfh3["n"])

Let’s take a quick look at the data to see if we are getting the structure we are looking for.

gdfh3
n chlor_avg geometry log_n
h3_02
820007fffffffff 667 2.514208 POLYGON ((36.42872 77.68575, 43.86177 78.00915... 6.502790
82000ffffffffff 1891 3.735855 POLYGON ((30.78838 80.33975, 40.19373 80.81642... 7.544861
820017fffffffff 119 1.853077 POLYGON ((48.58085 76.68007, 55.65377 76.66744... 4.779123
82001ffffffffff 1085 3.568005 POLYGON ((46.68072 79.52276, 55.75077 79.55833... 6.989335
820027fffffffff 2371 6.709328 POLYGON ((29.20747 75.65094, 35.20921 76.15221... 7.771067
... ... ... ... ...
82f31ffffffffff 9 5.836377 POLYGON ((-168.19092 -76.36649, -170.0979 -77.... 2.197225
82f337fffffffff 71 1.949622 POLYGON ((180.32561 -73.31022, 177.7638 -74.58... 4.262680
82f367fffffffff 1 0.113506 POLYGON ((-151.74413 -69.84475, -151.56695 -71... 0.000000
82f3a7fffffffff 18 1.966249 POLYGON ((170.97665 -77.08353, 166.42141 -78.2... 2.890372
82f3b7fffffffff 870 0.000000 POLYGON ((159.6908 -77.80316, 153.57501 -78.79... 6.768493

3341 rows × 4 columns

Finally, let’s plot the OBIS data at the top and PACE at the lower panel.

fig, (ax0, ax1) = plt.subplots(nrows=2, figsize=(13, 11), sharex=True, sharey=True)

ax = gdfh3.plot(
    column="n",
    ax=ax0,
    legend=True,
    norm=colors.LogNorm(),
)
gdfh3.plot(
    column="chlor_avg",
    ax=ax1,
    legend=True,
    norm=colors.LogNorm(),
)
ax0.axis([-180, 180, -90, 90])
ax0.set_title("OBIS: Number of Phytoplankton Occurrences")
ax1.set_title("PACE: Average Chlorophyll-a Concentration")
fig.tight_layout()
../../../_images/c4955ef051207634181be8275d3030e957b63b5de8ed012916fc34e6bb596d92.png

Done! Hopefully this example will be useful for those using these different data sources.

We would expect to see some rough alignment of higher concentrations of phytoplankton species occurrences (OBIS) with higher concentrations of Chlorophyll-a (PACE). And we do see some of that off Europe and the Gulf Stream. However, we do need to consider that the observing tool for PACE is satellite observations, so that is only presenting surface observations. Whereas OBIS occurrences are the total number of records for one geographic cell, regardless of the depth at which they observed the animal (think of a CTD profile where bottles were collected at the same station but different depths. The OBIS records would be the summation of all the occurrences for that animal at that station, regardless of depth). Additionally, PACE is a snapshot from 2025-01-01 to 2025-12-31 versus OBIS as a cummulative total from 1972-2025.