SCShores: Time-Series of Shorelines from Spanish Sandy Beaches¶
Category: Coasts & Oceanography · Size: 9.3 MB · Format: GeoJSON License: CC-BY-4.0 · Zenodo record · Data sheet on the CSDH
1,721 shorelines over 5 years derived from a citizen monitoring programme on 5 Spanish sandy beaches (Atlantic and Mediterranean), in GeoJSON format.
The data is mounted read-only at /srv/data/scshores-shorelines/.
Save anything you produce in your personal folder (~/).
What's in the dataset¶
The dataset is a single GeoJSON file. Each feature is one shoreline: the waterline of a beach traced from a single geotagged photo on a given date, stored as a MultiPoint (~100 ordered points running alongshore). Let's load it with GeoPandas and look at the columns, geometry and coverage.
import geopandas as gpd
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from pathlib import Path
DATA = Path('/srv/data/scshores-shorelines')
gdf = gpd.read_file(DATA / 'SCShores_v1.0.0.geojson')
print(f'features (shorelines): {len(gdf)}')
print(f'geometry type : {gdf.geom_type.unique().tolist()}')
print(f'CRS : {gdf.crs}')
print(f'date range : {gdf.date.min().date()} -> {gdf.date.max().date()}')
print(f'columns : {list(gdf.columns)}')
gdf.drop(columns='geometry').head()
ERROR 1: PROJ: proj_create_from_database: Open of /opt/tljh/user/share/proj failed
features (shorelines): 1721 geometry type : ['MultiPoint'] CRS : EPSG:4326 date range : 2018-04-04 -> 2023-01-05 columns : ['site', 'timestampQuality', 'date', 'imageSource', 'elevation_m', 'timezone', 'verticalDatum', 'geometry']
| site | timestampQuality | date | imageSource | elevation_m | timezone | verticalDatum | |
|---|---|---|---|---|---|---|---|
| 0 | agrelo | 1.0 | 2019-01-23 16:18:09 | 2.211 | UTC | NMMA | |
| 1 | agrelo | 1.0 | 2019-01-25 15:41:00 | 0.708 | UTC | NMMA | |
| 2 | agrelo | 1.0 | 2019-01-26 16:32:00 | 0.749 | UTC | NMMA | |
| 3 | agrelo | 1.0 | 2019-01-26 16:36:37 | 0.798 | UTC | NMMA | |
| 4 | agrelo | 1.0 | 2019-01-28 11:15:00 | 0.944 | UTC | NMMA |
The five beaches¶
The programme monitors 5 Spanish sandy beaches. Longitude tells us the coast: the three western beaches (negative longitude) face the Atlantic, the two eastern ones (positive longitude) sit on the nearly tideless Mediterranean. The elevation_m column is the tide level (relative to mean sea level) at the moment the photo was taken, and imageSource shows the crowdsourcing channel.
rows = []
for site, g in gdf.groupby('site'):
lon = g.geometry.apply(lambda mp: mp.centroid.x).mean()
lat = g.geometry.apply(lambda mp: mp.centroid.y).mean()
rows.append({
'site': site,
'coast': 'Atlantic' if lon < 0 else 'Mediterranean',
'n_shorelines': len(g),
'first': g.date.min().date(),
'last': g.date.max().date(),
'lon': round(lon, 3), 'lat': round(lat, 3),
'tide_range_m': round(g.elevation_m.max() - g.elevation_m.min(), 2),
})
summary = pd.DataFrame(rows).sort_values('n_shorelines', ascending=False).reset_index(drop=True)
print('Photo sources:', gdf.imageSource.value_counts().to_dict())
summary
Photo sources: {'Email': 641, 'CoastSnapApp': 404, 'Instagram': 299, 'Twitter': 227, 'Facebook': 150}
| site | coast | n_shorelines | first | last | lon | lat | tide_range_m | |
|---|---|---|---|---|---|---|---|---|
| 0 | cadiz | Atlantic | 950 | 2020-10-16 | 2023-01-05 | -6.288 | 36.521 | 3.46 |
| 1 | cies | Atlantic | 430 | 2018-04-04 | 2022-12-09 | -8.901 | 42.226 | 3.72 |
| 2 | agrelo | Atlantic | 244 | 2019-01-23 | 2022-12-26 | -8.771 | 42.333 | 3.65 |
| 3 | samarador | Mediterranean | 57 | 2022-07-21 | 2022-12-16 | 3.186 | 39.350 | 0.29 |
| 4 | arenaldentem | Mediterranean | 40 | 2022-08-29 | 2022-12-21 | 2.975 | 39.352 | 0.20 |
Where the beaches are¶
A quick offline locator map (no basemap tiles) of the five sites across Spain, coloured by coast. Notice how far apart the Atlantic (NW Galicia + SW Cadiz) and Mediterranean (Balearic) sites are.
fig, ax = plt.subplots(figsize=(8, 6))
colors = {'Atlantic': '#1f77b4', 'Mediterranean': '#d62728'}
for coast, grp in summary.groupby('coast'):
ax.scatter(grp.lon, grp.lat, s=120, color=colors[coast], label=coast, zorder=3, edgecolor='k')
for _, r in summary.iterrows():
ax.annotate(f"{r['site']}\n({r.n_shorelines})", (r.lon, r.lat),
textcoords='offset points', xytext=(8, 4), fontsize=9)
ax.set_xlabel('longitude'); ax.set_ylabel('latitude')
ax.set_aspect(1 / np.cos(np.deg2rad(39))) # keep distances honest at ~39N
ax.set_title('SCShores monitoring sites (label = site, n shorelines)')
ax.legend(); ax.grid(alpha=0.3)
plt.tight_layout(); plt.show()
Shoreline change through time at one beach¶
Cadiz has the most data (950 shorelines over ~2.5 years). Plotting every traced waterline, coloured by date, reveals the envelope the shoreline sweeps as it advances (accretes) and retreats (erodes). We work in the local UTM projection so axes are in metres.
SITE = 'cadiz'
beach = gdf[gdf.site == SITE].sort_values('date').copy()
beach_utm = beach.to_crs(beach.estimate_utm_crs())
# explode MultiPoints into one row per point, carrying the shoreline date
pts = beach_utm.explode(index_parts=False)
px = pts.geometry.x.values
py = pts.geometry.y.values
pt_date = mdates.date2num(pts.date.values)
fig, ax = plt.subplots(figsize=(9, 6))
sc = ax.scatter(px - px.min(), py - py.min(), c=pt_date, cmap='viridis', s=2)
ax.set_aspect('equal')
ax.set_xlabel('easting (m, local)'); ax.set_ylabel('northing (m, local)')
ax.set_title(f'{SITE.capitalize()}: {len(beach)} shorelines coloured by date')
cbar = fig.colorbar(sc, ax=ax)
cbar.ax.yaxis.set_major_locator(mdates.AutoDateLocator())
cbar.ax.yaxis.set_major_formatter(mdates.DateFormatter('%Y-%m'))
plt.tight_layout(); plt.show()
Turning shorelines into a movement time-series¶
To quantify movement we need a single number per shoreline. A robust, transect-free approach:
- Pool all points of the beach and run a 2-D PCA. The major axis is alongshore; the minor axis is cross-shore (the direction the beach grows/shrinks).
- For each shoreline, project its centroid onto the cross-shore axis -> one cross-shore position in metres.
The absolute sign of the axis is arbitrary, so we centre the series on its own mean: positive = further one way, negative = the other.
def cross_shore_series(site):
"""Return a DataFrame (date, elevation_m, cross_m) of shoreline cross-shore position."""
g = gdf[gdf.site == site].sort_values('date')
gm = g.to_crs(g.estimate_utm_crs())
allpts = gm.explode(index_parts=False)
X = np.column_stack([allpts.geometry.x, allpts.geometry.y])
Xc = X - X.mean(axis=0)
_, vecs = np.linalg.eigh(np.cov(Xc.T))
cross_axis = vecs[:, 0] # minor axis = cross-shore
cent = np.column_stack([gm.geometry.centroid.x, gm.geometry.centroid.y])
cross = (cent - X.mean(axis=0)) @ cross_axis
out = pd.DataFrame({'date': gm.date.values,
'elevation_m': gm.elevation_m.values,
'cross_m': cross - cross.mean()})
return out.sort_values('date').reset_index(drop=True)
ts = cross_shore_series(SITE)
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(ts.date, ts.cross_m, marker='.', lw=0.6, ms=3, color='#1f77b4')
ax.axhline(0, color='grey', lw=0.8)
ax.set_ylabel('cross-shore position (m)')
ax.set_title(f'{SITE.capitalize()}: shoreline position over time '
f'(range {ts.cross_m.max()-ts.cross_m.min():.0f} m, std {ts.cross_m.std():.0f} m)')
ax.grid(alpha=0.3)
plt.tight_layout(); plt.show()
What drives the day-to-day scatter? The tide.¶
On a macro-tidal Atlantic beach the waterline traced from a photo depends heavily on the tide at that instant: a high tide floods the beach and pushes the waterline landward. So cross-shore position should correlate tightly with elevation_m.
from scipy.stats import pearsonr
r, p = pearsonr(ts.elevation_m, ts.cross_m)
fig, ax = plt.subplots(figsize=(6, 5))
ax.scatter(ts.elevation_m, ts.cross_m, s=10, alpha=0.5, color='#1f77b4')
m, b = np.polyfit(ts.elevation_m, ts.cross_m, 1)
xs = np.array([ts.elevation_m.min(), ts.elevation_m.max()])
ax.plot(xs, m * xs + b, color='k', lw=1.5)
ax.set_xlabel('tide elevation at photo time (m)')
ax.set_ylabel('cross-shore position (m)')
ax.set_title(f'{SITE.capitalize()}: tide vs shoreline position (r = {r:.2f})')
ax.grid(alpha=0.3)
plt.tight_layout(); plt.show()
print(f'Pearson r = {r:.2f} (p = {p:.1e})')
Pearson r = -0.95 (p = 0.0e+00)
Atlantic vs Mediterranean: the tidal signature¶
Now the payoff. If the tide really controls the daily waterline, then the near-tideless Mediterranean beaches should show a weak tide-shoreline correlation and a tiny tidal range, while the Atlantic beaches show a strong one. We compute the same metrics for every site.
table = []
for _, r in summary.iterrows():
s = cross_shore_series(r['site'])
corr = abs(pearsonr(s.elevation_m, s.cross_m)[0]) if s.elevation_m.std() > 1e-6 else np.nan
table.append({
'site': r['site'], 'coast': r['coast'], 'n': r['n_shorelines'],
'tide_range_m': r['tide_range_m'],
'shoreline_std_m': round(s.cross_m.std(), 1),
'abs_corr_tide': round(corr, 2),
})
result = pd.DataFrame(table).sort_values(['coast', 'n'], ascending=[True, False]).reset_index(drop=True)
result
| site | coast | n | tide_range_m | shoreline_std_m | abs_corr_tide | |
|---|---|---|---|---|---|---|
| 0 | cadiz | Atlantic | 950 | 3.46 | 30.3 | 0.95 |
| 1 | cies | Atlantic | 430 | 3.72 | 6.6 | 0.94 |
| 2 | agrelo | Atlantic | 244 | 3.65 | 8.1 | 0.73 |
| 3 | samarador | Mediterranean | 57 | 0.29 | 4.2 | 0.01 |
| 4 | arenaldentem | Mediterranean | 40 | 0.20 | 3.6 | 0.43 |
fig, axes = plt.subplots(1, 2, figsize=(11, 4.5))
for ax, site in zip(axes, ['cadiz', 'samarador']):
s = cross_shore_series(site)
coast = summary.loc[summary.site == site, 'coast'].iloc[0]
rr = pearsonr(s.elevation_m, s.cross_m)[0]
ax.scatter(s.elevation_m, s.cross_m, s=12, alpha=0.6, color=colors[coast])
ax.set_xlabel('tide elevation (m)'); ax.set_ylabel('cross-shore position (m)')
ax.set_title(f'{site.capitalize()} ({coast})\ntide range {s.elevation_m.max()-s.elevation_m.min():.2f} m, r = {rr:.2f}')
ax.grid(alpha=0.3)
fig.suptitle('Macro-tidal Atlantic vs micro-tidal Mediterranean', y=1.03)
plt.tight_layout(); plt.show()
Takeaways¶
- Each feature is a citizen-traced waterline; stacking them by date maps the zone the shoreline sweeps.
- A PCA cross-shore projection turns 1,721 point-clouds into interpretable metre-scale position time-series without hand-drawn transects.
- The Atlantic beaches (Cadiz, Cies, Agrelo) have a ~3.5 m tidal range and a strong tide-shoreline correlation (|r| up to ~0.95): much of the daily waterline movement is simply the tide.
- The Mediterranean beach (Samarador) has a ~0.3 m range and essentially no tide correlation, so its movement reflects waves/storms and real accretion-erosion.
- Next steps: filter to shorelines near a fixed
elevation_mto remove the tidal signal and isolate genuine erosion trends; look for seasonal cycles; or measure movement along proper cross-shore transects.
Your turn¶
This is just the starting point. Some ideas:
- Check the dataset challenge on its CSDH data sheet.
- Work on a copy: right-click the file → Duplicate (or Save Notebook As…). Your changes only live in your Hub space — they're never pushed to GitHub.
- Edited this notebook and want the original back? Use the Restore cell
below (or the
restore.ipynbnotebook). - Questions and results: on the platform forum.
Attribution: data from SCShores: Time-Series of Shorelines from Spanish Sandy Beaches, license CC-BY-4.0. Notebook from the Citizen Science Data Hub (CSDH) — Fundación Ibercivis.
# ⚠️ RESTORE: this DISCARDS YOUR CHANGES to this notebook and resets it to the original.
# 1. Uncomment the line below (remove the #) 2. Run this cell
# 3. Then: menu File → Reload Notebook from Disk
# !git -C ~/citizen-science-data fetch -q origin && git -C ~/citizen-science-data checkout origin/main -- scshores-shorelines.ipynb && echo "Restored. Now: File → Reload Notebook from Disk"