Biologically Informed Climate Insights Through Animal-Borne Sensors#
As climate change reshapes ecosystems, more precise and ecologically relevant measurements are needed. Traditional climate data are often limited by static, coarse, and sparse sampling, with indirect links to ecological impacts. The MEOP consortium (MEOP stands for “Marine Mammals Exploring the Oceans Pole to Pole”) brings together several national programmes to produce a comprehensive quality-controlled database of oceanographic data obtained in Polar Regions from instrumented marine mammals. Animal-borne sensors offer fine-scale, biologically tuned measurements of climatic conditions, enhancing ecological and climate forecasting. Millions of meteorological observations from over a thousand species have already been collected using these sensors. This notebok explores how this growing dataset can bridge gaps in biodiversity and climate science, particularly in terrestrial environments, positioning tagged animals as key environmental sentinels and data providers for understanding changing ecosystems.
The tool uses the following product:
OCEAN:ICE’s ERDDAP’s dataset (https://er1.s4oceanice.eu/erddap/tabledap/MEOP_Animal-borne_profiles.html)
Data gathering#
The following code cell will download a list of platforms with data satisfying the time range chosen for this example (2019-12-01 to 2020-03-10) and with latitude less or equal than -55. This list of platforms is then displayed below.
Show code cell source
# @title
max_latitude = -55
all_platforms_south_url = f'https://er1.s4oceanice.eu/erddap/tabledap/MEOP_Animal-borne_profiles.csv?platform_code&latitude%3C={max_latitude}&time%3E={start_date}&time%3C={end_date}&distinct()'
resp = requests.get(all_platforms_south_url)
df = pd.read_csv(BytesIO(resp.content), header=0, encoding='utf-8', dtype={'platform_code': str})
df = df.iloc[1:]
display(df)
platform_code | |
---|---|
1 | 85848 |
2 | 86562 |
3 | 86580 |
4 | 87888 |
5 | 87900 |
... | ... |
63 | wd13-420BAT-16 |
64 | wd13-765-18 |
65 | wd13-880-18 |
66 | wd13-910-18 |
67 | wd13-911-18 |
67 rows × 1 columns
In the next code cell the CTD data for the selected platforms will be gathered and displayed.
Show code cell source
# @title
all_data_traj = []
all_data_depth = []
for platform in df['platform_code']:
platform_data_url = f'https://er1.s4oceanice.eu/erddap/tabledap/MEOP_Animal-borne_profiles.csv?platform_code%2Ctime%2CPRES%2CPSAL%2Clatitude%2Clongitude%2CTEMP&platform_code=%22{platform}%22&time%3E={start_date}&time%3C={end_date}'
resp = requests.get(platform_data_url)
if "Not Found: Your query produced no matching results. (nRows = 0)" in resp.text:
continue
df_platform = pd.read_csv(BytesIO(resp.content), encoding='utf-8', low_memory=False, dtype=str)
df_platform = df_platform.iloc[1:]
df_platform_filtered = df_platform.dropna(subset=['TEMP'])
df_platform_temp = pd.DataFrame()
df_platform_temp['PSAL'] = pd.to_numeric(df_platform_filtered['PSAL'], errors='coerce')
df_platform_temp['PRES'] = pd.to_numeric(df_platform_filtered['PRES'], errors='coerce')
df_platform_temp['TEMP'] = pd.to_numeric(df_platform_filtered['TEMP'], errors='coerce')
df_platform_temp['latitude'] = pd.to_numeric(df_platform_filtered['latitude'], errors='coerce')
df_platform_temp['longitude'] = pd.to_numeric(df_platform_filtered['longitude'], errors='coerce')
df_platform_temp['time'] = pd.to_datetime(df_platform_filtered['time'], format='%Y-%m-%dT%H:%M:%SZ', errors='coerce')
df_platform_temp['platform_code'] = df_platform_filtered['platform_code']
df_platform_filtered = df_platform_temp
df_daily_avg = df_platform_filtered.groupby([df_platform_filtered['platform_code'], df_platform_filtered['time'].dt.date]).agg({
'TEMP': 'mean',
'PSAL': 'mean',
'latitude': 'mean',
'longitude': 'mean'
}).reset_index()
all_data_traj.append(df_daily_avg)
all_data_depth.append(df_platform_filtered)
if all_data_traj:
data_df = pd.concat(all_data_traj, ignore_index=True)
display(data_df)
else:
print("No valid data found across platforms.")
platform_code | time | TEMP | PSAL | latitude | longitude | |
---|---|---|---|---|---|---|
0 | 85848 | 2019-12-01 | 1.097900 | 34.331646 | -54.651689 | 91.141638 |
1 | 85848 | 2019-12-02 | 1.078399 | 34.341045 | -54.538264 | 91.280233 |
2 | 85848 | 2019-12-03 | 1.181849 | 34.219489 | -54.339163 | 91.447827 |
3 | 85848 | 2019-12-04 | 1.353664 | 34.336993 | -54.325048 | 91.865526 |
4 | 85848 | 2019-12-05 | 1.668006 | 34.324731 | -54.650168 | 91.985747 |
... | ... | ... | ... | ... | ... | ... |
2193 | wd13-911-18 | 2019-12-28 | -1.673009 | 34.289086 | -66.050900 | 142.225500 |
2194 | wd13-911-18 | 2019-12-29 | -1.590236 | 34.284033 | -66.001992 | 142.056821 |
2195 | wd13-911-18 | 2019-12-30 | -1.599414 | 34.272934 | -66.004350 | 141.929450 |
2196 | wd13-911-18 | 2019-12-31 | -1.720421 | 34.239960 | -65.997523 | 141.830023 |
2197 | wd13-911-18 | 2020-01-01 | -1.722475 | 34.183049 | -66.084431 | 142.192408 |
2198 rows × 6 columns
Trajectory plot#
The next code cell will visualize the trajectories of platforms over time, plotting the Sea Water Temperature for each day. The map is centered on the Southern Hemisphere, and data points will be connected with lines to show the movement of each platform. The color of the lines represents the value for the temperature, with a color bar provided for reference. The animation updates daily, highlighting how the platforms’ trajectories evolve over time.
The data is first filtered to include only valid temperature readings, and then numeric fields such as temperature, salinity, pressure, latitude, and longitude are converted to appropriate types. For each platform and time, only the data from the shallowest depth is selected, ensuring that the temperature, salinity, latitude, and longitude values closest to the surface are used. This selection provides a meaningful surface-level representation. The grouped data is used to plot the daily average trajectory, improving efficiency while maintaining the accuracy of the visualized trends.
Show code cell source
# @title
%matplotlib agg
params_dict = {'Practical Salinity': 'PSAL', 'Sea Water Temperature': 'TEMP'}
param = params_dict[selected_param]
data_df['TEMP'] = pd.to_numeric(data_df['TEMP'], errors='coerce')
data_df['PSAL'] = pd.to_numeric(data_df['PSAL'], errors='coerce')
data_df['time'] = pd.to_datetime(data_df['time'], format='%Y-%m-%dT%H:%M:%SZ')
data_df['latitude'] = data_df['latitude'].astype(float)
data_df['longitude'] = data_df['longitude'].astype(float)
norm = plt.Normalize(vmin=data_df[param].min(), vmax=data_df[param].max())
cmap = plt.get_cmap('coolwarm')
data_df['color'] = data_df[param].apply(lambda x: cmap(norm(x)))
fig, ax = plt.subplots(figsize=(8, 6), subplot_kw={'projection': ccrs.SouthPolarStereo()})
ax.add_feature(cfeature.LAND, edgecolor='black')
ax.add_feature(cfeature.OCEAN)
ax.add_feature(cfeature.COASTLINE)
ax.add_feature(cfeature.BORDERS, linestyle=':')
ax.set_extent([-180, 180, -90, -45], crs=ccrs.PlateCarree())
sm = ScalarMappable(cmap=cmap, norm=norm)
sm.set_array([])
cbar = fig.colorbar(sm, ax=ax, orientation='vertical', fraction=0.025, pad=0.1)
if param == 'TEMP':
cbar.set_label('Sea Water Temperature (°C)', fontsize=12)
elif param == 'PSAL':
cbar.set_label('Practical Salinity (psu)', fontsize=12)
unique_days = sorted(data_df['time'].dt.date.unique())
status_text = ax.text(-170, -50, '', fontsize=12, color='black', transform=ccrs.PlateCarree())
plt.close()
def update_plot(day):
day_check = day.strftime("%Y-%m-%dT00:00:00Z")
if day_check == end_date or day >= unique_days[-2]:
print('\r'+' '*50, end='', flush=True)
else:
print(f'\rUpdating for day: {day}', end='', flush=True)
ax.clear()
ax.add_feature(cfeature.LAND, edgecolor='black')
ax.add_feature(cfeature.OCEAN)
ax.add_feature(cfeature.COASTLINE)
ax.add_feature(cfeature.BORDERS, linestyle=':')
ax.set_extent([-180, 180, -90, -45], crs=ccrs.PlateCarree())
current_data = data_df[data_df['time'].dt.date <= day]
status_text.set_text(f'Current Day: {day}, Data Points: {len(current_data)}')
for platform in current_data['platform_code'].unique():
platform_data = current_data[current_data['platform_code'] == platform]
for i in range(len(platform_data) - 1):
ax.plot([platform_data.iloc[i]['longitude'], platform_data.iloc[i + 1]['longitude']],
[platform_data.iloc[i]['latitude'], platform_data.iloc[i + 1]['latitude']],
color=platform_data.iloc[i]['color'], marker='.', linewidth=2.5, transform=ccrs.PlateCarree(), alpha=0.7)
ax.set_title(f'Trajectories of Platforms - Day: {day}')
plt.draw()
ani = FuncAnimation(fig, update_plot, frames=unique_days, interval=250, repeat=True)
HTML(ani.to_html5_video())
Pressure plot#
The next code cell will display the DataFrame containing the data used for the final plot, which analyzes the Sea Water Temperature based on depth.
Show code cell source
# @title
if all_data_depth:
data_df_pres_concat = pd.concat(all_data_depth, ignore_index=True)
display(data_df_pres_concat)
else:
print("No valid data found across platforms.")
PSAL | PRES | TEMP | latitude | longitude | time | platform_code | |
---|---|---|---|---|---|---|---|
0 | 34.184635 | 1.0 | 1.857105 | -54.721316 | 91.0963 | 2019-12-01 04:33:57 | 85848 |
1 | 34.184635 | 2.0 | 1.857105 | -54.721316 | 91.0963 | 2019-12-01 04:33:57 | 85848 |
2 | 34.184635 | 3.0 | 1.857105 | -54.721316 | 91.0963 | 2019-12-01 04:33:57 | 85848 |
3 | 34.181420 | 4.0 | 1.846964 | -54.721316 | 91.0963 | 2019-12-01 04:33:57 | 85848 |
4 | 34.175495 | 5.0 | 1.828237 | -54.721316 | 91.0963 | 2019-12-01 04:33:57 | 85848 |
... | ... | ... | ... | ... | ... | ... | ... |
735599 | 34.493000 | 126.0 | -1.781000 | -66.026900 | 142.2741 | 2020-01-01 22:10:00 | wd13-911-18 |
735600 | 34.503000 | 150.0 | -1.731000 | -66.026900 | 142.2741 | 2020-01-01 22:10:00 | wd13-911-18 |
735601 | 34.520000 | 170.0 | -1.840000 | -66.026900 | 142.2741 | 2020-01-01 22:10:00 | wd13-911-18 |
735602 | 34.528000 | 200.0 | -1.829000 | -66.026900 | 142.2741 | 2020-01-01 22:10:00 | wd13-911-18 |
735603 | 34.530000 | 210.0 | -1.828000 | -66.026900 | 142.2741 | 2020-01-01 22:10:00 | wd13-911-18 |
735604 rows × 7 columns
The last code cell will visualize platform data at different pressure levels over time. The data points for each pressure level (binned in increments of 10 dbar) are plotted based on the Sea Water Temperature. The color of the data points represents the value of the selected parameter, with a color bar provided for reference.
This plot complements the previous trajectory plot, allowing users to visualize another fundamental component of Conductivity-Temperature-Depth (CTD) profiles. Averaging is applied to the tempearture. A second depth scale on the left visualizes the pressure levels, and the animation updates the map by showing data points for different pressure bins, enabling users to observe how conditions change with depth.
Show code cell source
# @title
pres_bin_size = 10
data_df_pres = data_df_pres_concat.copy()
norm = plt.Normalize(vmin=data_df[param].min(), vmax=data_df[param].max())
data_df_pres['PRES'] = data_df_pres['PRES'].astype(float)
data_df_pres['TEMP'] = data_df_pres['TEMP'].astype(float)
data_df_pres['PSAL'] = data_df_pres['PSAL'].astype(float)
data_df_pres['time'] = pd.to_datetime(data_df_pres['time'])
depth_bins = np.arange(0, data_df_pres['PRES'].max() + pres_bin_size, pres_bin_size)
data_df_pres['PRES'] = pd.cut(data_df_pres['PRES'], bins=depth_bins, labels=depth_bins[:-1], right=False)
data_df_pres['PRES'] = data_df_pres['PRES'].astype(float)
numeric_columns = ['PSAL', 'TEMP']
grouped_df = data_df_pres.groupby(['time', 'PRES']).agg({
'latitude': 'first',
'longitude': 'first',
'PSAL': 'mean',
'TEMP': 'mean'
}).reset_index()
param = params_dict[selected_param]
cmap = plt.get_cmap('coolwarm')
data_df_pres['color'] = data_df_pres[param].apply(lambda x: cmap(norm(x)))
fig, ax = plt.subplots(figsize=(8, 6), subplot_kw={'projection': ccrs.SouthPolarStereo()})
ax.add_feature(cfeature.LAND, edgecolor='black')
ax.add_feature(cfeature.OCEAN)
ax.add_feature(cfeature.COASTLINE)
ax.add_feature(cfeature.BORDERS, linestyle=':')
ax.set_extent([-180, 180, -90, -45], crs=ccrs.PlateCarree())
sm = ScalarMappable(cmap=cmap, norm=norm)
sm.set_array([])
cbar = fig.colorbar(sm, ax=ax, orientation='vertical', fraction=0.025, pad=0.1)
if param == 'TEMP':
cbar.set_label('Sea Water Temperature (°C)', fontsize=12)
elif param == 'PSAL':
cbar.set_label('Practical Salinity (psu)', fontsize=12)
unique_pres = sorted(data_df_pres['PRES'].unique())
ax_depth = fig.add_axes([0.1, 0.1, 0.03, 0.8])
ax_depth.set_ylim(0, data_df_pres['PRES'].max())
ax_depth.invert_yaxis()
ax_depth.set_xticks([])
ax_depth.set_yticks(np.arange(0, data_df_pres['PRES'].max() + 100, 100))
ax_depth.set_ylabel('Pressure (dbar)', fontsize=12)
ax_depth.plot([0.5, 0.5], [0, data_df_pres['PRES'].max()], color='black', lw=2)
highlight_bin = ax_depth.scatter([], [], color='black', s=100, zorder=5)
def update_plot(pres):
if pres >= sorted(list(data_df_pres['PRES']))[-1] or pres <= sorted(list(data_df_pres['PRES']))[0]:
print('\r' + ' ' * 50, end='', flush=True)
else:
print(f'\rUpdating for pressure: {int(pres)}/{int(unique_pres[-1])}', end='', flush=True)
ax.clear()
ax.add_feature(cfeature.LAND, edgecolor='black')
ax.add_feature(cfeature.OCEAN)
ax.add_feature(cfeature.COASTLINE)
ax.add_feature(cfeature.BORDERS, linestyle=':')
ax.set_extent([-180, 180, -90, -45], crs=ccrs.PlateCarree())
current_data = data_df_pres[data_df_pres['PRES'] == pres]
ax.scatter(current_data['longitude'], current_data['latitude'],
c=current_data['color'], transform=ccrs.PlateCarree(), alpha=0.7, s=10)
ax.set_title(f'Pressure Level: {int(pres)} dbar')
highlight_bin.set_offsets([[0.5, pres]])
plt.draw()
ani = FuncAnimation(fig, update_plot, frames=unique_pres, interval=500, repeat=True)
HTML(ani.to_html5_video())
Additional resources#
The Python libraries that have been used in this notebook are: