IOOS Data

Contents

IOOS Data#

The Integrated Ocean Observing System (IOOS) collects two chlorophyll variables:

  • mass_concentration_of_chlorophyll_in_sea_water

  • mass_fraction_of_chlorophyll_a_in_sea_water

Based on research into their extensive QA/QC methods, mass_fraction_of_chlorophyll_a_in_sea_water is in-vivo, flourecence chlorophyll, so this variable is not collected.

All data is organized and compiled from the IOOS ERDDAP database. Since these datasets are usually very large due to the regular samples, the datasets are organized into regions for better processing speeds.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import cartopy
import cartopy.crs as ccrs
import cartopy.feature as cfeature
from cartopy.mpl.gridliner import LONGITUDE_FORMATTER, LATITUDE_FORMATTER
import geopandas as gpd
from datetime import datetime
import os
from matplotlib import ticker
import datetime as dt
import plotly.express as px
import cmocean as cm
import cmocean.cm as cmo
import matplotlib.gridspec as gridspec
import time
import matplotlib.ticker as mticker
#Seperate datasets into regions for easier manegment 
#boundries for west coast
kwest  = {"min_lon": -131, "max_lon": -111,
    "min_lat": 19,"max_lat": 53,
    "min_time": "2000-01-01T00:00:00Z",
    "max_time": "2025-04-28T00:00:00Z"}
#boundries for east coast
keast  = {"min_lon": -82, "max_lon": -47,
    "min_lat": 19, "max_lat": 50,
    "min_time": "2000-01-01T00:00:00Z",
    "max_time": "2025-04-28T00:00:00Z"}
#boundries for gulf of mexico
kgulf  = {"min_lon": -103, "max_lon": -82,
    "min_lat": 17,"max_lat": 34,
    "min_time": "2000-01-01T00:00:00Z",
    "max_time": "2025-04-28T00:00:00Z"}

#boundries for alaska
kalas  = {"min_lon": -168, "max_lon": -135,
    "min_lat": 51,"max_lat": 77,
    "min_time": "2000-01-01T00:00:00Z",
    "max_time": "2025-04-28T00:00:00Z"}
#boundries for hawaii
khaw  = {"min_lon": -162, "max_lon": -150,
    "min_lat": 15, "max_lat": 23,
    "min_time": "2000-01-01T00:00:00Z",
    "max_time": "2025-04-28T00:00:00Z"}

Using the ERDDAP server, the variable name mass_concentration_of_chlorophyll_in_sea_water was searched. Any projects with that variable was saved in each region

server = "http://erddap.sensors.ioos.us/erddap" #ioos erddap server
e = ERDDAP(server=server, protocol="tabledap")
url_west = e.get_search_url(search_for="mass_concentration_of_chlorophyll_in_sea_water", response="csv",**kwest) #only pull chlorophyll data from within the boundries
url_east = e.get_search_url(search_for="mass_concentration_of_chlorophyll_in_sea_water", response="csv",**keast) 
url_gulf = e.get_search_url(search_for="mass_concentration_of_chlorophyll_in_sea_water", response="csv",**kgulf) 
url_alas = e.get_search_url(search_for="mass_concentration_of_chlorophyll_in_sea_water", response="csv",**kalas) 
url_haw = e.get_search_url(search_for="mass_concentration_of_chlorophyll_in_sea_water", response="csv",**khaw) 

#read each url in
dfs_east = pd.read_csv(url_east)
dfs_west = pd.read_csv(url_west)
dfs_gulf = pd.read_csv(url_gulf)
dfs_alas = pd.read_csv(url_alas)
dfs_haw = pd.read_csv(url_haw)

#initialize regional dataframes to loop throug
dataframes_east = {}
dataframes_west = {}
dataframes_gulf = {}
dataframes_ak = {}
dataframes_haw = {}

reg_names = ['dataframes_east','dataframes_west','dataframes_gulf','dataframes_ak','dataframes_haw'] #create a dataframe for each region 
df_names = ['dfs_east','dfs_west','dfs_gulf','dfs_alas','dfs_haw']
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[3], line 2
      1 server = "http://erddap.sensors.ioos.us/erddap" #ioos erddap server
