Global Ocean Data Explorer: visualize Temperature, Salinity and Currents Velocity

Global Ocean Data Explorer: visualize Temperature, Salinity and Currents Velocity#

For an interactive version of this page please visit the Google Colab:
Open in Google Colab
(To open link in new tab press Ctrl + click)

Alternatively this notebook can be opened with Binder by following the link: Global Ocean Data Explorer: visualize Temperature, Salinity and Currents Velocity

Purpose

The Southern Ocean plays a central role in regulating Earth’s climate by storing heat and carbon, driving global ocean circulation, and modulating sea level through its interactions with the Antarctic Ice Sheet. Understanding these complex ocean-ice-climate interactions is critical for improving climate projections and policy responses. As part of the OCEAN ICE project, which aims to reduce uncertainties in future sea level rise and climate predictions by improving our knowledge of Antarctic and Southern Ocean processes, this Colab notebook serves as an interactive tool for exploring global oceanographic data. This tool is particularly useful for:

• Exploring spatial patterns in the ocean.

• Investigating surface and subsurface currents.

• Accessing oceanographic conditions at specific coordinates.

Data sources

This tool is powered by data from the GLORYS12V1 global ocean reanalysis, developed by the Copernicus Marine Service (CMEMS). It provides a detailed reconstruction of ocean conditions around the world from 1993 to the present.

The dataset combines advanced ocean models with a wide range of real-world observations — including satellite data (like sea level and surface temperature), sea ice measurements and in-situ temperature and salinity profiles collected by ships and floats. These observations are blended to produce the most accurate possible picture of the ocean over time.

Key features of this data:

  • High resolution: ~8 km horizontal resolution and 50 depth levels.

  • Daily and monthly values for temperature, salinity, currents, sea level and sea ice.

  • Based on the NEMO ocean model and driven by atmospheric data from the European Centre for Medium-Range Weather Forecasts (ECMWF).

The data used in this Notebook are hosted into: https://er1.s4oceanice.eu/erddap/griddap/GLORYS12V1_sea_floor_potential_temp.html

Instructions to use this Notebook

Run each code cell by clicking the Play button (▶️) on the left side of each grey code block. This will execute the code in order and allow all features to work properly.

Explaining the code

1. Install and import required libraries and set data source URLs

This section installs all the necessary libraries into the Colab environment. These libraries are required for displaying interactive maps, creating user interface elements like sliders and dropdowns and visualizing oceanographic data with plots.

The following libraries are used in this notebook:

Then it sets the endpoints for downloading real-time, high-resolution ocean data from the GLORYS12V1 dataset hosted on the ERDDAP server.

Note: This cell has to be runned once at the beginning so the rest of the notebook can use these tools and connect to the data source.

# @title
import requests
import xml.etree.ElementTree as ET
import pandas as pd
import matplotlib.pyplot as plt
from io import BytesIO
from traitlets import Unicode
from ipyleaflet import (
    Map,
    Marker,
    basemaps,
    projections,
    WMSLayer,
    Popup,
    Icon,
    AwesomeIcon,
    ImageOverlay,
)
from ipywidgets import (
    IntSlider,
    HBox,
    Layout,
    Output,
    VBox,
    HTML,
    Label,
    Dropdown
)
from IPython.display import display, clear_output

ERRDAP_SLA_URL = 'https://er1.s4oceanice.eu/erddap/wms/GLORYS12V1_sea_floor_potential_temp/request?service=WMS&request=GetCapabilities&version=1.3.0'
BASE_DATA_URL = 'https://er1.s4oceanice.eu/erddap/griddap/GLORYS12V1_sea_floor_potential_temp.csv?'

3. Fetch and parse available layers, dates and depths

This section dynamically prepares the list of:

  • Available variables (temperature, salinity, currents)

  • Time steps (dates)

  • Depths (elevation levels)

from the remote GLORYS12V1 ocean dataset, allowing the rest of the notebook to automatically adapt to the actual contents of the server.

# @title
get_capabilities = requests.get(ERRDAP_SLA_URL)

