import geopandas as gpd
import folium
from datetime import date
from requests import Request
from requests.sessions import Session
We saw in my previous post how to get tidal data for Canadian stations and the coverage for those stations. Now, I want to step further by plotting stations and their provided series interactively. While my previous work was in R to leverage its declarative API syntax, I’m switching to Python since this will ultimately need to integrate in the floatingtrails
codebase.
Before we hit this API, we need to construct a base URL. In R’s httr2
library, this looked like
request <- "https://api.iwls-sine.azure.cloud-nuage.dfo-mpo.gc.ca" |>
request() |>
req_url_path_append("api/v1") |>
req_user_agent("floatingtrails.dev") |>
req_headers(accept = "application/json") |>
req_url_path_append("stations") |>
req_url_query(!!!list(
dateStart = "2025-08-01T00:00:00Z",
dateEnd = "2025-08-09T23:59:59Z"
))
In Python’s requests
library, we can’t quite do this. Everything needs to be defined up front, though we can prepare it before sending. To get the station positions, names, and series, we need to fetch data from the stations
path.
= (
base_request
Request(= "GET",
method = "https://api.iwls-sine.azure.cloud-nuage.dfo-mpo.gc.ca/api/v1/stations",
url = {
headers "user-agent": "floatingtrails.dev",
"accept": "application/json"
},= {
params "dateStart": date.today().isoformat() + "T00:00:00Z",
}
).prepare() )
Now, we can send this request using a Session
, which will return a Response
object that we can parse as JSON.
with Session() as session:
= session.send(base_request)
response = response.json()
station_list session.close()
GeoPandas will read the JSON and, bada bing bada boom, we have a GeoDataFrame.
= gpd.GeoDataFrame(station_list) stations
GeoPandas’ API doesn’t give an elegant way to define the geometry, so we’ll add that in a separate step.
= (
stations_gpd
stations
.set_geometry(
gpd.points_from_xy(stations.longitude, stations.latitude),= "EPSG:3348" # NAD83 / Statistics Canada
crs
)"latitude", "longitude"], axis = 1)
.drop([ )
For floatingtrails, we’re only interested in water level and water current predictions so we’ll filter to those in the timeSeries
column.
"series_codes"] = stations_gpd.apply(
stations_gpd[lambda row: ",".join([
'code'] for i in row['timeSeries'] if i['code'] in [
i['wlp', # water-level predictions
'wlp-hilo', # high and low tide predictions
'wlf', # water-level forecasts
'wlf-spine', # water-level forecasts along the St Lawrence River
'wcd1', # current direction predictions
'wcs1' # current speed predictions
= 1
]]), axis )
I really hate how hard it is to chain together Pandas expressions. I read about imperative vs declarative programming recently and really see how Python encourage imperative operations at the expense of readability and elegance.
We’ll store this dataset as a GeoJSON for y’all to browse…
= stations_gpd.to_json()
gj with open("canadian_tidal_stations.geojson", "w") as f:
f.write(gj)
… and then read it back in to plot.
map = folium.Map(location = [61, -96], zoom_start = 2, tiles = "CartoDB Positron")
folium.GeoJson(
gj,= "Canadian Tidal Stations",
name = folium.CircleMarker(
marker = 3,
radius = "black",
color = "orange",
fill_color = .4,
fill_opacity = .5
weight
),= folium.GeoJsonTooltip(
tooltip = ["officialName", "code", "series_codes"],
fields = ["Station Name", "Station Code", "Provided Series"],
aliases = True
localize
),= lambda x: {"fillOpacity": 1, "weight": 1}
highlight_function map)
).add_to(
map