----> 2 e = ERDDAP(server=server, protocol="tabledap")
      3 url_west = e.get_search_url(search_for="mass_concentration_of_chlorophyll_in_sea_water", response="csv",**kwest) #only pull chlorophyll data from within the boundries
      4 url_east = e.get_search_url(search_for="mass_concentration_of_chlorophyll_in_sea_water", response="csv",**keast) 

NameError: name 'ERDDAP' is not defined

Each variable in the list ‘df_names’ holds the ERDDAP information for each project in the region that has chlorophyll data. For example, this is what dfs_haw looks like:

ioos_dfshaw

For each ‘Dataset ID’ in the dfs (for example, in dfs_haw, for the 4 Dataset IDs compiled), the variables time, lat, lon, depth, chlorophyll, and chlorophyll flags are compiled to that project’s dataframe along with metadata. The dataframe is then added to the dictionarys in reg_names

for idx2 in range(len(reg_names)): #for each reg_name
    print('region '+reg_names[idx2]) 
    for idx, row in globals()[df_names[idx2]].iterrows(): #for every project in the df_names
        dataset_id = row['Dataset ID'] #identify the id
        e.dataset_id = dataset_id #match the id with the e dictionary
        e.variables = ['time', 'latitude', 'longitude', 'mass_concentration_of_chlorophyll_in_sea_water',
                       'z','mass_concentration_of_chlorophyll_in_sea_water_qc_agg']  #only append these variables
        try:
            print(f"Loading {dataset_id}...")
            df_data = e.to_pandas(index_col="time (UTC)") #create dataframe from e indexing the time
            globals()[reg_names[idx2]][dataset_id] = df_data #add the dataframe to the reg_name by id
            #add these to dataframe to ensure metadata is recorded
            globals()[reg_names[idx2]][dataset_id]['Dataset ID'] = dataset_id #add a column for dataset id
            globals()[reg_names[idx2]][dataset_id]['source'] = 'IOOS' #add source column
            globals()[reg_names[idx2]][dataset_id]['Institution'] = row['Institution'] #add institude column
            globals()[reg_names[idx2]][dataset_id]['url'] = row['Background Info'] #add link to sensor website
            globals()[reg_names[idx2]][dataset_id]['experiment'] = row['Title'] #add project to dataset

            time.sleep(1)  # small delay to avoid hammering the server
        except Exception as ex:
            print(f"Failed to load {dataset_id}: {ex}") #if no chlorophyll in the dataset, this allows it to skip those

After running the code above, each region should now have a dictionary holding each IOOS project’s dataframe. Here’s an example of what the variable dataframes_haw looks like

ioos_dfhaw

Next, rename the columns in each dataframe to match SeaBASS columns and overall be more managable, and remove all suspect chlorophyll data.

for idx2 in range(len(reg_names)):    
    for dsid, df in globals()[reg_names[idx2]].items():
        df = df.reset_index()
        df = df.rename(columns={'latitude (degrees_north)': 'lat','longitude (degrees_east)': 'lon',
            'mass_concentration_of_chlorophyll_in_sea_water (microg.L-1)': 'chl', #micro gram/L == mg/m^3, so same units as seabass
            'mass_concentration_of_chlorophyll_in_sea_water_qc_agg': 'chl_qc_t','z (m)': 'depth', 'time (UTC)':'datetime'})
        df.datetime=pd.to_datetime(df.datetime.astype(str),format='mixed')
        #remove bad flags
        df = df[df['chl_qc_t'] != 3] #remove all 3s from dataframe i.e suspect points
        df = df[df['chl_qc_t'] != 4] #remove all 4s from dataframe i.e. failed points 

        globals()[reg_names[idx2]][dsid] = df  #update the dict

Since IOOS is a data repository, where extracted chlorophyll data is mixed in with in vivo estimated chlorophyll, we’ll add a data_type_flag to help determind if the sample resolution is high enough to be suspicious.