if get_capabilities.status_code == 200:
    root = ET.fromstring(get_capabilities.content)

    for elem in root.findall(".//{http://www.opengis.net/wms}Dimension"):
        if 'name' in elem.attrib and elem.attrib['name'] == 'time':
            obs_dates = elem.text.strip().split(',')
        elif 'name' in elem.attrib and elem.attrib['name'] == 'elevation':
            obs_elev = elem.text.strip().split(',')
    layers = {}
    layers_to_display = []
    layers_to_call = []
    for name_tag in root.findall(".//{http://www.opengis.net/wms}Name"):
        if name_tag.text and name_tag.text.startswith("GLORYS12V1"):
            if name_tag.text.endswith("thetao"):
              layers[name_tag.text] = 'Temperature'
            elif name_tag.text.endswith("so"):
              layers[name_tag.text] = 'Salinity'
            elif name_tag.text.endswith("uo"):
              layers[name_tag.text] = 'U-Velocity'
            elif name_tag.text.endswith("vo"):
              layers[name_tag.text] = 'V-Velocity'
            layers_to_display.append(name_tag.text)
            # Populate layers_to_call with the part after the last colon
            if ':' in name_tag.text:
                layers_to_call.append(name_tag.text.split(':')[-1])
            else:
                layers_to_call.append(name_tag.text)
else:
    print('ERROR: ', get_capabilities.status_code)

4. Define function to extract layer type and fetch data

Thus section builds the logic engine of this Notebook.

When the user select a date, depth, and variable using the sliders in the interface below and then click on a point in the ocean on the map as requested, the code below reacts to those selections, loads the selected layer onto the map, show the exact ocean data at that location and plot a time series of how that variable behaved over one year at that depth and location.

# @title
def get_layer_type(selected_layer):
    if selected_layer:
        stop_char = ':'
        res = ''
        for c in reversed(selected_layer):
            if c == stop_char:
                break
            res = c + res
        return res
    return None


