Repository: niconoe/pyinaturalist
Pull request #96 (2021-02-03 01:02:14)

Add data visualization examples using Altair
Based on forum conversation here: https://forum.inaturalist.org/t/what-would-you-like-to-learn-about-getting-data-from-the-system/19225/14

And blog post here: https://www.inaturalist.org/journal/muir/32944-alaska-inat-passes-100k-observations-5k-unique-taxa

Other misc changes:
* Update changelog and list of implemented endpoints
* Add some more dependencies to Dockerfile
* Add 0 and 0.0 to valid 'falsy' values to not strip from request params
* Fix bug with processing datetime parameters
* Add function to help with getting date ranges for histograms with custom metrics


Patch:
diff --git a/HISTORY.md b/HISTORY.md
index dc8dc73..52cb582 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -4,9 +4,11 @@
 
 ### New Endpoints
 * Added new function for **Observation Histogram** endpoint: `get_observation_histogram()`
+* Added new function for **Observers** endpoint: `get_observation_observers()`
+* Added new function for **Identifiers** endpoint: `get_observation_identifiers()`
 * Added new function for **Controlled Terms** endpoints: `get_controlled_terms()`
     * Wraps both `GET /controlled_terms` and `/controlled_terms/for_taxon` endpoints
-    
+
 ### Modified Endpoints
 * Added conversion from date/time strings to timezone-aware python `datetime` objects.
   This applies to the following functions:
@@ -29,10 +31,11 @@
 
 ### Other Changes
 * Added a `Dockerfile` and `docker-compose.yml` for a Jupyter notebook containing pyinaturalist + other relevant packages
-* Added some more detailed usage examples under `examples/` 
+* Added some more detailed usage examples under `examples/`
 * Improved performance for large paginated queries
+* Fixed bug that dropped request parameter values of `0` as if they were `None`
 * Dropped support for python 3.5
