Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
321 changes: 321 additions & 0 deletions docs/usecases/01-mobility-pulse.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": "<!--\n Licensed to the Apache Software Foundation (ASF) under one\n or more contributor license agreements. See the NOTICE file\n distributed with this work for additional information\n regarding copyright ownership. The ASF licenses this file\n to you under the Apache License, Version 2.0 (the\n \"License\"); you may not use this file except in compliance\n with the License. You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing,\n software distributed under the License is distributed on an\n \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY\n KIND, either express or implied. See the License for the\n specific language governing permissions and limitations\n under the License.\n-->\n\n# The pulse of NYC yellow-taxi pickups\n\nA complete vector workflow on a real, public dataset. We answer:\n\n> **Which NYC zones are busiest at which hour, and what are the dominant origin-destination flows?**\n\nThis notebook reads one month of NYC yellow-taxi trips (~3M rows, 50 MB parquet) from the public TLC mirror, joins them to taxi-zone polygons, finds each zone's peak hour, derives the top origin→destination flows as `ST_MakeLine` geometries on zone centroids, bins zone activity into Bing tiles, writes the per-zone result as **GeoParquet 1.1**, and renders the whole thing in **SedonaKepler**.\n\nThe 3M rows are the *input*. The first `groupBy` collapses them to ~5,300 `(zone, hour)` buckets, and everything downstream operates at the 263-zone scale — so the notebook fits comfortably in this single-container docker image with `DRIVER_MEM=4g` and finishes in well under two minutes once the parquet is downloaded. The same SQL would run unchanged on a year of data on a real cluster; only the `master(...)` URL changes.\n\n**Data sources** — taxi-zone polygons ship with the docker image (NYC TLC, public, attribution: NYC Taxi & Limousine Commission). Trip data is fetched at runtime from the [TLC Cloudfront mirror](https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2024-01.parquet) hyperlinked from the [TLC trip record data page](https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page) (the canonical publishing source). See `data/README.md` for full per-file provenance and licensing.\n\n<!-- requires-network: true -->\n\n*This notebook requires outbound network access to download the trip parquet (~50 MB). Set `SEDONA_NOTEBOOK_OFFLINE=1` to skip it under sandboxed CI.*"
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 1. Connect to Spark through SedonaContext\n",
"\n",
"`SedonaContext.create(...)` registers the Sedona spatial functions on top of an existing Spark session and returns it. Everything below is plain Spark SQL with the `ST_*` and `RS_*` catalog enabled."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from sedona.spark import SedonaContext\n",
"\n",
"config = SedonaContext.builder().master(\"spark://localhost:7077\").getOrCreate()\n",
"sedona = SedonaContext.create(config)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 2. Load taxi-zone polygons and reproject to WGS84\n",
"\n",
"The shipped shapefile is in **EPSG:2263** (NAD83 / NY State Plane Long Island, US-feet). For tile binning and Kepler we want lon/lat. Since 1.9, `ST_Transform` is backed by **proj4sedona** and accepts EPSG codes, full WKT2, PROJ strings, and PROJJSON interchangeably."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"zones_raw = sedona.read.format(\"shapefile\").load(\"data/nyc_taxi_zones\")\n",
"zones = zones_raw.selectExpr(\n",
" \"LocationID as location_id\",\n",
" \"zone\",\n",
" \"borough\",\n",
" \"ST_Transform(geometry, 'epsg:2263', 'epsg:4326') as geometry\",\n",
")\n",
"zones.cache()\n",
"print(f\"{zones.count()} taxi zones\")\n",
"zones.show(5, truncate=40)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 3. Pull one month of yellow-taxi trips\n",
"\n",
"Spark cannot currently read Parquet directly from an `https://` URL through its Hadoop FileSystem layer, so we cache the file to `/tmp` once and point Spark at the local copy. The TLC keeps `pickup_longitude`/`pickup_latitude` out of recent releases for privacy, so trips are zoned by `PULocationID`/`DOLocationID` — those join to the polygons above by ID."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import os\n",
"import urllib.request\n",
"\n",
"TRIP_URL = (\n",
" \"https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2024-01.parquet\"\n",
")\n",
"TRIP_PATH = \"/tmp/yellow_tripdata_2024-01.parquet\"\n",
"\n",
"if not os.path.exists(TRIP_PATH):\n",
" print(f\"downloading {TRIP_URL} ...\")\n",
" urllib.request.urlretrieve(TRIP_URL, TRIP_PATH)\n",
"\n",
"trips = sedona.read.parquet(TRIP_PATH).selectExpr(\n",
" \"PULocationID as pu_zone\",\n",
" \"DOLocationID as do_zone\",\n",
" \"hour(tpep_pickup_datetime) as pickup_hour\",\n",
" \"tpep_pickup_datetime as pickup_ts\",\n",
")\n",
"trips = trips.where(\"pickup_ts >= '2024-01-01' AND pickup_ts < '2024-02-01'\")\n",
"print(f\"{trips.count():,} trips\")\n",
"trips.show(5)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 4. Find each zone's peak hour\n",
"\n",
"Aggregate trips per `(zone, hour)` and use `MAX_BY` to pull the busiest hour per zone in a single pass. We label each zone with a coarse bucket (morning / midday / evening / nightlife) so the map at the end is read-at-a-glance."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"trips.createOrReplaceTempView(\"trips\")\n",
"\n",
"zone_hour = sedona.sql(\"\"\"\n",
" SELECT pu_zone, pickup_hour, COUNT(*) AS trips\n",
" FROM trips\n",
" GROUP BY pu_zone, pickup_hour\n",
"\"\"\")\n",
"zone_hour.createOrReplaceTempView(\"zone_hour\")\n",
"\n",
"zone_profile = sedona.sql(\"\"\"\n",
" SELECT pu_zone,\n",
" SUM(trips) AS total_trips,\n",
" MAX_BY(pickup_hour, trips) AS peak_hour,\n",
" CASE\n",
" WHEN MAX_BY(pickup_hour, trips) BETWEEN 5 AND 10 THEN 'morning'\n",
" WHEN MAX_BY(pickup_hour, trips) BETWEEN 11 AND 15 THEN 'midday'\n",
" WHEN MAX_BY(pickup_hour, trips) BETWEEN 16 AND 19 THEN 'evening'\n",
" ELSE 'nightlife'\n",
" END AS peak_bucket\n",
" FROM zone_hour\n",
" GROUP BY pu_zone\n",
"\"\"\")\n",
"zone_profile.show(10)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 5. Bin each zone's centroid into a Bing-tile quadkey\n",
"\n",
"`ST_BingTileAt(longitude, latitude, zoom)` is new in 1.9 and produces a quadkey string for the Bing tile that contains a point. Combined with `ST_Centroid`, it gives a free \"which neighborhood-sized tile is this zone in?\" feature without bringing in an external H3/S2 library."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"zones.createOrReplaceTempView(\"zones\")\n",
"zone_profile.createOrReplaceTempView(\"zone_profile\")\n",
"\n",
"zones_with_pulse = sedona.sql(\"\"\"\n",
" SELECT z.location_id, z.zone, z.borough,\n",
" p.total_trips, p.peak_hour, p.peak_bucket,\n",
" z.geometry,\n",
" ST_Centroid(z.geometry) AS centroid,\n",
" ST_BingTileAt(ST_X(ST_Centroid(z.geometry)),\n",
" ST_Y(ST_Centroid(z.geometry)), 14) AS tile_quadkey\n",
" FROM zones z\n",
" JOIN zone_profile p ON z.location_id = p.pu_zone\n",
"\"\"\")\n",
"zones_with_pulse.cache()\n",
"zones_with_pulse.select(\n",
" \"zone\", \"borough\", \"total_trips\", \"peak_hour\", \"peak_bucket\", \"tile_quadkey\"\n",
").show(10, truncate=30)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 6. Build top origin-destination flow lines\n",
"\n",
"Take the 30 most-travelled `(origin, destination)` zone pairs and draw a `LineString` from origin centroid to destination centroid. `ST_MakeLine(ST_Centroid(...), ST_Centroid(...))` is the idiomatic way to derive flow geometries from a zone-attributed OD matrix."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"od = sedona.sql(\"\"\"\n",
" SELECT pu_zone, do_zone, COUNT(*) AS trips\n",
" FROM trips\n",
" WHERE pu_zone <> do_zone\n",
" GROUP BY pu_zone, do_zone\n",
" ORDER BY trips DESC\n",
" LIMIT 30\n",
"\"\"\")\n",
"od.createOrReplaceTempView(\"od\")\n",
"\n",
"flows = sedona.sql(\"\"\"\n",
" SELECT od.pu_zone, od.do_zone, od.trips,\n",
" pu.zone AS pu_name, do_.zone AS do_name,\n",
" ST_MakeLine(ST_Centroid(pu.geometry),\n",
" ST_Centroid(do_.geometry)) AS flow_geom\n",
" FROM od\n",
" JOIN zones pu ON pu.location_id = od.pu_zone\n",
" JOIN zones do_ ON do_.location_id = od.do_zone\n",
"\"\"\")\n",
"flows.select(\"pu_name\", \"do_name\", \"trips\").show(10, truncate=30)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 7. Persist per-zone aggregates as GeoParquet 1.1\n",
"\n",
"Sedona auto-populates **covering-bbox metadata** and **projjson CRS** from the geometry SRID; downstream readers can filter on a bounding box without scanning every row. We round-trip through a read-back to confirm the schema lands."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import shutil\n",
"\n",
"out_path = \"/tmp/nyc_taxi_zone_pulse.parquet\"\n",
"if os.path.exists(out_path):\n",
" shutil.rmtree(out_path)\n",
"\n",
"(\n",
" zones_with_pulse.select(\n",
" \"location_id\",\n",
" \"zone\",\n",
" \"borough\",\n",
" \"total_trips\",\n",
" \"peak_hour\",\n",
" \"peak_bucket\",\n",
" \"tile_quadkey\",\n",
" \"geometry\",\n",
" )\n",
" .coalesce(1)\n",
" .write.format(\"geoparquet\")\n",
" .save(out_path)\n",
")\n",
"\n",
"sedona.read.format(\"geoparquet\").load(out_path).select(\n",
" \"zone\", \"borough\", \"total_trips\", \"peak_bucket\"\n",
").show(5, truncate=30)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 8. Render three layers on a single SedonaKepler map\n",
"\n",
"- **`zones_by_peak`** — zone polygons coloured by peak-hour bucket\n",
"- **`zone_centroids`** — points scaled by trip count\n",
"- **`top_od_flows`** — the busiest 30 origin-destination flowlines\n",
"\n",
"All three layers share the same `geometry` column convention; `SedonaKepler.add_df` handles the Spark-to-Kepler conversion under the hood."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from sedona.spark import SedonaKepler\n",
"\n",
"zones_layer = sedona.read.format(\"geoparquet\").load(out_path).drop(\"tile_quadkey\")\n",
"centroids_layer = zones_with_pulse.selectExpr(\n",
" \"zone\", \"borough\", \"total_trips\", \"peak_bucket\", \"centroid as geometry\"\n",
")\n",
"flows_layer = flows.selectExpr(\"pu_name\", \"do_name\", \"trips\", \"flow_geom as geometry\")\n",
"\n",
"kepler_map = SedonaKepler.create_map(df=zones_layer, name=\"zones_by_peak\")\n",
"SedonaKepler.add_df(kepler_map, centroids_layer, name=\"zone_centroids\")\n",
"SedonaKepler.add_df(kepler_map, flows_layer, name=\"top_od_flows\")\n",
"kepler_map"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## What just happened?\n",
"\n",
"We turned 3 million raw trip records into a labelled spatial dataset in eight steps:\n",
"\n",
"1. Reprojected zone polygons from NY State Plane to WGS84 with `ST_Transform` (proj4sedona).\n",
"2. Pulled a month of TLC trips from the public Cloudfront mirror.\n",
"3. Aggregated by `(zone, hour)` and bucketed each zone's peak hour with a single `MAX_BY` window.\n",
"4. Bing-tiled zone centroids via the new `ST_BingTileAt`.\n",
"5. Built the top-30 origin-destination flow geometries with `ST_MakeLine` over `ST_Centroid` outputs.\n",
"6. Wrote the per-zone result as **GeoParquet 1.1** with auto covering-bbox metadata, then read it back.\n",
"7. Visualized zones, centroids, and flowlines as three layers on a single SedonaKepler map.\n",
"\n",
"Sedona ran the same SQL on a single laptop here that it would run across a thousand-node cluster on a year of TLC data — no API change, just more workers."
]
}
],
"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.11"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
Loading
Loading