def update_data():
    global m # Declare m as global to modify the map object
    if coordinates is not None and selected_time is not None and selected_elev is not None and selected_layer is not None:
        layer_type = get_layer_type(selected_layer)
        if layer_type:
            # Convert selected_elev to a number and take the absolute value *only* for the data API call
            try:
                elev_for_data_api = abs(float(selected_elev))
            except ValueError:
                 with data_output:
                    clear_output(wait=True)
                    print(f"Error: Could not convert elevation '{selected_elev}' to a number for the data API call.")
                 return # Exit the function if conversion fails


            # Update the WMS layer on the map using the original selected_elev
            new_wms_url = f'https://er1.s4oceanice.eu/erddap/wms/GLORYS12V1_sea_floor_potential_temp/request?&elevation={selected_elev}&time={selected_time}'
            new_wms_layer_name = selected_layer

            # Find and remove the existing WMS layer based on URL and layers
            existing_wms_layer = None
            # Iterate over a copy of the list to avoid issues during removal
            for layer in list(m.layers):
                if isinstance(layer, WMSLayer):
                    # Check if the URL contains the base WMS service URL
                    if layer.url.startswith('https://er1.s4oceanice.eu/erddap/wms/GLORYS12V1_sea_floor_potential_temp/request?'):
                         existing_wms_layer = layer
                         break

            if existing_wms_layer:
                m.remove_layer(existing_wms_layer)
                # print("Removed existing WMS layer.") # Debugging print


            # Add the new WMS layer
            new_wms_layer = WMSLayer(
                url=new_wms_url,
                layers=f'{new_wms_layer_name},Coastlines,Nations,States',
                format='image/png',
                transparent=True,
                crs={'name': 'EPSG:4326'},
            )
            m.add_layer(new_wms_layer)
            # print("Added new WMS layer.") # Debugging print


            # Fetch and display the data for the selected coordinates using the positive elevation value
            api_url = f'{BASE_DATA_URL}{layer_type}%5B({selected_time}):1:({selected_time})%5D%5B({elev_for_data_api}):1:({elev_for_data_api})%5D%5B({coordinates[0]}):1:({coordinates[0]})%5D%5B({coordinates[1]}):1:({coordinates[1]})%5D'
            graph_url = f"https://er1.s4oceanice.eu/erddap/griddap/GLORYS12V1_sea_floor_potential_temp.csv?{layer_type}%5B(1993-01-16T12:00:00Z):1:(1993-12-16T12:00:00Z)%5D%5B({elev_for_data_api}):1:({elev_for_data_api})%5D%5B({coordinates[0]}):1:({coordinates[0]})%5D%5B({coordinates[1]}):1:({coordinates[1]})%5D"


            with data_output: # Use the dedicated data_output widget
                clear_output(wait=True)
                try:
                    get_data_from_coords = requests.get(api_url)
                    get_graph_data = requests.get(graph_url)


                    if get_data_from_coords.status_code == 200 and get_graph_data.status_code == 200:
                        df = pd.read_csv(BytesIO(get_data_from_coords.content))
                        df_graph = pd.read_csv(BytesIO(get_graph_data.content))
                        print("Data received:")
                        print(df) # Print DataFrame to data_output

                        # Plotting logic
                        # Convert the 'time' column to datetime objects, specifying the format
                        df_graph['time'] = pd.to_datetime(df_graph['time'], format='%Y-%m-%dT%H:%M:%SZ', errors='coerce')

                        # Assuming the first row is header information we want to skip
                        # and the actual data starts from the second row (index 1)
                        # Also assuming the column for the value to plot is the last one
                        data_col_name = df_graph.columns[-1]
                        df_plot_data = df_graph.iloc[1:].copy() # Make a copy to avoid SettingWithCopyWarning

                        # Convert the data column to numeric, coercing errors to NaN
                        df_plot_data[data_col_name] = pd.to_numeric(df_plot_data[data_col_name], errors='coerce')

                        # Drop rows where the data column is NaN
                        df_plot_data.dropna(subset=[data_col_name], inplace=True)

                        # Check if all values in the data column are NaN after dropping rows with NaN
                        with plot_output: # Use the dedicated plot_output widget
                            clear_output(wait=True)
                            if df_plot_data[data_col_name].isnull().all():
                                print("Data not available for the selected location and depth. Please ensure you have clicked on a water area on the map.")
                            else:
                                fig, ax = plt.subplots(figsize=(12, 6)) # Get figure and axes objects
                                ax.plot(df_plot_data['time'], df_plot_data[data_col_name])
                                ax.set_xlabel('Time')

                                # Get the user-friendly layer name from the layers dictionary
                                user_friendly_layer_name = data_col_name # Default to data_col_name if not found
                                for key, value in layers.items():
                                    if key.endswith(f':{data_col_name}'):
                                        user_friendly_layer_name = value
                                        break

                                ax.set_ylabel(user_friendly_layer_name)
                                ax.set_title(f'Time Series of {user_friendly_layer_name} at Selected Location and Depth')
                                ax.grid(True)
                                display(fig) # Display the figure in plot_output
                                plt.close(fig) # Close the figure to prevent it from displaying elsewhere

                    else:
                        with plot_output: # Clear plot output if data fetch fails
                            clear_output(wait=True)
                        print('ERROR fetching data from one or both URLs:')
                        if get_data_from_coords.status_code != 200:
                            print('Data URL Error:', get_data_from_coords.status_code)
                            print(get_data_from_coords.text)
                        if get_graph_data.status_code != 200:
                            print('Graph URL Error:', get_graph_data.status_code)
                            print(get_graph_data.text)

                except requests.exceptions.RequestException as e:
                    with data_output:
                         clear_output(wait=True)
                         print(f"An error occurred during the data request: {e}")
                    with plot_output: # Clear plot output if data fetch fails
                         clear_output(wait=True)

        else:
            with data_output:
                clear_output(wait=True)
            with plot_output: # Clear plot output if layer type is not determined
                clear_output(wait=True)
            print("Could not determine layer type.")
    else:
        # Clear output when inputs are not complete
        with data_output:
            clear_output(wait=True)
        with plot_output: # Clear plot output when inputs are not complete
            clear_output(wait=True)
        print("Please ensure a location is selected on the map, and time, elevation, and layer are available.")

5. Create time, depth and variable selection widgets

This section builds the user interface, including:

  • A slider for date selection.

  • A slider for depth selection.

  • A dropdown for choosing which variable to display.

This part of the code works togheter with the last one above. The difference is that the code above respond to the settings created below by the users.

Note: be sure to drag the selector through the slider and to not just clicking on the bar in order to select the value you want.

# @title
# Time Slider
selected_time = obs_dates[0] if obs_dates else None
time_label = Label(value=str(selected_time))