#create time and depth flags
for idx2 in range(len(reg_names)): #for every region's dataframe
    for dsid, df in globals()[reg_names[idx2]].items(): #for every dataframe in the region   
        df = df.sort_values(by='datetime')
        df['t_flag']=0 #initialize temporal resolution flag, 0=good, 1= bad (less than 1hour),2=flag (time is 0 i.e repeated)
        df['diff_time'] = 0 #column to populate with datatypes as values for organization
        df['d_flag'] = 0 #initialize depth flag, 0=good, 1=bad (less than 5m), 2=flag
        df['decision'] = 2 #ultimate decision flag inidcating whether to keep or toss data point (0=good, 1=bad,2=flag)
        
        df['diff_time']= df['datetime'].diff()      
        df.t_flag=np.where(df['diff_time']< pd.to_timedelta('10 minutes'), 1, df.t_flag) #if delta t is less than 1 hour, flag as bad
        df.t_flag=np.where(df['diff_time']== pd.to_timedelta(0), 2, df.t_flag) #if 0, then just a repeat so not necessarily bad       
        if 'depth' in df.columns: #find average change in depth
            depth_diff =abs(df.depth.diff())#calculate absolute change in depth
            df.loc[df[depth_diff<1].index,'d_flag']=1 #if the change in depth is not large enough
            df.loc[df[depth_diff==0].index,'d_flag']=2 #if the change in depth doesn't move, set as 2, diff_time 
        else:
            avg_z_res = None
            
        df.decision[(df['t_flag'] ==0) & (df['d_flag']==0)] = 0 #if both good, then good
        df.decision[(df['t_flag'] ==0) & (df['d_flag']==1)] = 1 #if everything else is good but the depth is too short, flag as nad
        df.decision[(df['t_flag'] ==0) & (df['d_flag']==2)] = 0 #if everything else is good and depth repeats, good
        df.decision[(df['t_flag'] ==1) & (df['d_flag']==0)] = 1 #IF TIME IS EVER BAD then the whole thing is bad
        df.decision[(df['t_flag'] ==1) & (df['d_flag']==1)] = 1
        df.decision[(df['t_flag'] ==1) & (df['d_flag']==2)] = 1
        df.decision[(df['t_flag'] ==2) & (df['d_flag']==0)] = 0
        df.decision[(df['t_flag'] ==2) & (df['d_flag']==1)] = 1
        df.decision[(df['t_flag'] ==2) & (df['d_flag']==2)] = 1
            
        globals()[reg_names[idx2]][dsid] = df  #update the dict

As mentioned above, these dataframes are very large. IOOS is once of the most comprehensive oceanographic data repositories and some of these project record full water column data every day for the past 30 years. Since this algorithm does not need this temporal resolution, the dataframes can be reduced to only the top 10 meters and averaged to 1 day values. Only then can the dictionary of dataframes be turned into a dataframe without breaking the code.

for idx2 in range(len(reg_names)):    
    for dsid, df in globals()[reg_names[idx2]].items(): 
        df=df[(df['depth']>=-10) & (df['depth']<=10)] #only within top 10 meters
        df['date'] = df['datetime'].dt.date
        df = df.drop(columns='datetime')
        df=df.groupby(['date','Dataset ID','source','Institution','url','experiment']).mean() #groupby date and take average
        globals()[reg_names[idx2]][dsid] = df.reset_index()

#turn the dictionary of dataframes into 1 single dataframe with all values concatinated 
dataframes_east = pd.concat(dataframes_east.values(), ignore_index=True)
dataframes_west = pd.concat(dataframes_west.values(), ignore_index=True)
dataframes_gulf = pd.concat(dataframes_gulf.values(), ignore_index=True)
dataframes_haw = pd.concat(dataframes_haw.values(), ignore_index=True)
dataframes_ak = pd.concat(dataframes_ak.values(), ignore_index=True)
dfs=[dataframes_east,dataframes_west,dataframes_gulf,dataframes_haw,dataframes_ak]
ioos_chl = pd.concat(dfs).reset_index(drop=True) #concatinate them all together 
ioos_chl=ioos_chl[['date', 'lat', 'lon', 'chl', 'depth','source','Dataset ID','Institution','url', 'experiment']]
ioos_chl['date'] = pd.to_datetime(ioos_chl['date'])
ioos_chl = ioos_chl.loc[ioos_chl['date'] > '2000-01-01'] #only want dates post 2000 for this algorithm 
#remove any negative chl values
ioos_chl=ioos_chl[(ioos_chl['chl']>0)]

