import requests
import datetime
In this notebook, we will download data about earthquakes in Nepal from USGS and produce a dashboard to explore the data interactively.
Getting the Data
The data is freely available from USGS’s Earthquake Catalog.
The API supports a number of query parameters, but we need only a subset of those, particularly to limit the search to Nepal’s geographical bounds and since 2010. We’ll also limit the results to include earthquakes > 4 in magnitude.
= "https://earthquake.usgs.gov/fdsnws/event/1/query.csv?" CATALOG_URL
To get the geographic bounds of Nepal, we will read a parquet file using the geopandas library. Let’s see what this looks like.
import geopandas as gpd
= gpd.read_parquet("nepal.parq")
nepal nepal.plot()
We only care about the boundaries, so let’s dissolve the internal boundaries and get the geographical bounds.
= nepal.dissolve()
nepal nepal.bounds
minx | miny | maxx | maxy | |
---|---|---|---|---|
0 | 80.057081 | 26.347758 | 88.201526 | 30.471487 |
Now we’re ready to query the catalog. We’ll save the results as a CSV file ‘quakes.csv’.
= {
params "starttime": "2010-10-07",
"endtime": str(datetime.date.today()),
"maxlatitude": nepal.bounds["maxy"],
"minlatitude": nepal.bounds["miny"],
"maxlongitude": nepal.bounds["maxx"],
"minlongitude": nepal.bounds["minx"],
"minmagnitude": 3,
}
= requests.get(CATALOG_URL, params=params)
response with open("quakes.csv", "wb") as f:
f.write(response.content)
Filtering the results
We’ll read the CSV file as a pandas DataFrame
first and then convert it to a GeoDataFrame
with geopandas
.
import pandas as pd
= pd.read_csv(
quakes "quakes.csv",
=["time"],
parse_dates={
dtype"latitude": "float32",
"longitude": "float32",
"depth": "float32",
"mag": "float32",
},=["time", "latitude", "longitude", "depth", "mag", "place"],
usecols
)
= gpd.GeoDataFrame(
quakes
quakes,=gpd.points_from_xy(quakes["longitude"], quakes["latitude"]),
geometry=nepal.crs,
crs=["longitude", "latitude"]) ).drop(columns
The time is currently in UTC, lets convert that that ‘Asia/Kathmandu’
"time"] = quakes["time"].dt.tz_convert(tz="Asia/Kathmandu") quakes[
Let’s visualise the earthquakes now.
= nepal.plot(color="white", edgecolor="black")
ax =ax, markersize=5, color="red") quakes.plot(ax
The results include all earthquakes located within the rectangular geogrpahical bounds. We can see earthquakes recorded in China and India.
To fix this, we’ll use the within
function provided by geopandas
to filter only those within Nepal’s boundary.
= quakes[quakes.within(nepal.loc[0, "geometry"])]
quakes
= nepal.plot(color="white", edgecolor="black")
ax =ax, markersize=5, color="red") quakes.plot(ax
Interactive Visualisation
Now that we have downloaded and filtered the data, we’ll improve on the static map and produce an interactive map. We’ll use the holoviews
and geoviews
libraries to achieve this. In this case, we’ll use the bokeh
backend for plotting.
import holoviews as hv
import geoviews as gv
"bokeh") hv.extension(
We only need to plot Nepal’s boundary, so we’ll use the Path
method of geoviews
instead of Polygons
.
= gv.Path(nepal).opts(
nepal_map ="black",
color=None,
xaxis=None,
yaxis=1,
data_aspect='width'
responsive )
Next, we’ll plot the earthquakes as points on the map. We’ll visually relate the size of the earthquakes with the corresponding markers on the map. We’ll define a method to generate the point plot, and holoviews
allows us to modify the output of this method by chaining opts
later.
def plot_quakes(data):
return gv.Points(data).opts(
=(2 ** gv.dim("mag")) / 4,
size=0,
fill_alpha="above",
toolbar=True,
show_legend )
This time, we want to provide some information about the earthquake (e.g. date, magnitude, etc.) when a user hovers over a point on the map. For this, we’ll use the HoverTool
provided by bokeh
.
from bokeh.models import HoverTool
from bokeh.models.widgets.tables import NumberFormatter
= HoverTool(
hover_tool =[
tooltips"Date", "@time{%d %b %Y %I:%M %p}"),
("Magnitude", "@mag{0.0}"),
("Place", "@place"),
("Depth", "@depth{0.00} kms"),
(
],={"@time": "datetime"},
formatters )
* plot_quakes(quakes).opts(tools=[hover_tool]) nepal_map
We can improve this further by indicating how far in the past the earthquakes occurred. To achieve this, we’ll create the following bins:
- < 1 day
- 1 - 7 days
- 7 - 30 days
- 30 - 365 days
- older
"timebin"] = pd.to_datetime("now").tz_localize("UTC") - quakes["time"]
quakes[= [
bins =0),
pd.Timedelta(seconds=24),
pd.Timedelta(hours=7),
pd.Timedelta(days=30),
pd.Timedelta(days=365),
pd.Timedelta(days"now").tz_localize("UTC") - min(quakes["time"]),
pd.to_datetime(
]= [
labels "past day",
"past week",
"past month",
"past year",
"older",
]"timebin"] = pd.cut(quakes["timebin"], bins, labels=labels) quakes[
"time", "timebin"]].head(5) quakes[[
time | timebin | |
---|---|---|
0 | 2024-09-15 00:06:42.274000+05:45 | past year |
1 | 2024-08-31 03:46:59.157000+05:45 | past year |
7 | 2024-05-28 06:58:06.793000+05:45 | past year |
8 | 2024-03-06 10:10:08.225000+05:45 | past year |
10 | 2024-01-12 23:32:29.479000+05:45 | past year |
* plot_quakes(quakes).opts(
nepal_map ="timebin", cmap="Dark2", fill_alpha=0.5, legend_cols=3, tools=[hover_tool]
color )
Interaction with Filtering
HoloViews provides the HoloMap
container, which is supported by a multi-dimensional dictionary of objects. We can then explore the data along those dimensions. See here for the official examples.
HoloMap
produces the plot together with widgets corresponding to the dictionaries for exploration. By default HoloMap
’s widgets are placed to the right of the plot, which is not very suitable for our rectangular map. So, we’ll place the widgets at the bottom using hv.output
.
="bottom") hv.output(widget_location
When displayed as a webpage using Quarto by converting to HTML, HoloMap creates plots for each unique combination of dimension(s) because we don’t have a phython server to support dynamic generation of plots. Therefore, we’ll limit the values of our dimensions.
Ideally you’d produce a dashboard supported by a python server which would allow us to explore all key dimensions. For how to do that, read below.
First, we’ll create the dimensions
from math import floor
= [2010, 2015, 2020]
years = [
kdims "start_year", "Since"), default=2010),
hv.Dimension(("min_magnitude", "Minimum magnitude"), default=3),
hv.Dimension((
]
= min(quakes["mag"]), max(quakes["mag"])
min_mag, max_mag = [i for i in range(floor(min_mag), round(max_mag))] mag_bins
Next we’ll define a method that generates the plot for a given (year, min_magnitude) combination, and bind this to the HoloMap
container.
def plot_filtered_quakes(start_year, min_mag):
= quakes[(quakes["time"].dt.year >= start_year) & (quakes["mag"] >= min_mag)]
data
# Here, we need to handle cases where there's no earthquake records.
if not len(data):
return nepal_map * gv.Points([]).opts(tools=[hover_tool])
return nepal_map * gv.Points(data).opts(
=(2 ** gv.dim("mag")) / 4,
size="timebin",
color="Dark2",
cmap=0.5,
fill_alpha="above",
toolbar=[hover_tool],
tools=True,
show_legend
)
= {
map_dict for m in mag_bins[:-1] for y in [2010, 2015, 2020]
(y, m): plot_filtered_quakes(y, m)
}= hv.HoloMap(map_dict, kdims=kdims) hmap
hmap
Dashboard with DynamicMap and Panel
HoloViews
plots can be dynamically linked with panel
widgets, which allows the widgets to act as input to control the range of values shown in the plots. In this case we’ll use the DynamicMap
container which use callables to return HoloViews object using the python server, instead of the HoloMap
way of pre-rendering plots for all possible dimension combinations.
For our map, we will use a date slider widget to filter earthquakes within a date range, and a min magnitude widget to filter the minimum magnitude of the earthquakes.
import panel as pn
pn.extension()
Date Slider
We’ll create a slider that allows us to select any range of dates within our data.
= quakes["time"].max()
last_date = quakes["time"].min()
earliest_date = pn.widgets.DateRangeSlider(
date_slider ="Date Range",
name=earliest_date,
start=last_date,
end=(earliest_date, last_date),
value )
Minium Magnitude
The next widget will allow us to filter by the minium magnitude of the earthquake.
= pn.widgets.IntSlider(
min_magnitude ="Minimum Magnitude",
name=3,
value=1,
step=3,
start=floor(quakes["mag"].max()),
end )
Let’s wrap the widgets in panel
’s WidgetBox
layout that nicely arranges the widgets for us.
= pn.layout.WidgetBox(date_slider, min_magnitude)
widgets widgets
Binding the widgets with the Map
Now that we’ve created the widgets, we will bind these widgets with a method that will filter the data accordingly and call the plot_quakes
method we defined earlier to plot the earthquakes on a map.
def plot_filtered_quakes(date_range, min_mag):
try:
= [pd.to_datetime(x).tz_localize("UTC") for x in date_range]
dates = quakes[
data "time"] > dates[0])
(quakes[& (quakes["time"] < dates[1])
& (quakes["mag"] >= min_mag)
]except TypeError:
= quakes
data
return nepal_map * gv.Points(data).opts(
=(2 ** gv.dim("mag")) / 4,
size="timebin",
color="Dark2",
cmap=0.5,
fill_alpha="above",
toolbar=1,
data_aspect='width',
responsive=[hover_tool],
tools=True,
show_legend
)
= pn.bind(
quakes_map =date_slider, min_mag=min_magnitude
plot_filtered_quakes, date_range
)= hv.DynamicMap(quakes_map) quakes_dmap
pn.Column(quakes_dmap, widgets)
Custom Legend for Earthquake Size
Lastly, we would like a legend to show what the sizes of the circles means. Unfortunately, the bokeh
backend we’re using for holoviews
doesn’t support a legend with different marker sizes. So, we’ll create a custom plot that mimics a legend and add that to the plot.
First, let’s create bins to place the earthquakes in: starting with 4 (the lowest possible) to the max recorded.
+= [round(max_mag, 2)]
mag_bins mag_bins
[3, 4, 5, 6, 7, 7.8]
Now we determine the marker sizes for these, as well as their positions on the y-axis, as we’ll create a vertical legend. If we wanted a horizontal legend, we’d swap the x and y positions.
= [2**x / 4 for x in mag_bins]
marker_sizes
= []
xs for i, s in enumerate(marker_sizes):
= (xs[i - 1] + 0.1 + s / 100) if i > 0 else 0
x += [x]
xs
= dict(
marker_data =xs,
x=[0] * len(mag_bins),
y=marker_sizes,
mag
) marker_data
{'x': [0, 0.14, 0.32, 0.5800000000000001, 1.0, 1.6571523605095195],
'y': [0, 0, 0, 0, 0, 0],
'mag': [2.0, 4.0, 8.0, 16.0, 32.0, 55.71523605095194]}
We also need labels, one before the first marker, and one after the last marker. This will be a bit a of a trial and error in terms of positioning the labels to your satisfaction.
= hv.Labels(
mag_labels
{"x", "y"): [(xs[0] - 0.2, 0), (xs[-1] + 0.55, 0)],
("text": [f"{mag_bins[0]}", f"{mag_bins[-1]}+"],
},"x", "y"],
["text",
="black", text_font_size="10pt") ).opts(text_color
Let’s see what our legend looks like.
= hv.Points(marker_data, vdims=["mag"]).opts(
mag_points ="mag",
size="gray",
color=0,
fill_alpha=None,
toolbar=(-0.2, 0.2),
ylim=(-0.25, 2.5),
xlim=100,
height=250,
width=None,
xaxis=None,
yaxis=False,
show_frame="Magnitude",
title
)
= mag_points * mag_labels
mag_legend mag_legend
Now we can add this legend with the map to complete our interactive map.
="center")) pn.Column(quakes_dmap, pn.Row(widgets, mag_legend, align
If you were to run this in Jupyter Lab or as part of a dashboard, you’d be able to do lag-free filtering using the python server. For an example of a work in progress dashboard see here.