def updating_time_values(change):
  global selected_time
  # Ensure the index is within bounds
  if obs_dates and 0 <= (change.new)-1 < len(obs_dates):
      selected_time = obs_dates[(change.new)-1]
      time_label.value = str(selected_time)
      update_data() # Call update_data here
  else:
      selected_time = None
      time_label.value = "Invalid Index or No Data"


time_slider = IntSlider(min=1, max=len(obs_dates), readout = False, continuous_update=False) if obs_dates else Label("No time data available")
if obs_dates:
    time_slider.observe(updating_time_values, names='value')
    display(HBox([Label('Date: '), time_slider, time_label]))
else:
    display(time_slider)


# Elevation Slider
selected_elev = obs_elev[0] if obs_elev else None
elev_label = Label(value=str(selected_elev))

def updating_elev_values(change):
  global selected_elev
   # Ensure the index is within bounds
  if obs_elev and 0 <= (change.new)-1 < len(obs_elev):
      selected_elev = obs_elev[(change.new)-1]
      elev_label.value = str(selected_elev)
      update_data() # Call update_data here
  else:
      selected_elev = None
      elev_label.value = "Invalid Index or No Data"


elev_slider = IntSlider(min=1, max=len(obs_elev), readout = False, continuous_update=False) if obs_elev else Label("No depth data available")
if obs_elev:
    elev_slider.observe(updating_elev_values, names='value')
    display(HBox([Label('Depth (m): '), elev_slider, elev_label]))
else:
     display(elev_slider)


# Layer Dropdown
layer_dropdown = Dropdown(
    options=layers.values(),
    description='Select Layer:',
    disabled=False,
)

selected_layer = list(layers.keys())[list(layers.values()).index(layer_dropdown.value)] if layers else None

def update_selected_layer(change):
    global selected_layer
    selected_value = change['new']
    # Find the key corresponding to the selected value
    selected_layer = list(layers.keys())[list(layers.values()).index(selected_value)]
    update_data() # Call update_data here

layer_dropdown.observe(update_selected_layer, names='value')

# Display the dropdown menu
display(VBox([Label('Choose a layer to display on the map:'), layer_dropdown]))

6. Generate interactive map and handle click events

This section creates the interactive map interface. The user can click anywhere on the map to select a location. A marker appears, and the coordinates are printed. The system will then:

  • Update the map with the correct ocean data layer.

  • Fetch and show the values (temperature, salinity, etc.).

  • Plot a time series for that point and depth.

# @title
# Map Generator
m = Map(
    center=(0, 0),
    zoom=1,
    basemap = basemaps.Esri,
    layout=Layout(width='80%', height='500px',  margin='10px 0 0 0'),
    crs = projections.EPSG4326,
)

# Output widget to display coordinates
output = Output()
display(output)

# Output widget specifically for the DataFrame and plot
data_output = Output()
plot_output = Output(layout=Layout(width='80%', height='500px', margin='10px 0 0 0'))


# Variable to store the current marker and coordinates
current_marker = None
coordinates = None

def handle_map_click(**kwargs):
    global current_marker
    global coordinates
    if kwargs.get('type') == 'click':
        coordinates = kwargs.get('coordinates')
        with output:
            clear_output()
            print(f"Selected coordinates: {coordinates}")

        # Remove previous marker if it exists
        if current_marker is not None:
            m.remove_layer(current_marker)

        # Add a new marker at the clicked location
        current_marker = Marker(location=coordinates)
        m.add_layer(current_marker)
        update_data() # Call update_data here after coordinates are set


m.on_interaction(handle_map_click)

# This initial layer will be replaced when update_data is called by interacting with widgets/map
if layers and obs_dates and obs_elev:
    initial_wms = WMSLayer(
        url=f'https://er1.s4oceanice.eu/erddap/wms/GLORYS12V1_sea_floor_potential_temp/request?&elevation={selected_elev}&time={selected_time}',
        layers=f'{selected_layer},Coastlines,Nations,States',
        format='image/png',
        transparent=True,
        crs={'name': 'EPSG:4326'},
    )
    m.add(initial_wms)

# Arrange the HBox (map and plot) and the data output vertically in a VBox
#VBox([m, data_output, plot_output])

display(m)
display(data_output)
  1. Display plot output area

This section draws a graph in the notebook, so the users can see how the selected ocean variable changes over time at the clicked location.

# @title
display(plot_output)