Due to the high number of projects and locations, many times chlorophyll values extremely large which can lead to some questionable data. For example, Dataset ID gov_usgs_nwis_01304562 has many datapoints in the 200,000 mg/L. And so, remove the top 5% quantile of the data before matching with seabass.

Q1 = ioos_chl['chl'].quantile(0.05) 
Q3 = ioos_chl['chl'].quantile(0.95)
IQR = Q3 - Q1
upper_bound = Q3 + 1.5 * IQR
ioos_chl2 = ioos_chl[ioos_chl['chl'] <= upper_bound] #only keep chl data below the upper bound
ioos_chl2['HPLC'] = 1 #there's no definitive way of distinguishing between hplc, so assume no
ioos_chl2['triplicate'] = 1 #since i'm reducing to shallow, 1 day average, triplicate flag = 1 (no triplicate)

There are 2 projects that still don’t have lat and lon values, so manually look up the location and add to the dataframe

ioos_chl2.loc[ioos_chl2['Dataset ID'] == 'pivers-island-coastal-observa', ['lat', 'lon']] = [34.7181, -76.6707]
ioos_chl2.loc[ioos_chl2['Dataset ID'] == 'mlml_monterey', ['lat', 'lon']] = [36.60513, -121.88935]

Done with IOOS yay!

Plots#

IOOS = pd.read_excel(r'C:\Users\gianna.milton\Documents\Python\Coastal_chl_final\ioos_chl_qc2.xlsx')
year_test=IOOS.copy()
year_test['date'] = pd.to_datetime(year_test['date'])
year_test['year'] = year_test['date'].dt.year
grouped = year_test.groupby(['year']).size().reset_index(name='DataPoints')

# Create bar chart
fig = px.bar(grouped,x='year', y='DataPoints', title='Yearly distribution all data',
             labels={'year': 'Year', 'DataPoints': 'Number of Data Points', 'metadata': 'Metadata'},)
fig.update_xaxes(range=[1999,2027])
fig.update_layout(barmode='stack')  # ensures stacking
fig.show()
_images/65548562be38a35d5f94a9586cf1e595698569c91cb33c8e08f1dc85ec0a9a8f.png
from matplotlib.colors import LogNorm # Important for high-variance data
fig = plt.figure(figsize=(15, 10))
ax = fig.add_subplot(1, 1, 1, projection=ccrs.PlateCarree())
ax.add_feature(cfeature.LAND)
ax.add_feature(cfeature.OCEAN)
ax.add_feature(cfeature.COASTLINE)
ax.add_feature(cfeature.BORDERS)
ax.add_feature(cfeature.STATES)
hb = ax.hexbin(year_test.lon, year_test.lat, gridsize=55, cmap='inferno_r', mincnt=1, transform=ccrs.PlateCarree(),norm=LogNorm()) 
cb = plt.colorbar(hb, ax=ax, orientation='vertical', pad=0.02, shrink=0.8)
cb.set_label('Number of Datapoints', fontsize=14)
gl=ax.gridlines(linewidth=0.2,color='grey',alpha=0.7,linestyle='-', draw_labels=True, x_inline= False,y_inline=False)
gl.xformatter=LONGITUDE_FORMATTER
gl.yformatter=LATITUDE_FORMATTER
gl.top_labels = False    # Disable top labels
gl.right_labels = False  # Disable right labels
ax.set_xlim(min(year_test.lon)-2,max(year_test.lon)+2)
ax.set_ylim(min(year_test.lat)-2,max(year_test.lat)+2)

ax.set_title('Spatial Data Density', fontsize=18, fontweight='bold')

plt.show()
_images/24a72c1e3e486e70538d0be36602630257fc7fdc9d362bdc8f9b58dd30317905.png