-* Removed request parameters deprecated in 0.11
+* Removed request parameters that were deprecated in 0.11
 
 ## 0.11 (2020-11-04)
 [See all Issues & PRs](https://github.com/niconoe/pyinaturalist/milestone/2?closed=1)
diff --git a/docs/endpoints_table.md b/docs/endpoints_table.md
index cfd3eb5..225f8ef 100644
--- a/docs/endpoints_table.md
+++ b/docs/endpoints_table.md
@@ -59,8 +59,8 @@ GET               | /observations                               | yes
 POST              | /observations                               |
 GET               | /observations/deleted                       |
 GET               | /observations/histogram                     | yes
-GET               | /observations/identifiers                   |
-GET               | /observations/observers                     |
+GET               | /observations/identifiers                   | yes
+GET               | /observations/observers                     | yes
 GET               | /observations/popular_field_values          |
 GET               | /observations/species_counts                | yes
 GET               | /observations/updates                       |
diff --git a/examples/Data-Visualizations-Matplotlib.ipynb b/examples/Data Visualizations - Matplotlib.ipynb
old mode 100644
new mode 100755
similarity index 98%
rename from examples/Data-Visualizations-Matplotlib.ipynb
rename to examples/Data Visualizations - Matplotlib.ipynb
index 3b6a2db..60cd8c1
--- a/examples/Data-Visualizations-Matplotlib.ipynb
+++ b/examples/Data Visualizations - Matplotlib.ipynb
@@ -4,9 +4,9 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "## Simple seasonality chart using matplotlib\n",
-    "\n",
-    "This shows observations counts by month for monarch butterflies observed in the state of Iowa"
+    "# Matplotlib examples\n",
+    "Here is an example of making a simple seasonality chart using [matplotlib](https://matplotlib.org/).\n",
+    "This shows observations counts by month for monarch butterflies observed in the state of Iowa."
    ]
   },
   {
diff --git a/examples/Data Visualizations - Regional Activity Report.ipynb b/examples/Data Visualizations - Regional Activity Report.ipynb
new file mode 100755
index 0000000..2d2cfba
--- /dev/null
+++ b/examples/Data Visualizations - Regional Activity Report.ipynb
@@ -0,0 +1,684 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "attached-priority",
+   "metadata": {},
+   "source": [
+    "# Regional activity visualizations\n",
+    "This example shows how to create visualizations of iNaturalist activity in a given area using [Altair](https://altair-viz.github.io), with the following metrics:\n",
+    "* Number of observations\n",
+    "* Number of taxa observed\n",
+    "* Number of observers\n",
+    "* Number of identifiers"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "prostate-overall",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from datetime import datetime\n",
+    "from time import sleep\n",
+    "\n",
+    "from dateutil.relativedelta import relativedelta\n",
+    "from typing import Any, BinaryIO, Dict, Iterable, List, Optional, Tuple\n",
+    "\n",
+    "import altair as alt\n",
+    "import pandas as pd\n",
+    "from pyinaturalist.node_api import (\n",
+    "    get_observation_histogram,\n",
+    "    get_observation_species_counts,\n",
+    "    get_observation_observers,\n",
+    "    get_observation_identifiers,\n",
+    ")\n",
+    "from pyinaturalist.request_params import get_interval_ranges\n",
+    "\n",
+    "PLACE_ID = 6\n",
+    "PLACE_NAME = 'Alaska'\n",
+    "YEAR = 2020\n",
+    "THROTTLING_DELAY = 1.0  # Time to wait in between subsequent requests"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "angry-longer",
+   "metadata": {},
+   "source": [
+    "### Show observation counts by year"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "id": "joint-interference",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "\n",
+       "<div id=\"altair-viz-38efd4e1e9a146aab29a0949dfb68172\"></div>\n",
+       "<script type=\"text/javascript\">\n",
+       "  (function(spec, embedOpt){\n",
+       "    let outputDiv = document.currentScript.previousElementSibling;\n",
+       "    if (outputDiv.id !== \"altair-viz-38efd4e1e9a146aab29a0949dfb68172\") {\n",
+       "      outputDiv = document.getElementById(\"altair-viz-38efd4e1e9a146aab29a0949dfb68172\");\n",
+       "    }\n",
+       "    const paths = {\n",
+       "      \"vega\": \"https://cdn.jsdelivr.net/npm//vega@5?noext\",\n",
+       "      \"vega-lib\": \"https://cdn.jsdelivr.net/npm//vega-lib?noext\",\n",
+       "      \"vega-lite\": \"https://cdn.jsdelivr.net/npm//vega-lite@4.8.1?noext\",\n",
+       "      \"vega-embed\": \"https://cdn.jsdelivr.net/npm//vega-embed@6?noext\",\n",
+       "    };\n",
+       "\n",
+       "    function loadScript(lib) {\n",
+       "      return new Promise(function(resolve, reject) {\n",
+       "        var s = document.createElement('script');\n",
+       "        s.src = paths[lib];\n",
+       "        s.async = true;\n",
+       "        s.onload = () => resolve(paths[lib]);\n",
+       "        s.onerror = () => reject(`Error loading script: ${paths[lib]}`);\n",
+       "        document.getElementsByTagName(\"head\")[0].appendChild(s);\n",
+       "      });\n",
+       "    }\n",
+       "\n",
+       "    function showError(err) {\n",
+       "      outputDiv.innerHTML = `<div class=\"error\" style=\"color:red;\">${err}</div>`;\n",
+       "      throw err;\n",
+       "    }\n",
+       "\n",
+       "    function displayChart(vegaEmbed) {\n",
+       "      vegaEmbed(outputDiv, spec, embedOpt)\n",
+       "        .catch(err => showError(`Javascript Error: ${err.message}<br>This usually means there's a typo in your chart specification. See the javascript console for the full traceback.`));\n",
+       "    }\n",
+       "\n",
+       "    if(typeof define === \"function\" && define.amd) {\n",
+       "      requirejs.config({paths});\n",
+       "      require([\"vega-embed\"], displayChart, err => showError(`Error loading script: ${err.message}`));\n",
+       "    } else if (typeof vegaEmbed === \"function\") {\n",
+       "      displayChart(vegaEmbed);\n",
+       "    } else {\n",
+       "      loadScript(\"vega\")\n",
+       "        .then(() => loadScript(\"vega-lite\"))\n",
+       "        .then(() => loadScript(\"vega-embed\"))\n",
+       "        .catch(showError)\n",
+       "        .then(() => displayChart(vegaEmbed));\n",
+       "    }\n",
+       "  })({\"config\": {\"view\": {\"continuousWidth\": 400, \"continuousHeight\": 300}}, \"data\": {\"name\": \"data-4416843fe69d1b840921b47c22f5a831\"}, \"mark\": \"bar\", \"encoding\": {\"x\": {\"type\": \"temporal\", \"field\": \"date\", \"timeUnit\": \"year\"}, \"y\": {\"type\": \"quantitative\", \"field\": \"observations\"}}, \"$schema\": \"https://vega.github.io/schema/vega-lite/v4.8.1.json\", \"datasets\": {\"data-4416843fe69d1b840921b47c22f5a831\": [{\"date\": \"2008-01-01T00:00:00\", \"observations\": 1996}, {\"date\": \"2009-01-01T00:00:00\", \"observations\": 1381}, {\"date\": \"2010-01-01T00:00:00\", \"observations\": 2144}, {\"date\": \"2011-01-01T00:00:00\", \"observations\": 1441}, {\"date\": \"2012-01-01T00:00:00\", \"observations\": 5347}, {\"date\": \"2013-01-01T00:00:00\", \"observations\": 2194}, {\"date\": \"2014-01-01T00:00:00\", \"observations\": 4049}, {\"date\": \"2015-01-01T00:00:00\", \"observations\": 5304}, {\"date\": \"2016-01-01T00:00:00\", \"observations\": 8763}, {\"date\": \"2017-01-01T00:00:00\", \"observations\": 22034}, {\"date\": \"2018-01-01T00:00:00\", \"observations\": 26124}, {\"date\": \"2019-01-01T00:00:00\", \"observations\": 37429}, {\"date\": \"2020-01-01T00:00:00\", \"observations\": 40256}]}}, {\"mode\": \"vega-lite\"});\n",
+       "</script>"
+      ],
+      "text/plain": [
+       "alt.Chart(...)"
+      ]
+     },
+     "execution_count": 2,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "obs_by_year = get_observation_histogram(\n",
+    "    place_id=PLACE_ID,\n",
+    "    interval='year',\n",
+    "    d1='2008-01-01',\n",
+    "    d2=f'{YEAR}-12-31',\n",
+    ")\n",
+    "obs_by_year = pd.DataFrame([{'date': k, 'observations': v} for k, v in obs_by_year.items()])\n",
+    "alt.Chart(obs_by_year).mark_bar().encode(x='year(date):T', y='observations:Q')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "invisible-needle",
+   "metadata": {},
+   "source": [
+    "### Show observation counts by month"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "id": "dietary-tours",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "\n",
+       "<div id=\"altair-viz-37a63d365b2347d4831ee1c70d278767\"></div>\n",
+       "<script type=\"text/javascript\">\n",
+       "  (function(spec, embedOpt){\n",
+       "    let outputDiv = document.currentScript.previousElementSibling;\n",
+       "    if (outputDiv.id !== \"altair-viz-37a63d365b2347d4831ee1c70d278767\") {\n",
+       "      outputDiv = document.getElementById(\"altair-viz-37a63d365b2347d4831ee1c70d278767\");\n",
+       "    }\n",
+       "    const paths = {\n",
+       "      \"vega\": \"https://cdn.jsdelivr.net/npm//vega@5?noext\",\n",
+       "      \"vega-lib\": \"https://cdn.jsdelivr.net/npm//vega-lib?noext\",\n",
+       "      \"vega-lite\": \"https://cdn.jsdelivr.net/npm//vega-lite@4.8.1?noext\",\n",
+       "      \"vega-embed\": \"https://cdn.jsdelivr.net/npm//vega-embed@6?noext\",\n",
+       "    };\n",
+       "\n",
+       "    function loadScript(lib) {\n",
+       "      return new Promise(function(resolve, reject) {\n",
+       "        var s = document.createElement('script');\n",
+       "        s.src = paths[lib];\n",
+       "        s.async = true;\n",
+       "        s.onload = () => resolve(paths[lib]);\n",
+       "        s.onerror = () => reject(`Error loading script: ${paths[lib]}`);\n",
+       "        document.getElementsByTagName(\"head\")[0].appendChild(s);\n",
+       "      });\n",
+       "    }\n",
+       "\n",
+       "    function showError(err) {\n",
+       "      outputDiv.innerHTML = `<div class=\"error\" style=\"color:red;\">${err}</div>`;\n",
+       "      throw err;\n",
+       "    }\n",
+       "\n",
+       "    function displayChart(vegaEmbed) {\n",
+       "      vegaEmbed(outputDiv, spec, embedOpt)\n",
+       "        .catch(err => showError(`Javascript Error: ${err.message}<br>This usually means there's a typo in your chart specification. See the javascript console for the full traceback.`));\n",
+       "    }\n",
+       "\n",
+       "    if(typeof define === \"function\" && define.amd) {\n",
+       "      requirejs.config({paths});\n",
+       "      require([\"vega-embed\"], displayChart, err => showError(`Error loading script: ${err.message}`));\n",
+       "    } else if (typeof vegaEmbed === \"function\") {\n",
+       "      displayChart(vegaEmbed);\n",
+       "    } else {\n",
+       "      loadScript(\"vega\")\n",
+       "        .then(() => loadScript(\"vega-lite\"))\n",
+       "        .then(() => loadScript(\"vega-embed\"))\n",
+       "        .catch(showError)\n",
+       "        .then(() => displayChart(vegaEmbed));\n",
+       "    }\n",
+       "  })({\"config\": {\"view\": {\"continuousWidth\": 400, \"continuousHeight\": 300}}, \"data\": {\"name\": \"data-6ed22332cc0c8d06cb87ca0440056a49\"}, \"mark\": \"bar\", \"encoding\": {\"x\": {\"type\": \"temporal\", \"field\": \"date\", \"timeUnit\": \"month\"}, \"y\": {\"type\": \"quantitative\", \"field\": \"count\"}}, \"$schema\": \"https://vega.github.io/schema/vega-lite/v4.8.1.json\", \"datasets\": {\"data-6ed22332cc0c8d06cb87ca0440056a49\": [{\"metric\": \"Observations\", \"date\": \"2020-01-01T00:00:00\", \"count\": 441}, {\"metric\": \"Observations\", \"date\": \"2020-02-01T00:00:00\", \"count\": 379}, {\"metric\": \"Observations\", \"date\": \"2020-03-01T00:00:00\", \"count\": 688}, {\"metric\": \"Observations\", \"date\": \"2020-04-01T00:00:00\", \"count\": 2694}, {\"metric\": \"Observations\", \"date\": \"2020-05-01T00:00:00\", \"count\": 5970}, {\"metric\": \"Observations\", \"date\": \"2020-06-01T00:00:00\", \"count\": 6344}, {\"metric\": \"Observations\", \"date\": \"2020-07-01T00:00:00\", \"count\": 7783}, {\"metric\": \"Observations\", \"date\": \"2020-08-01T00:00:00\", \"count\": 7152}, {\"metric\": \"Observations\", \"date\": \"2020-09-01T00:00:00\", \"count\": 4756}, {\"metric\": \"Observations\", \"date\": \"2020-10-01T00:00:00\", \"count\": 1547}, {\"metric\": \"Observations\", \"date\": \"2020-11-01T00:00:00\", \"count\": 985}, {\"metric\": \"Observations\", \"date\": \"2020-12-01T00:00:00\", \"count\": 1514}]}}, {\"mode\": \"vega-lite\"});\n",
+       "</script>"
+      ],
+      "text/plain": [
+       "alt.Chart(...)"
+      ]
+     },
+     "execution_count": 3,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "obs_by_month = get_observation_histogram(\n",
+    "    place_id=PLACE_ID,\n",
+    "    interval='month',\n",
+    "    d1='2020-01-02',\n",
+    "    d2='2020-12-31',\n",
+    ")\n",
+    "observations_by_month = pd.DataFrame([{'metric': 'Observations', 'date': k, 'count': v} for k, v in obs_by_month.items()])\n",
+    "alt.Chart(observations_by_month).mark_bar().encode(x='month(date):T', y='count:Q')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "genetic-camping",
+   "metadata": {},
+   "source": [
+    "### Making histograms for custom metrics\n",
+    "The API does not have a histogram endpoint for taxa observed, observers, or identifiers,\n",
+    "so we first need to determine our date ranges of interest, and then run one search per date range.\n",
+    "\n",
+    "Here are some functions to simplify this."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "id": "duplicate-attribute",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def count_date_range_results(function, start_date, end_date):\n",
+    "    \"\"\"Get the count of results for the given date range and search function\"\"\"\n",
+    "    # Running this search with per_page=0 will (quickly) return only a count of results, not complete results\n",
+    "    response = function(\n",
+    "        place_id=PLACE_ID,\n",
+    "        d1=start_date,\n",
+    "        d2=end_date,\n",
+    "        per_page=0,  \n",
+    "    )\n",
+    "    print(f'Total results for {start_date.strftime(\"%b\")}: {response[\"total_results\"]}')\n",
+    "    return response['total_results']\n",
+    "    if start_date.month != 12:\n",
+    "        sleep(THROTTLING_DELAY)\n",
+    "\n",
+    "\n",
+    "def get_monthly_counts(function, label):\n",
+    "    \"\"\"Get the count of results per month for the given search function\"\"\"\n",
+    "    month_ranges = get_interval_ranges(datetime(YEAR, 1, 1), datetime(YEAR, 12, 31), 'monthly')\n",
+    "    counts_by_month = {\n",
+    "        start_date: count_date_range_results(function, start_date, end_date)\n",
+    "        for (start_date, end_date) in month_ranges\n",
+    "    }\n",
+    "    return pd.DataFrame([{'metric': label, 'date': k, 'count': v} for k, v in counts_by_month.items()])"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "induced-stone",
+   "metadata": {},
+   "source": [
+    "### Show unique taxa observed per month"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "id": "exempt-victor",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Total results for Jan: 196\n",
+      "Total results for Feb: 179\n",
+      "Total results for Mar: 324\n",
+      "Total results for Apr: 800\n",
+      "Total results for May: 1372\n",
+      "Total results for Jun: 1502\n",
+      "Total results for Jul: 1728\n",
+      "Total results for Aug: 1605\n",
+      "Total results for Sep: 1268\n",
+      "Total results for Oct: 650\n",
+      "Total results for Nov: 413\n",
+      "Total results for Dec: 555\n"
+     ]
+    },
+    {
+     "data": {
+      "text/html": [
+       "\n",
+       "<div id=\"altair-viz-0c8c78e8abdb459581cdb5fc62851c40\"></div>\n",
+       "<script type=\"text/javascript\">\n",
+       "  (function(spec, embedOpt){\n",
+       "    let outputDiv = document.currentScript.previousElementSibling;\n",
+       "    if (outputDiv.id !== \"altair-viz-0c8c78e8abdb459581cdb5fc62851c40\") {\n",
+       "      outputDiv = document.getElementById(\"altair-viz-0c8c78e8abdb459581cdb5fc62851c40\");\n",
+       "    }\n",
+       "    const paths = {\n",
+       "      \"vega\": \"https://cdn.jsdelivr.net/npm//vega@5?noext\",\n",
+       "      \"vega-lib\": \"https://cdn.jsdelivr.net/npm//vega-lib?noext\",\n",
+       "      \"vega-lite\": \"https://cdn.jsdelivr.net/npm//vega-lite@4.8.1?noext\",\n",
+       "      \"vega-embed\": \"https://cdn.jsdelivr.net/npm//vega-embed@6?noext\",\n",
+       "    };\n",
+       "\n",
+       "    function loadScript(lib) {\n",
+       "      return new Promise(function(resolve, reject) {\n",
+       "        var s = document.createElement('script');\n",
+       "        s.src = paths[lib];\n",
+       "        s.async = true;\n",
+       "        s.onload = () => resolve(paths[lib]);\n",
+       "        s.onerror = () => reject(`Error loading script: ${paths[lib]}`);\n",
+       "        document.getElementsByTagName(\"head\")[0].appendChild(s);\n",
+       "      });\n",
+       "    }\n",
+       "\n",
+       "    function showError(err) {\n",
+       "      outputDiv.innerHTML = `<div class=\"error\" style=\"color:red;\">${err}</div>`;\n",
+       "      throw err;\n",
+       "    }\n",
+       "\n",
+       "    function displayChart(vegaEmbed) {\n",
+       "      vegaEmbed(outputDiv, spec, embedOpt)\n",
+       "        .catch(err => showError(`Javascript Error: ${err.message}<br>This usually means there's a typo in your chart specification. See the javascript console for the full traceback.`));\n",
+       "    }\n",
+       "\n",
+       "    if(typeof define === \"function\" && define.amd) {\n",
+       "      requirejs.config({paths});\n",
+       "      require([\"vega-embed\"], displayChart, err => showError(`Error loading script: ${err.message}`));\n",
+       "    } else if (typeof vegaEmbed === \"function\") {\n",
+       "      displayChart(vegaEmbed);\n",
+       "    } else {\n",
+       "      loadScript(\"vega\")\n",
+       "        .then(() => loadScript(\"vega-lite\"))\n",
+       "        .then(() => loadScript(\"vega-embed\"))\n",
+       "        .catch(showError)\n",
+       "        .then(() => displayChart(vegaEmbed));\n",
+       "    }\n",
+       "  })({\"config\": {\"view\": {\"continuousWidth\": 400, \"continuousHeight\": 300}}, \"data\": {\"name\": \"data-b757ef331c7c89ccb4c56fd0f1af6923\"}, \"mark\": \"bar\", \"encoding\": {\"x\": {\"type\": \"temporal\", \"field\": \"date\", \"timeUnit\": \"month\"}, \"y\": {\"type\": \"quantitative\", \"field\": \"count\"}}, \"$schema\": \"https://vega.github.io/schema/vega-lite/v4.8.1.json\", \"datasets\": {\"data-b757ef331c7c89ccb4c56fd0f1af6923\": [{\"metric\": \"Taxa\", \"date\": \"2020-01-01T00:00:00\", \"count\": 196}, {\"metric\": \"Taxa\", \"date\": \"2020-02-01T00:00:00\", \"count\": 179}, {\"metric\": \"Taxa\", \"date\": \"2020-03-01T00:00:00\", \"count\": 324}, {\"metric\": \"Taxa\", \"date\": \"2020-04-01T00:00:00\", \"count\": 800}, {\"metric\": \"Taxa\", \"date\": \"2020-05-01T00:00:00\", \"count\": 1372}, {\"metric\": \"Taxa\", \"date\": \"2020-06-01T00:00:00\", \"count\": 1502}, {\"metric\": \"Taxa\", \"date\": \"2020-07-01T00:00:00\", \"count\": 1728}, {\"metric\": \"Taxa\", \"date\": \"2020-08-01T00:00:00\", \"count\": 1605}, {\"metric\": \"Taxa\", \"date\": \"2020-09-01T00:00:00\", \"count\": 1268}, {\"metric\": \"Taxa\", \"date\": \"2020-10-01T00:00:00\", \"count\": 650}, {\"metric\": \"Taxa\", \"date\": \"2020-11-01T00:00:00\", \"count\": 413}, {\"metric\": \"Taxa\", \"date\": \"2020-12-01T00:00:00\", \"count\": 555}]}}, {\"mode\": \"vega-lite\"});\n",
+       "</script>"
+      ],
+      "text/plain": [
+       "alt.Chart(...)"
+      ]
+     },
+     "execution_count": 5,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "taxa_by_month = get_monthly_counts(get_observation_species_counts, 'Taxa')\n",
+    "alt.Chart(taxa_by_month).mark_bar().encode(x='month(date):T', y='count:Q')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "brief-daniel",
+   "metadata": {},
+   "source": [
+    "### Show number of observers per month"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "id": "generous-candy",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Total results for Jan: 44\n",
+      "Total results for Feb: 43\n",
+      "Total results for Mar: 79\n",
+      "Total results for Apr: 147\n",
+      "Total results for May: 376\n",
+      "Total results for Jun: 496\n",
+      "Total results for Jul: 572\n",
+      "Total results for Aug: 599\n",
+      "Total results for Sep: 439\n",
+      "Total results for Oct: 187\n",
+      "Total results for Nov: 91\n",
+      "Total results for Dec: 54\n"
+     ]
+    },
+    {
+     "data": {
+      "text/html": [
+       "\n",
+       "<div id=\"altair-viz-0329d2d9d86e4e1d82e03f6b6d5792bb\"></div>\n",
+       "<script type=\"text/javascript\">\n",
+       "  (function(spec, embedOpt){\n",
+       "    let outputDiv = document.currentScript.previousElementSibling;\n",
+       "    if (outputDiv.id !== \"altair-viz-0329d2d9d86e4e1d82e03f6b6d5792bb\") {\n",
+       "      outputDiv = document.getElementById(\"altair-viz-0329d2d9d86e4e1d82e03f6b6d5792bb\");\n",
+       "    }\n",
+       "    const paths = {\n",
+       "      \"vega\": \"https://cdn.jsdelivr.net/npm//vega@5?noext\",\n",
+       "      \"vega-lib\": \"https://cdn.jsdelivr.net/npm//vega-lib?noext\",\n",
+       "      \"vega-lite\": \"https://cdn.jsdelivr.net/npm//vega-lite@4.8.1?noext\",\n",
+       "      \"vega-embed\": \"https://cdn.jsdelivr.net/npm//vega-embed@6?noext\",\n",
+       "    };\n",
+       "\n",
+       "    function loadScript(lib) {\n",
+       "      return new Promise(function(resolve, reject) {\n",
+       "        var s = document.createElement('script');\n",
+       "        s.src = paths[lib];\n",
+       "        s.async = true;\n",
+       "        s.onload = () => resolve(paths[lib]);\n",
+       "        s.onerror = () => reject(`Error loading script: ${paths[lib]}`);\n",
+       "        document.getElementsByTagName(\"head\")[0].appendChild(s);\n",
+       "      });\n",
+       "    }\n",
+       "\n",
+       "    function showError(err) {\n",
+       "      outputDiv.innerHTML = `<div class=\"error\" style=\"color:red;\">${err}</div>`;\n",
+       "      throw err;\n",
+       "    }\n",
+       "\n",
+       "    function displayChart(vegaEmbed) {\n",
+       "      vegaEmbed(outputDiv, spec, embedOpt)\n",
+       "        .catch(err => showError(`Javascript Error: ${err.message}<br>This usually means there's a typo in your chart specification. See the javascript console for the full traceback.`));\n",
+       "    }\n",
+       "\n",
+       "    if(typeof define === \"function\" && define.amd) {\n",
+       "      requirejs.config({paths});\n",
+       "      require([\"vega-embed\"], displayChart, err => showError(`Error loading script: ${err.message}`));\n",
+       "    } else if (typeof vegaEmbed === \"function\") {\n",
+       "      displayChart(vegaEmbed);\n",
+       "    } else {\n",
+       "      loadScript(\"vega\")\n",
+       "        .then(() => loadScript(\"vega-lite\"))\n",
+       "        .then(() => loadScript(\"vega-embed\"))\n",
+       "        .catch(showError)\n",
+       "        .then(() => displayChart(vegaEmbed));\n",
+       "    }\n",
+       "  })({\"config\": {\"view\": {\"continuousWidth\": 400, \"continuousHeight\": 300}}, \"data\": {\"name\": \"data-071bc3e593c6cf3cc5ace535d859b57a\"}, \"mark\": \"bar\", \"encoding\": {\"x\": {\"type\": \"temporal\", \"field\": \"date\", \"timeUnit\": \"month\"}, \"y\": {\"type\": \"quantitative\", \"field\": \"count\"}}, \"$schema\": \"https://vega.github.io/schema/vega-lite/v4.8.1.json\", \"datasets\": {\"data-071bc3e593c6cf3cc5ace535d859b57a\": [{\"metric\": \"Observers\", \"date\": \"2020-01-01T00:00:00\", \"count\": 44}, {\"metric\": \"Observers\", \"date\": \"2020-02-01T00:00:00\", \"count\": 43}, {\"metric\": \"Observers\", \"date\": \"2020-03-01T00:00:00\", \"count\": 79}, {\"metric\": \"Observers\", \"date\": \"2020-04-01T00:00:00\", \"count\": 147}, {\"metric\": \"Observers\", \"date\": \"2020-05-01T00:00:00\", \"count\": 376}, {\"metric\": \"Observers\", \"date\": \"2020-06-01T00:00:00\", \"count\": 496}, {\"metric\": \"Observers\", \"date\": \"2020-07-01T00:00:00\", \"count\": 572}, {\"metric\": \"Observers\", \"date\": \"2020-08-01T00:00:00\", \"count\": 599}, {\"metric\": \"Observers\", \"date\": \"2020-09-01T00:00:00\", \"count\": 439}, {\"metric\": \"Observers\", \"date\": \"2020-10-01T00:00:00\", \"count\": 187}, {\"metric\": \"Observers\", \"date\": \"2020-11-01T00:00:00\", \"count\": 91}, {\"metric\": \"Observers\", \"date\": \"2020-12-01T00:00:00\", \"count\": 54}]}}, {\"mode\": \"vega-lite\"});\n",
+       "</script>"
+      ],
+      "text/plain": [
+       "alt.Chart(...)"
+      ]
+     },
+     "execution_count": 6,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "observers_by_month = get_monthly_counts(get_observation_observers, 'Observers')\n",
+    "alt.Chart(observers_by_month).mark_bar().encode(x='month(date):T', y='count:Q')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "earlier-warren",
+   "metadata": {},
+   "source": [
+    "### Show number of identifiers per month"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "id": "parliamentary-edward",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Total results for Jan: 141\n",
+      "Total results for Feb: 149\n",
+      "Total results for Mar: 188\n",
+      "Total results for Apr: 350\n",
+      "Total results for May: 624\n",
+      "Total results for Jun: 594\n",
+      "Total results for Jul: 664\n",
+      "Total results for Aug: 620\n",
+      "Total results for Sep: 497\n",
+      "Total results for Oct: 317\n",
+      "Total results for Nov: 216\n",
+      "Total results for Dec: 206\n"
+     ]
+    },
+    {
+     "data": {
+      "text/html": [
+       "\n",
+       "<div id=\"altair-viz-8ab86059e2514386bff5012d1df0ca07\"></div>\n",
+       "<script type=\"text/javascript\">\n",
+       "  (function(spec, embedOpt){\n",
+       "    let outputDiv = document.currentScript.previousElementSibling;\n",
+       "    if (outputDiv.id !== \"altair-viz-8ab86059e2514386bff5012d1df0ca07\") {\n",
+       "      outputDiv = document.getElementById(\"altair-viz-8ab86059e2514386bff5012d1df0ca07\");\n",
+       "    }\n",
+       "    const paths = {\n",
+       "      \"vega\": \"https://cdn.jsdelivr.net/npm//vega@5?noext\",\n",
+       "      \"vega-lib\": \"https://cdn.jsdelivr.net/npm//vega-lib?noext\",\n",
+       "      \"vega-lite\": \"https://cdn.jsdelivr.net/npm//vega-lite@4.8.1?noext\",\n",
+       "      \"vega-embed\": \"https://cdn.jsdelivr.net/npm//vega-embed@6?noext\",\n",
+       "    };\n",
+       "\n",
+       "    function loadScript(lib) {\n",
+       "      return new Promise(function(resolve, reject) {\n",
+       "        var s = document.createElement('script');\n",
+       "        s.src = paths[lib];\n",
+       "        s.async = true;\n",
+       "        s.onload = () => resolve(paths[lib]);\n",
+       "        s.onerror = () => reject(`Error loading script: ${paths[lib]}`);\n",
+       "        document.getElementsByTagName(\"head\")[0].appendChild(s);\n",
+       "      });\n",
+       "    }\n",
+       "\n",
+       "    function showError(err) {\n",
+       "      outputDiv.innerHTML = `<div class=\"error\" style=\"color:red;\">${err}</div>`;\n",
+       "      throw err;\n",
+       "    }\n",
+       "\n",
+       "    function displayChart(vegaEmbed) {\n",
+       "      vegaEmbed(outputDiv, spec, embedOpt)\n",
+       "        .catch(err => showError(`Javascript Error: ${err.message}<br>This usually means there's a typo in your chart specification. See the javascript console for the full traceback.`));\n",
+       "    }\n",
+       "\n",
+       "    if(typeof define === \"function\" && define.amd) {\n",
+       "      requirejs.config({paths});\n",
+       "      require([\"vega-embed\"], displayChart, err => showError(`Error loading script: ${err.message}`));\n",
+       "    } else if (typeof vegaEmbed === \"function\") {\n",
+       "      displayChart(vegaEmbed);\n",
+       "    } else {\n",
+       "      loadScript(\"vega\")\n",
+       "        .then(() => loadScript(\"vega-lite\"))\n",
+       "        .then(() => loadScript(\"vega-embed\"))\n",
+       "        .catch(showError)\n",
+       "        .then(() => displayChart(vegaEmbed));\n",
+       "    }\n",
+       "  })({\"config\": {\"view\": {\"continuousWidth\": 400, \"continuousHeight\": 300}}, \"data\": {\"name\": \"data-4b4be869d1dc7a0c576d391d05a07472\"}, \"mark\": \"bar\", \"encoding\": {\"x\": {\"type\": \"temporal\", \"field\": \"date\", \"timeUnit\": \"month\"}, \"y\": {\"type\": \"quantitative\", \"field\": \"count\"}}, \"$schema\": \"https://vega.github.io/schema/vega-lite/v4.8.1.json\", \"datasets\": {\"data-4b4be869d1dc7a0c576d391d05a07472\": [{\"metric\": \"Identifiers\", \"date\": \"2020-01-01T00:00:00\", \"count\": 141}, {\"metric\": \"Identifiers\", \"date\": \"2020-02-01T00:00:00\", \"count\": 149}, {\"metric\": \"Identifiers\", \"date\": \"2020-03-01T00:00:00\", \"count\": 188}, {\"metric\": \"Identifiers\", \"date\": \"2020-04-01T00:00:00\", \"count\": 350}, {\"metric\": \"Identifiers\", \"date\": \"2020-05-01T00:00:00\", \"count\": 624}, {\"metric\": \"Identifiers\", \"date\": \"2020-06-01T00:00:00\", \"count\": 594}, {\"metric\": \"Identifiers\", \"date\": \"2020-07-01T00:00:00\", \"count\": 664}, {\"metric\": \"Identifiers\", \"date\": \"2020-08-01T00:00:00\", \"count\": 620}, {\"metric\": \"Identifiers\", \"date\": \"2020-09-01T00:00:00\", \"count\": 497}, {\"metric\": \"Identifiers\", \"date\": \"2020-10-01T00:00:00\", \"count\": 317}, {\"metric\": \"Identifiers\", \"date\": \"2020-11-01T00:00:00\", \"count\": 216}, {\"metric\": \"Identifiers\", \"date\": \"2020-12-01T00:00:00\", \"count\": 206}]}}, {\"mode\": \"vega-lite\"});\n",
+       "</script>"
+      ],
+      "text/plain": [
+       "alt.Chart(...)"
+      ]
+     },
+     "execution_count": 7,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "identifiers_by_month = get_monthly_counts(get_observation_identifiers, 'Identifiers')\n",
+    "alt.Chart(identifiers_by_month).mark_bar().encode(x='month(date):T', y='count:Q')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "another-ambassador",
+   "metadata": {},
+   "source": [
+    "### Now, let's combine all four metrics into one chart"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "id": "serious-literacy",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "\n",
+       "<div id=\"altair-viz-3cc67bd28316416fbcd3b17a20015c28\"></div>\n",
+       "<script type=\"text/javascript\">\n",
+       "  (function(spec, embedOpt){\n",
+       "    let outputDiv = document.currentScript.previousElementSibling;\n",
+       "    if (outputDiv.id !== \"altair-viz-3cc67bd28316416fbcd3b17a20015c28\") {\n",
+       "      outputDiv = document.getElementById(\"altair-viz-3cc67bd28316416fbcd3b17a20015c28\");\n",
+       "    }\n",
+       "    const paths = {\n",
+       "      \"vega\": \"https://cdn.jsdelivr.net/npm//vega@5?noext\",\n",
+       "      \"vega-lib\": \"https://cdn.jsdelivr.net/npm//vega-lib?noext\",\n",
+       "      \"vega-lite\": \"https://cdn.jsdelivr.net/npm//vega-lite@4.8.1?noext\",\n",
+       "      \"vega-embed\": \"https://cdn.jsdelivr.net/npm//vega-embed@6?noext\",\n",
+       "    };\n",
+       "\n",
+       "    function loadScript(lib) {\n",
+       "      return new Promise(function(resolve, reject) {\n",
+       "        var s = document.createElement('script');\n",
+       "        s.src = paths[lib];\n",
+       "        s.async = true;\n",
+       "        s.onload = () => resolve(paths[lib]);\n",
+       "        s.onerror = () => reject(`Error loading script: ${paths[lib]}`);\n",
+       "        document.getElementsByTagName(\"head\")[0].appendChild(s);\n",
+       "      });\n",
+       "    }\n",
+       "\n",
+       "    function showError(err) {\n",
+       "      outputDiv.innerHTML = `<div class=\"error\" style=\"color:red;\">${err}</div>`;\n",
+       "      throw err;\n",
+       "    }\n",
+       "\n",
+       "    function displayChart(vegaEmbed) {\n",
+       "      vegaEmbed(outputDiv, spec, embedOpt)\n",
+       "        .catch(err => showError(`Javascript Error: ${err.message}<br>This usually means there's a typo in your chart specification. See the javascript console for the full traceback.`));\n",
+       "    }\n",
+       "\n",
+       "    if(typeof define === \"function\" && define.amd) {\n",
+       "      requirejs.config({paths});\n",
+       "      require([\"vega-embed\"], displayChart, err => showError(`Error loading script: ${err.message}`));\n",
+       "    } else if (typeof vegaEmbed === \"function\") {\n",
+       "      displayChart(vegaEmbed);\n",
+       "    } else {\n",
+       "      loadScript(\"vega\")\n",
+       "        .then(() => loadScript(\"vega-lite\"))\n",
+       "        .then(() => loadScript(\"vega-embed\"))\n",
+       "        .catch(showError)\n",
+       "        .then(() => displayChart(vegaEmbed));\n",
+       "    }\n",
+       "  })({\"config\": {\"view\": {\"continuousWidth\": 400, \"continuousHeight\": 300}, \"axis\": {\"labelFontSize\": 15, \"titleFontSize\": 20}}, \"data\": {\"name\": \"data-3dff2f88a8244810e126428de549aaa6\"}, \"mark\": \"line\", \"encoding\": {\"color\": {\"type\": \"nominal\", \"field\": \"metric\"}, \"strokeDash\": {\"type\": \"nominal\", \"field\": \"metric\"}, \"x\": {\"type\": \"temporal\", \"axis\": {\"title\": \"Month\"}, \"field\": \"date\", \"timeUnit\": \"month\"}, \"y\": {\"type\": \"quantitative\", \"axis\": {\"title\": \"Count\"}, \"field\": \"count\"}}, \"height\": 500, \"title\": \"iNaturalist activity in Alaska (2020)\", \"width\": 750, \"$schema\": \"https://vega.github.io/schema/vega-lite/v4.8.1.json\", \"datasets\": {\"data-3dff2f88a8244810e126428de549aaa6\": [{\"metric\": \"Observations\", \"date\": \"2020-01-01T00:00:00\", \"count\": 441}, {\"metric\": \"Observations\", \"date\": \"2020-02-01T00:00:00\", \"count\": 379}, {\"metric\": \"Observations\", \"date\": \"2020-03-01T00:00:00\", \"count\": 688}, {\"metric\": \"Observations\", \"date\": \"2020-04-01T00:00:00\", \"count\": 2694}, {\"metric\": \"Observations\", \"date\": \"2020-05-01T00:00:00\", \"count\": 5970}, {\"metric\": \"Observations\", \"date\": \"2020-06-01T00:00:00\", \"count\": 6344}, {\"metric\": \"Observations\", \"date\": \"2020-07-01T00:00:00\", \"count\": 7783}, {\"metric\": \"Observations\", \"date\": \"2020-08-01T00:00:00\", \"count\": 7152}, {\"metric\": \"Observations\", \"date\": \"2020-09-01T00:00:00\", \"count\": 4756}, {\"metric\": \"Observations\", \"date\": \"2020-10-01T00:00:00\", \"count\": 1547}, {\"metric\": \"Observations\", \"date\": \"2020-11-01T00:00:00\", \"count\": 985}, {\"metric\": \"Observations\", \"date\": \"2020-12-01T00:00:00\", \"count\": 1514}, {\"metric\": \"Taxa\", \"date\": \"2020-01-01T00:00:00\", \"count\": 196}, {\"metric\": \"Taxa\", \"date\": \"2020-02-01T00:00:00\", \"count\": 179}, {\"metric\": \"Taxa\", \"date\": \"2020-03-01T00:00:00\", \"count\": 324}, {\"metric\": \"Taxa\", \"date\": \"2020-04-01T00:00:00\", \"count\": 800}, {\"metric\": \"Taxa\", \"date\": \"2020-05-01T00:00:00\", \"count\": 1372}, {\"metric\": \"Taxa\", \"date\": \"2020-06-01T00:00:00\", \"count\": 1502}, {\"metric\": \"Taxa\", \"date\": \"2020-07-01T00:00:00\", \"count\": 1728}, {\"metric\": \"Taxa\", \"date\": \"2020-08-01T00:00:00\", \"count\": 1605}, {\"metric\": \"Taxa\", \"date\": \"2020-09-01T00:00:00\", \"count\": 1268}, {\"metric\": \"Taxa\", \"date\": \"2020-10-01T00:00:00\", \"count\": 650}, {\"metric\": \"Taxa\", \"date\": \"2020-11-01T00:00:00\", \"count\": 413}, {\"metric\": \"Taxa\", \"date\": \"2020-12-01T00:00:00\", \"count\": 555}, {\"metric\": \"Observers\", \"date\": \"2020-01-01T00:00:00\", \"count\": 44}, {\"metric\": \"Observers\", \"date\": \"2020-02-01T00:00:00\", \"count\": 43}, {\"metric\": \"Observers\", \"date\": \"2020-03-01T00:00:00\", \"count\": 79}, {\"metric\": \"Observers\", \"date\": \"2020-04-01T00:00:00\", \"count\": 147}, {\"metric\": \"Observers\", \"date\": \"2020-05-01T00:00:00\", \"count\": 376}, {\"metric\": \"Observers\", \"date\": \"2020-06-01T00:00:00\", \"count\": 496}, {\"metric\": \"Observers\", \"date\": \"2020-07-01T00:00:00\", \"count\": 572}, {\"metric\": \"Observers\", \"date\": \"2020-08-01T00:00:00\", \"count\": 599}, {\"metric\": \"Observers\", \"date\": \"2020-09-01T00:00:00\", \"count\": 439}, {\"metric\": \"Observers\", \"date\": \"2020-10-01T00:00:00\", \"count\": 187}, {\"metric\": \"Observers\", \"date\": \"2020-11-01T00:00:00\", \"count\": 91}, {\"metric\": \"Observers\", \"date\": \"2020-12-01T00:00:00\", \"count\": 54}, {\"metric\": \"Identifiers\", \"date\": \"2020-01-01T00:00:00\", \"count\": 141}, {\"metric\": \"Identifiers\", \"date\": \"2020-02-01T00:00:00\", \"count\": 149}, {\"metric\": \"Identifiers\", \"date\": \"2020-03-01T00:00:00\", \"count\": 188}, {\"metric\": \"Identifiers\", \"date\": \"2020-04-01T00:00:00\", \"count\": 350}, {\"metric\": \"Identifiers\", \"date\": \"2020-05-01T00:00:00\", \"count\": 624}, {\"metric\": \"Identifiers\", \"date\": \"2020-06-01T00:00:00\", \"count\": 594}, {\"metric\": \"Identifiers\", \"date\": \"2020-07-01T00:00:00\", \"count\": 664}, {\"metric\": \"Identifiers\", \"date\": \"2020-08-01T00:00:00\", \"count\": 620}, {\"metric\": \"Identifiers\", \"date\": \"2020-09-01T00:00:00\", \"count\": 497}, {\"metric\": \"Identifiers\", \"date\": \"2020-10-01T00:00:00\", \"count\": 317}, {\"metric\": \"Identifiers\", \"date\": \"2020-11-01T00:00:00\", \"count\": 216}, {\"metric\": \"Identifiers\", \"date\": \"2020-12-01T00:00:00\", \"count\": 206}]}}, {\"mode\": \"vega-lite\"});\n",
+       "</script>"
+      ],
+      "text/plain": [
+       "alt.Chart(...)"
+      ]
+     },
+     "execution_count": 8,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "combined_results = observations_by_month.append([taxa_by_month, observers_by_month, identifiers_by_month])\n",
+    "\n",
+    "\n",
+    "alt.Chart(\n",
+    "    combined_results,\n",
+    "    title=f'iNaturalist activity in {PLACE_NAME} ({YEAR})',\n",
+    "    width=750,\n",
+    "    height=500,\n",
+    ").mark_line().encode(\n",
+    "    alt.X('month(date):T', axis=alt.Axis(title=\"Month\")),\n",
+    "    alt.Y('count:Q', axis=alt.Axis(title=\"Count\")),\n",
+    "    color='metric',\n",
+    "    strokeDash='metric',\n",
+    ").configure_axis(\n",
+    "    labelFontSize=15,\n",
+    "    titleFontSize=20,\n",
+    ")"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.8.6"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/Data-Visualizations-Seaborn.ipynb b/examples/Data Visualizations - Seaborn.ipynb
old mode 100644
new mode 100755
similarity index 99%
rename from examples/Data-Visualizations-Seaborn.ipynb
rename to examples/Data Visualizations - Seaborn.ipynb
index 2be8563..907246b
--- a/examples/Data-Visualizations-Seaborn.ipynb
+++ b/examples/Data Visualizations - Seaborn.ipynb
@@ -1,5 +1,13 @@
 {
  "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Seaborn examples\n",
+    "Here are some examples of visualizations that can be created using [Seaborn](seaborn.pydata.org)."
+   ]
+  },
   {
    "cell_type": "code",
    "execution_count": null,
diff --git a/examples/Dockerfile b/examples/Dockerfile
index 09a81f1..9ddcea9 100644
--- a/examples/Dockerfile
+++ b/examples/Dockerfile
@@ -1,13 +1,22 @@
 FROM jupyter/scipy-notebook
 USER root
 
-# Install a few more conda packages, and install any non-conda pip packages last
+# Install some more conda packages useful for data exploration & visualization
 RUN conda install --quiet --yes \
     'altair=4.*' \
     'dash=1.*' \
-    'pip=20.3.*' && \
+    'gdal=3.*' \
+    'geoviews' \
+    'geopandas' \
+    'pip=21.*' \
+    'plotly=4.*' \
+    'python-dateutil' \
+    'python-forge' \
+    'requests=2.25.*' \
+    'xarray' && \
     conda clean --all -f -y && \
-    pip install "pyinaturalist~=0.11" && \
+    # Install any non-conda pip packages last
+    pip install pyinaturalist && \
     fix-permissions "${CONDA_DIR}" && \
     fix-permissions "/home/${NB_USER}"
 
diff --git a/pyinaturalist/node_api.py b/pyinaturalist/node_api.py
index d88bfb9..1d35a09 100644
--- a/pyinaturalist/node_api.py
+++ b/pyinaturalist/node_api.py
@@ -225,7 +225,6 @@ def get_observation_histogram(user_agent: str = None, **params) -> HistogramResp
     """
     r = node_api_get('observations/histogram', params=params, user_agent=user_agent)
     r.raise_for_status()
-    print(r.json())
     return format_histogram(r.json())
 
 
diff --git a/pyinaturalist/request_params.py b/pyinaturalist/request_params.py
index 0267228..b4a0c96 100644
--- a/pyinaturalist/request_params.py
+++ b/pyinaturalist/request_params.py
@@ -2,11 +2,12 @@
 import warnings
 from datetime import date, datetime
 from dateutil.parser import parse as parse_timestamp
+from dateutil.relativedelta import relativedelta
 from dateutil.tz import tzlocal
 from io import BytesIO
 from logging import getLogger
 from os.path import abspath, expanduser
-from typing import Any, BinaryIO, Dict, Iterable, Optional, Tuple
+from typing import Any, BinaryIO, Dict, Iterable, List, Optional, Tuple
 
 from pyinaturalist.constants import FileOrPath, RequestParams
 
@@ -183,38 +184,12 @@ def convert_datetime_params(params: RequestParams) -> RequestParams:
     for k, v in params.items():
         if isinstance(v, datetime) or isinstance(v, date):
             params[k] = _isoformat(v)
-        if v is not None and k in DATETIME_PARAMS:
+        elif v is not None and k in DATETIME_PARAMS:
             params[k] = _isoformat(parse_timestamp(v))
 
     return params
 
 
-def ensure_file_obj(photo: FileOrPath) -> BinaryIO:
-    """Given a file objects or path, read it into a file-like object if it's a path"""
-    if isinstance(photo, str):
-        file_path = abspath(expanduser(photo))
-        logger.info(f'Reading from file: {file_path}')
-        with open(file_path, 'rb') as f:
-            return BytesIO(f.read())
-    return photo
-
-
-def ensure_file_objs(photos: Iterable[FileOrPath]) -> Iterable[BinaryIO]:
-    """Given one or more file objects and/or paths, read any paths into a file-like object"""
-    return [ensure_file_obj(photo) for photo in ensure_list(photos)]
-
-
-def ensure_list(values: Any):
-    """If the value is a string or comma-separated list of values, convert it into a list"""
-    if not values:
-        values = []
-    elif isinstance(values, str) and ',' in values:
-        values = values.split(',')
-    elif not isinstance(values, list):
-        values = [values]
-    return values
-
-
 def convert_list(obj: Any) -> str:
     """Convert list parameters into an API-compatible (comma-delimited) string"""
     if not obj:
@@ -243,9 +218,66 @@ def convert_observation_fields(params: RequestParams) -> RequestParams:
     return params
 
 
+def get_interval_ranges(
+    start_date: datetime, end_date: datetime, interval='monthly'
+) -> List[Tuple[datetime, datetime]]:
+    """Get a list of date ranges between ``start_date`` and ``end_date`` with the specified interval
+    Example:
+        >>> # Get date ranges representing months of a year
+        >>> get_interval_ranges(datetime(2020, 1, 1), datetime(2020, 12, 31), 'monthly')
+
+    Args:
+        start_date: Starting date of date ranges (inclusive)
+        end_date: End date of date ranges (inclusive)
+        interval: Either 'monthly' or 'yearly'
+
+    Returns:
+        List of date ranges in the format: ``[(start_date, end_date), (start_date, end_date), ...]``
+    """
+    if interval == 'monthly':
+        delta = relativedelta(months=1)
+    elif interval == 'yearly':
+        delta = relativedelta(years=1)
+    else:
+        raise ValueError(f'Invalid interval: {interval}')
+
+    incremental_date = start_date
+    interval_ranges = []
+    while incremental_date <= end_date:
+        interval_ranges.append((incremental_date, incremental_date + delta - relativedelta(days=1)))
+        incremental_date += delta
+    return interval_ranges
+
+
+def ensure_file_obj(photo: FileOrPath) -> BinaryIO:
+    """Given a file objects or path, read it into a file-like object if it's a path"""
+    if isinstance(photo, str):
+        file_path = abspath(expanduser(photo))
+        logger.info(f'Reading from file: {file_path}')
+        with open(file_path, 'rb') as f:
+            return BytesIO(f.read())
+    return photo
+
+
+def ensure_file_objs(photos: Iterable[FileOrPath]) -> Iterable[BinaryIO]:
+    """Given one or more file objects and/or paths, read any paths into a file-like object"""
+    return [ensure_file_obj(photo) for photo in ensure_list(photos)]
+
+
+def ensure_list(values: Any):
+    """If the value is a string or comma-separated list of values, convert it into a list"""
+    if not values:
+        values = []
+    elif isinstance(values, str) and ',' in values:
+        values = values.split(',')
+    elif not isinstance(values, list):
+        values = [values]
+    return values
+
+
 def strip_empty_params(params: RequestParams) -> RequestParams:
     """Remove any request parameters with empty or ``None`` values."""
-    return {k: v for k, v in params.items() if v or v is False}
+    return {k: v for k, v in params.items() if v or v in [False, 0, 0.0]}
 
 
 def is_int(value: Any) -> bool:
diff --git a/setup.py b/setup.py
index e354dd7..a842bc4 100644
--- a/setup.py
+++ b/setup.py
@@ -48,7 +48,7 @@ setup(
         'keyring~=21.4.0',
         'python-dateutil>=2.0',
         'python-forge',
-        'requests>=2.24.0',
+        'requests~=2.25.0',
     ],
     extras_require=extras_require,
     zip_safe=False,


Test patch:
diff --git a/test/test_request_params.py b/test/test_request_params.py
index f361808..ef82621 100644
--- a/test/test_request_params.py
+++ b/test/test_request_params.py
@@ -14,6 +14,7 @@ from pyinaturalist.request_params import (
     convert_list_params,
     convert_observation_fields,
     ensure_file_obj,
+    get_interval_ranges,
     preprocess_request_params,
     strip_empty_params,
     validate_ids,
@@ -88,6 +89,48 @@ def test_ensure_file_obj__file_obj():
     assert ensure_file_obj(file_obj) == file_obj
 
 
+def test_get_interval_ranges__monthly():
+    expected_ranges = [
+        (datetime(2020, 1, 1, 0, 0), datetime(2020, 1, 31, 0, 0)),
+        (datetime(2020, 2, 1, 0, 0), datetime(2020, 2, 29, 0, 0)),
+        (datetime(2020, 3, 1, 0, 0), datetime(2020, 3, 31, 0, 0)),
+        (datetime(2020, 4, 1, 0, 0), datetime(2020, 4, 30, 0, 0)),
+        (datetime(2020, 5, 1, 0, 0), datetime(2020, 5, 31, 0, 0)),
+        (datetime(2020, 6, 1, 0, 0), datetime(2020, 6, 30, 0, 0)),
+        (datetime(2020, 7, 1, 0, 0), datetime(2020, 7, 31, 0, 0)),
+        (datetime(2020, 8, 1, 0, 0), datetime(2020, 8, 31, 0, 0)),
+        (datetime(2020, 9, 1, 0, 0), datetime(2020, 9, 30, 0, 0)),
+        (datetime(2020, 10, 1, 0, 0), datetime(2020, 10, 31, 0, 0)),
+        (datetime(2020, 11, 1, 0, 0), datetime(2020, 11, 30, 0, 0)),
+        (datetime(2020, 12, 1, 0, 0), datetime(2020, 12, 31, 0, 0)),
+    ]
+    ranges = get_interval_ranges(datetime(2020, 1, 1), datetime(2020, 12, 31), 'monthly')
+    assert ranges == expected_ranges
+
+
+def test_get_interval_ranges__yearly():
+    expected_ranges = [
+        (datetime(2010, 1, 1, 0, 0), datetime(2010, 12, 31, 0, 0)),
+        (datetime(2011, 1, 1, 0, 0), datetime(2011, 12, 31, 0, 0)),
+        (datetime(2012, 1, 1, 0, 0), datetime(2012, 12, 31, 0, 0)),
+        (datetime(2013, 1, 1, 0, 0), datetime(2013, 12, 31, 0, 0)),
+        (datetime(2014, 1, 1, 0, 0), datetime(2014, 12, 31, 0, 0)),
+        (datetime(2015, 1, 1, 0, 0), datetime(2015, 12, 31, 0, 0)),
+        (datetime(2016, 1, 1, 0, 0), datetime(2016, 12, 31, 0, 0)),
+        (datetime(2017, 1, 1, 0, 0), datetime(2017, 12, 31, 0, 0)),
+        (datetime(2018, 1, 1, 0, 0), datetime(2018, 12, 31, 0, 0)),
+        (datetime(2019, 1, 1, 0, 0), datetime(2019, 12, 31, 0, 0)),
+        (datetime(2020, 1, 1, 0, 0), datetime(2020, 12, 31, 0, 0)),
+    ]
+    ranges = get_interval_ranges(datetime(2010, 1, 1), datetime(2020, 1, 1), 'yearly')
+    assert ranges == expected_ranges
+
+
+def test_get_interval_ranges__invalid():
+    with pytest.raises(ValueError):
+        get_interval_ranges(datetime(2020, 1, 1), datetime(2020, 12, 31), 'daily')
+
+
 def test_strip_empty_params():
     params = strip_empty_params(TEST_PARAMS)
     assert len(params) == 6