{"id":1574,"date":"2026-10-27T08:38:07","date_gmt":"2026-10-27T08:38:07","guid":{"rendered":"https:\/\/helpwithmygisassignment.com\/blog\/?p=1574"},"modified":"2026-10-27T08:38:23","modified_gmt":"2026-10-27T08:38:23","slug":"multi-modal-public-transit-isochrones","status":"publish","type":"post","link":"https:\/\/helpwithmygisassignment.com\/blog\/multi-modal-public-transit-isochrones\/","title":{"rendered":"Building Multi-Modal Public Transit Isochrones with PostGIS and OpenTripPlanner"},"content":{"rendered":"<div><span style=\"font-family: inherit; font-size: 42px; font-style: inherit;\">Building Multi-Modal Public Transit Isochrones with PostGIS and OpenTripPlanner<\/span><\/div>\n<div>\n<div>\n<div id=\"ez-toc-container\" class=\"ez-toc-v2_0_89 counter-hierarchy ez-toc-counter ez-toc-grey ez-toc-container-direction\">\n<div class=\"ez-toc-title-container\">\n<p class=\"ez-toc-title\" style=\"cursor:inherit\">Table of Contents<\/p>\n<span class=\"ez-toc-title-toggle\"><a href=\"#\" class=\"ez-toc-pull-right ez-toc-btn ez-toc-btn-xs ez-toc-btn-default ez-toc-toggle\" aria-label=\"Toggle Table of Content\"><span class=\"ez-toc-js-icon-con\"><span class=\"\"><span class=\"eztoc-hide\" style=\"display:none;\">Toggle<\/span><span class=\"ez-toc-icon-toggle-span\"><svg style=\"fill: #999;color:#999\" xmlns=\"http:\/\/www.w3.org\/2000\/svg\" class=\"list-377408\" width=\"20px\" height=\"20px\" viewBox=\"0 0 24 24\" fill=\"none\"><path d=\"M6 6H4v2h2V6zm14 0H8v2h12V6zM4 11h2v2H4v-2zm16 0H8v2h12v-2zM4 16h2v2H4v-2zm16 0H8v2h12v-2z\" fill=\"currentColor\"><\/path><\/svg><svg style=\"fill: #999;color:#999\" class=\"arrow-unsorted-368013\" xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"10px\" height=\"10px\" viewBox=\"0 0 24 24\" version=\"1.2\" baseProfile=\"tiny\"><path d=\"M18.2 9.3l-6.2-6.3-6.2 6.3c-.2.2-.3.4-.3.7s.1.5.3.7c.2.2.4.3.7.3h11c.3 0 .5-.1.7-.3.2-.2.3-.5.3-.7s-.1-.5-.3-.7zM5.8 14.7l6.2 6.3 6.2-6.3c.2-.2.3-.5.3-.7s-.1-.5-.3-.7c-.2-.2-.4-.3-.7-.3h-11c-.3 0-.5.1-.7.3-.2.2-.3.5-.3.7s.1.5.3.7z\"\/><\/svg><\/span><\/span><\/span><\/a><\/span><\/div>\n<nav><ul class='ez-toc-list ez-toc-list-level-1 ' ><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-1\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/multi-modal-public-transit-isochrones\/#Introduction\" >Introduction<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-2\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/multi-modal-public-transit-isochrones\/#1_Architecture_and_data_flow_for_Multi-Modal_Public_Transit_Isochrones\" >1. Architecture and data flow for Multi-Modal Public Transit Isochrones<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-3\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/multi-modal-public-transit-isochrones\/#2_Assemble_and_validate_the_input_data\" >2. Assemble and validate the input data<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-4\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/multi-modal-public-transit-isochrones\/#3_Configure_OpenTripPlanner\" >3. Configure OpenTripPlanner<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-5\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/multi-modal-public-transit-isochrones\/#4_Generate_the_destination_grid_with_Python\" >4. Generate the destination grid with Python<\/a><ul class='ez-toc-list-level-3' ><li class='ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-6\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/multi-modal-public-transit-isochrones\/#Install_the_dependencies\" >Install the dependencies<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-7\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/multi-modal-public-transit-isochrones\/#Complete_script_generate_transit_gridpy\" >Complete script: generate_transit_grid.py<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-3'><a class=\"ez-toc-link ez-toc-heading-8\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/multi-modal-public-transit-isochrones\/#Run_the_grid_generator\" >Run the grid generator<\/a><\/li><\/ul><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-9\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/multi-modal-public-transit-isochrones\/#5_Query_OTP_with_Python\" >5. Query OTP with Python<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-10\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/multi-modal-public-transit-isochrones\/#6_Store_and_analyze_the_samples_in_PostGIS\" >6. Store and analyze the samples in PostGIS<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-11\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/multi-modal-public-transit-isochrones\/#7_Validate_the_catchments\" >7. Validate the catchments<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-12\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/multi-modal-public-transit-isochrones\/#Image_placeholder\" >Image placeholder<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-13\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/multi-modal-public-transit-isochrones\/#SEO_best_practices_and_internal_linking\" >SEO best practices and internal linking<\/a><\/li><li class='ez-toc-page-1 ez-toc-heading-level-2'><a class=\"ez-toc-link ez-toc-heading-14\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/multi-modal-public-transit-isochrones\/#Summary\" >Summary<\/a><\/li><\/ul><\/nav><\/div>\n<h2><span class=\"ez-toc-section\" id=\"Introduction\"><\/span>Introduction<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>A 15-minute public transport catchment is not simply a circle around a bus stop. A traveler might walk to a stop, wait for a scheduled bus, transfer to rail, and walk the final block. The same destination may be reachable in 20 minutes at 08:00 but take 40 minutes later in the evening.<\/p>\n<p>Accurate isochrones must therefore consider street-network connectivity, transit schedules, transfers, walking access, and departure time. Combining these factors is essential for identifying underserved neighborhoods, evaluating access to employment, and measuring how easily residents can reach hospitals and schools.<\/p>\n<p>This guide develops an open-source workflow using OpenStreetMap (OSM), General Transit Feed Specification (GTFS) schedules, OpenTripPlanner (OTP), Python, and PostGIS.<\/p>\n<p><strong>Technical distinction:<\/strong> OTP 2 is a journey-planning engine rather than a native isochrone-polygon generator. The workflow below samples travel times to grid locations, then polygonizes reachable cells in PostGIS. The result is an approximate, grid-resolution-dependent catchment rather than an exact representation of every reachable point.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"1_Architecture_and_data_flow_for_Multi-Modal_Public_Transit_Isochrones\"><\/span>1. Architecture and data flow for Multi-Modal Public Transit Isochrones<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-large wp-image-1695\" src=\"https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_k5j7lyk5j7lyk5j7-1024x559.jpg\" alt=\"multi-modal public transit isochrones architecture and data flow diagram\" width=\"1024\" height=\"559\" srcset=\"https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_k5j7lyk5j7lyk5j7-1024x559.jpg 1024w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_k5j7lyk5j7lyk5j7-300x164.jpg 300w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_k5j7lyk5j7lyk5j7-768x419.jpg 768w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_k5j7lyk5j7lyk5j7.jpg 1408w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/p>\n<p>The workflow contains four components:<\/p>\n<ol start=\"1\">\n<li><strong>Network inputs:<\/strong> GTFS supplies transit stops, routes, trips, schedules, and service calendars. OSM supplies routable streets, footways, cycleways, and other mapped connections.<\/li>\n<li><strong>Routing engine:<\/strong> OTP builds a graph that connects the transit network to accessible streets and answers journey-planning requests through its GraphQL API.<\/li>\n<li><strong>Python processing:<\/strong> Python creates a regular destination grid, queries OTP for travel durations, and associates the results with spatial cells.<\/li>\n<li><strong>Spatial analysis:<\/strong> PostGIS stores the cell geometries, creates time-threshold polygons, and intersects them with population, healthcare, education, and employment datasets.<\/li>\n<\/ol>\n<p>This separation keeps routing, orchestration, and spatial analysis independent. Record the graph version, GTFS feed dates, OSM snapshot, origin, departure time, permitted modes, and grid size for each run.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"2_Assemble_and_validate_the_input_data\"><\/span>2. Assemble and validate the input data<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Download GTFS Schedule ZIP files from the transport operators or relevant transport authority. Include all significant services in the study area, such as buses, commuter rail, metro, tram, and ferry where applicable.<\/p>\n<p>Validate the feeds using the <a href=\"https:\/\/gtfs.org\/getting-started\/validate\/\" target=\"_blank\" rel=\"noopener\">MobilityData GTFS Schedule Validator<\/a>. Inspect <code>stops.txt<\/code>, <code>routes.txt<\/code>, <code>trips.txt<\/code>, <code>stop_times.txt<\/code>, <code>calendar.txt<\/code>, and <code>calendar_dates.txt<\/code>. In particular, verify that the selected analysis date has valid service and that stops are correctly located.<\/p>\n<p>Obtain an OSM PBF extract covering the study area and a surrounding buffer. Clipping data too tightly can create artificial network endpoints and remove useful connections beyond the boundary.<\/p>\n<p>Check pedestrian paths, crossings, station entrances, bridges, and cycling connections. Missing links can create misleading travel times even when the GTFS feed passes validation.<\/p>\n<p>Keep the original datasets unchanged. Record their download dates and maintain a separate log of any feed corrections.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"3_Configure_OpenTripPlanner\"><\/span>3. Configure OpenTripPlanner<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Use a pinned OTP release and follow its official <a href=\"https:\/\/docs.opentripplanner.org\/en\/latest\/Container-Image\/\" target=\"_blank\" rel=\"noopener\">container documentation<\/a>. Store the OSM extract, GTFS ZIP files, and configuration files in a mounted directory.<\/p>\n<p>An example directory structure is:<\/p>\n<pre><code>transit-network\/\r\n\u251c\u2500\u2500 region.osm.pbf\r\n\u251c\u2500\u2500 city-gtfs.zip\r\n\u251c\u2500\u2500 rail-gtfs.zip\r\n\u251c\u2500\u2500 build-config.json\r\n\u251c\u2500\u2500 router-config.json\r\n\u2514\u2500\u2500 graph.obj<\/code><\/pre>\n<p>The GTFS filenames should include <code>gtfs<\/code>, following OTP&#8217;s container workflow. Configure the transit service period to cover the dates being analyzed and use the correct timezone for the study area.<\/p>\n<p>A typical container lifecycle is:<\/p>\n<pre><code># Build and save the graph\r\ndocker run --rm \\\r\n  -e JAVA_TOOL_OPTIONS='-Xmx8g' \\\r\n  -v \"$(pwd)\/transit-network:\/var\/opentripplanner\" \\\r\n  docker.io\/opentripplanner\/opentripplanner:&lt;pinned-version&gt; \\\r\n  --build --save\r\n\r\n# Start the saved graph\r\ndocker run --rm -p 8080:8080 \\\r\n  -e JAVA_TOOL_OPTIONS='-Xmx8g' \\\r\n  -v \"$(pwd)\/transit-network:\/var\/opentripplanner\" \\\r\n  docker.io\/opentripplanner\/opentripplanner:&lt;pinned-version&gt; \\\r\n  --load --serve<\/code><\/pre>\n<p>Replace <code>&lt;pinned-version&gt;<\/code> with an actual released tag. Allocate memory according to the graph&#8217;s size and inspect the build report for missing feeds, unlinked stops, and other import problems.<\/p>\n<p>OTP 2 exposes a GraphQL API. Its usual GTFS endpoint is:<\/p>\n<pre><code>http:\/\/localhost:8080\/otp\/gtfs\/v1<\/code><\/pre>\n<p>The GraphiQL interface, when enabled, is generally available at <code>http:\/\/localhost:8080\/graphiql<\/code>. Inspect the schema for the specific OTP release before implementing requests. Do not expose an unsecured development server to the public internet.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"4_Generate_the_destination_grid_with_Python\"><\/span>4. Generate the destination grid with Python<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-large wp-image-1696\" src=\"https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_d0qypud0qypud0qy-1024x559.jpg\" alt=\"multi-modal public transit isochrones python destination grid\" width=\"1024\" height=\"559\" srcset=\"https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_d0qypud0qypud0qy-1024x559.jpg 1024w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_d0qypud0qypud0qy-300x164.jpg 300w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_d0qypud0qypud0qy-768x419.jpg 768w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_d0qypud0qypud0qy.jpg 1408w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/p>\n<p>A destination grid converts a continuous study area into discrete locations where OTP can calculate travel times. Each cell receives a sample point, and Python submits that point to the router.<\/p>\n<p>Grid resolution controls the trade-off between spatial detail and computation. A 500-metre grid typically requires fewer requests than a 100-metre grid, but produces a coarser accessibility boundary. Start with 250\u2013500 metres for a city-scale analysis and refine the resolution only where necessary.<\/p>\n<p>The following complete script:<\/p>\n<ul>\n<li>Reads a polygon or multipolygon study boundary from a GeoPackage, GeoJSON, Shapefile, or another supported vector format.<\/li>\n<li>Repairs invalid geometries and combines the polygon boundaries.<\/li>\n<li>Uses a projected coordinate reference system (CRS) with metre units.<\/li>\n<li>Creates a regular square grid and clips each cell to the study area.<\/li>\n<li>Generates a sample point inside every polygon component.<\/li>\n<li>Transforms cell geometries and sample points to WGS84 (EPSG:4326).<\/li>\n<li>Exports a GeoPackage for spatial processing and a CSV containing the sample coordinates for routing.<\/li>\n<\/ul>\n<h3><span class=\"ez-toc-section\" id=\"Install_the_dependencies\"><\/span>Install the dependencies<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>Create a Python environment and install GeoPandas, Shapely, and their dependencies:<\/p>\n<pre><code>python -m venv .venv<\/code><\/pre>\n<p>Activate the environment on Windows:<\/p>\n<pre><code>.venv\\Scripts\\activate<\/code><\/pre>\n<p>Or on Linux:<\/p>\n<pre><code>source .venv\/bin\/activate<\/code><\/pre>\n<p>Install the packages:<\/p>\n<pre><code>python -m pip install geopandas shapely pyproj pandas<\/code><\/pre>\n<h3><span class=\"ez-toc-section\" id=\"Complete_script_generate_transit_gridpy\"><\/span>Complete script: <code>generate_transit_grid.py<\/code><span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>Save the following code as <code>generate_transit_grid.py<\/code>.<\/p>\n<pre><code>#!\/usr\/bin\/env python3\r\n\"\"\"\r\nGenerate a clipped destination grid for public-transit accessibility analysis.\r\n\r\nOutputs:\r\n    1. GeoPackage containing clipped polygon cells and sample coordinates.\r\n    2. CSV containing cell IDs, sample longitude\/latitude, and grid metadata.\r\n\r\nUsage:\r\n    python generate_transit_grid.py \\\r\n        --boundary data\/study_area.gpkg \\\r\n        --cell-size 300 \\\r\n        --metric-crs auto \\\r\n        --gpkg-output output\/transit_grid.gpkg \\\r\n        --csv-output output\/transit_grid_samples.csv\r\n\"\"\"\r\n\r\nimport argparse\r\nimport math\r\nfrom pathlib import Path\r\n\r\nimport geopandas as gpd\r\nimport pandas as pd\r\nfrom shapely import make_valid, union_all\r\nfrom shapely.geometry import Point, box\r\n\r\n\r\nWGS84 = \"EPSG:4326\"\r\n\r\n\r\ndef polygon_parts(geometry):\r\n    \"\"\"Return all Polygon components from a geometry.\"\"\"\r\n    if geometry is None or geometry.is_empty:\r\n        return []\r\n\r\n    if geometry.geom_type == \"Polygon\":\r\n        return [geometry]\r\n\r\n    if geometry.geom_type in (\"MultiPolygon\", \"GeometryCollection\"):\r\n        parts = []\r\n        for component in geometry.geoms:\r\n            parts.extend(polygon_parts(component))\r\n        return parts\r\n\r\n    # Ignore lines and points returned by geometry repair.\r\n    return []\r\n\r\n\r\ndef load_study_area(path, layer=None):\r\n    \"\"\"Read, repair, and dissolve polygon boundaries.\"\"\"\r\n    read_options = {}\r\n    if layer:\r\n        read_options[\"layer\"] = layer\r\n\r\n    boundaries = gpd.read_file(path, **read_options)\r\n\r\n    if boundaries.empty:\r\n        raise ValueError(\"The boundary dataset contains no features.\")\r\n\r\n    if boundaries.crs is None:\r\n        raise ValueError(\r\n            \"Input CRS is missing. Define the correct source CRS \"\r\n            \"before running this script.\"\r\n        )\r\n\r\n    geometries = []\r\n    for geometry in boundaries.geometry:\r\n        if geometry is None or geometry.is_empty:\r\n            continue\r\n\r\n        repaired = make_valid(geometry)\r\n        geometries.extend(polygon_parts(repaired))\r\n\r\n    if not geometries:\r\n        raise ValueError(\r\n            \"No valid polygon geometry was found in the input dataset.\"\r\n        )\r\n\r\n    study_area = union_all(geometries)\r\n\r\n    # Dissolve again after repair and retain polygonal components only.\r\n    geometries = polygon_parts(study_area)\r\n    if not geometries:\r\n        raise ValueError(\"The dissolved boundary contains no polygons.\")\r\n\r\n    return boundaries, union_all(geometries)\r\n\r\n\r\ndef select_metric_crs(boundaries, requested_crs):\r\n    \"\"\"\r\n    Select a projected CRS for metre-based grid construction.\r\n\r\n    'auto' estimates an appropriate UTM CRS from the input bounds.\r\n    For large or multi-zone areas, supply a suitable local projection.\r\n    \"\"\"\r\n    if requested_crs.lower() != \"auto\":\r\n        metric_crs = requested_crs\r\n    else:\r\n        metric_crs = boundaries.estimate_utm_crs()\r\n        if metric_crs is None:\r\n            raise ValueError(\r\n                \"Could not estimate a projected CRS. \"\r\n                \"Supply --metric-crs with an appropriate projected CRS.\"\r\n            )\r\n\r\n    projected = gpd.GeoSeries([], crs=metric_crs)\r\n    if not projected.crs.is_projected:\r\n        raise ValueError(\r\n            f\"{metric_crs} is not a projected CRS. \"\r\n            \"Choose a CRS suitable for metre-based grid construction.\"\r\n        )\r\n\r\n    # Confirm the CRS uses metres rather than feet.\r\n    axis_info = projected.crs.axis_info\r\n    if not axis_info or any(\r\n        not math.isclose(axis.unit_conversion_factor, 1.0, abs_tol=1e-9)\r\n        for axis in axis_info[:2]\r\n    ):\r\n        raise ValueError(\r\n            f\"{metric_crs} does not use metres as its horizontal units. \"\r\n            \"Supply a projected CRS whose units are metres.\"\r\n        )\r\n\r\n    return projected.crs\r\n\r\n\r\ndef generate_grid(study_area, cell_size, metric_crs, max_candidates):\r\n    \"\"\"Generate clipped polygon cells and interior sample points.\"\"\"\r\n    min_x, min_y, max_x, max_y = study_area.bounds\r\n\r\n    # Align the regular grid to integer multiples of cell_size.\r\n    ix_min = math.floor(min_x \/ cell_size)\r\n    iy_min = math.floor(min_y \/ cell_size)\r\n    ix_max = math.ceil(max_x \/ cell_size)\r\n    iy_max = math.ceil(max_y \/ cell_size)\r\n\r\n    candidate_count = (ix_max - ix_min) * (iy_max - iy_min)\r\n\r\n    if candidate_count &gt; max_candidates:\r\n        raise ValueError(\r\n            f\"The grid requires up to {candidate_count:,} candidate cells, \"\r\n            f\"exceeding the limit of {max_candidates:,}. \"\r\n            \"Increase --cell-size or --max-candidates.\"\r\n        )\r\n\r\n    cell_records = []\r\n    sample_points = []\r\n    cell_id = 1\r\n\r\n    for grid_i in range(ix_min, ix_max):\r\n        x_min = grid_i * cell_size\r\n        x_max = x_min + cell_size\r\n\r\n        for grid_j in range(iy_min, iy_max):\r\n            y_min = grid_j * cell_size\r\n            y_max = y_min + cell_size\r\n\r\n            square = box(x_min, y_min, x_max, y_max)\r\n\r\n            if not square.intersects(study_area):\r\n                continue\r\n\r\n            clipped = square.intersection(study_area)\r\n\r\n            # One row per polygon component avoids assigning one sample\r\n            # time to multiple disconnected pieces within the same square.\r\n            for part_number, polygon in enumerate(\r\n                polygon_parts(clipped), start=1\r\n            ):\r\n                if polygon.is_empty or polygon.area &lt;= 0:\r\n                    continue\r\n\r\n                # Guaranteed to lie inside the clipped polygon.\r\n                sample_point = polygon.representative_point()\r\n\r\n                cell_records.append({\r\n                    \"cell_id\": cell_id,\r\n                    \"grid_i\": grid_i,\r\n                    \"grid_j\": grid_j,\r\n                    \"part_no\": part_number,\r\n                    \"cell_size_m\": float(cell_size),\r\n                    \"cell_area_m2\": float(polygon.area),\r\n                    \"metric_crs\": str(metric_crs),\r\n                    \"geometry\": polygon,\r\n                })\r\n\r\n                sample_points.append(sample_point)\r\n                cell_id += 1\r\n\r\n    if not cell_records:\r\n        raise ValueError(\r\n            \"No grid cells were generated. Check the boundary and CRS.\"\r\n        )\r\n\r\n    cells_metric = gpd.GeoDataFrame(\r\n        cell_records,\r\n        geometry=\"geometry\",\r\n        crs=metric_crs,\r\n    )\r\n\r\n    points_metric = gpd.GeoDataFrame(\r\n        {\"cell_id\": cells_metric[\"cell_id\"].to_numpy()},\r\n        geometry=sample_points,\r\n        crs=metric_crs,\r\n    )\r\n\r\n    return cells_metric, points_metric\r\n\r\n\r\ndef main():\r\n    parser = argparse.ArgumentParser(\r\n        description=\"Generate a study-area-clipped transit destination grid.\"\r\n    )\r\n    parser.add_argument(\r\n        \"--boundary\",\r\n        required=True,\r\n        help=\"Input polygon boundary file.\",\r\n    )\r\n    parser.add_argument(\r\n        \"--layer\",\r\n        default=None,\r\n        help=\"Optional input layer name, for example a GeoPackage layer.\",\r\n    )\r\n    parser.add_argument(\r\n        \"--cell-size\",\r\n        type=float,\r\n        default=300.0,\r\n        help=\"Grid cell width in metres. Default: 300.\",\r\n    )\r\n    parser.add_argument(\r\n        \"--metric-crs\",\r\n        default=\"auto\",\r\n        help=\"Projected metre-based CRS, such as EPSG:32737, or 'auto'.\",\r\n    )\r\n    parser.add_argument(\r\n        \"--gpkg-output\",\r\n        default=\"output\/transit_grid.gpkg\",\r\n        help=\"Output GeoPackage path.\",\r\n    )\r\n    parser.add_argument(\r\n        \"--csv-output\",\r\n        default=\"output\/transit_grid_samples.csv\",\r\n        help=\"Output CSV path.\",\r\n    )\r\n    parser.add_argument(\r\n        \"--max-candidates\",\r\n        type=int,\r\n        default=250000,\r\n        help=\"Maximum candidate squares allowed. Default: 250000.\",\r\n    )\r\n    args = parser.parse_args()\r\n\r\n    if args.cell_size &lt;= 0:\r\n        parser.error(\"--cell-size must be greater than zero.\")\r\n\r\n    if args.max_candidates &lt;= 0:\r\n        parser.error(\"--max-candidates must be greater than zero.\")\r\n\r\n    boundary_path = Path(args.boundary)\r\n    if not boundary_path.exists():\r\n        parser.error(f\"Boundary file does not exist: {boundary_path}\")\r\n\r\n    boundaries, study_area = load_study_area(\r\n        boundary_path, layer=args.layer\r\n    )\r\n\r\n    metric_crs = select_metric_crs(boundaries, args.metric_crs)\r\n    study_area_metric = gpd.GeoSeries(\r\n        [study_area], crs=boundaries.crs\r\n    ).to_crs(metric_crs).iloc[0]\r\n\r\n    cells_metric, points_metric = generate_grid(\r\n        study_area=study_area_metric,\r\n        cell_size=args.cell_size,\r\n        metric_crs=metric_crs,\r\n        max_candidates=args.max_candidates,\r\n    )\r\n\r\n    # Convert both polygons and sample locations to longitude\/latitude.\r\n    cells_wgs84 = cells_metric.to_crs(WGS84)\r\n    points_wgs84 = points_metric.to_crs(WGS84)\r\n\r\n    cells_wgs84[\"sample_lon\"] = points_wgs84.geometry.x.to_numpy()\r\n    cells_wgs84[\"sample_lat\"] = points_wgs84.geometry.y.to_numpy()\r\n\r\n    gpkg_path = Path(args.gpkg_output)\r\n    csv_path = Path(args.csv_output)\r\n\r\n    gpkg_path.parent.mkdir(parents=True, exist_ok=True)\r\n    csv_path.parent.mkdir(parents=True, exist_ok=True)\r\n\r\n    cells_wgs84.to_file(\r\n        gpkg_path,\r\n        layer=\"transit_grid\",\r\n        driver=\"GPKG\",\r\n        index=False,\r\n    )\r\n\r\n    # CSV preserves the cell IDs used to join results back to polygons.\r\n    cells_wgs84.drop(columns=\"geometry\").to_csv(\r\n        csv_path,\r\n        index=False,\r\n    )\r\n\r\n    print(f\"Metric CRS: {metric_crs}\")\r\n    print(f\"Cell size: {args.cell_size:g} metres\")\r\n    print(f\"Generated cells: {len(cells_wgs84):,}\")\r\n    print(f\"GeoPackage: {gpkg_path.resolve()}\")\r\n    print(f\"Sample CSV: {csv_path.resolve()}\")\r\n    print(\"Sample coordinates are EPSG:4326 longitude\/latitude.\")\r\n\r\n\r\nif __name__ == \"__main__\":\r\n    main()<\/code><\/pre>\n<h3><span class=\"ez-toc-section\" id=\"Run_the_grid_generator\"><\/span>Run the grid generator<span class=\"ez-toc-section-end\"><\/span><\/h3>\n<p>For example, use a 300-metre grid for a study area stored in a GeoPackage:<\/p>\n<pre><code>python generate_transit_grid.py \\\r\n  --boundary data\/study_area.gpkg \\\r\n  --layer study_area \\\r\n  --cell-size 300 \\\r\n  --metric-crs auto \\\r\n  --gpkg-output output\/transit_grid.gpkg \\\r\n  --csv-output output\/transit_grid_samples.csv<\/code><\/pre>\n<p>For a study area in Nairobi, an appropriate local projected CRS may be selected explicitly, for example <code>EPSG:32737<\/code>, if it suits the dataset&#8217;s extent and accuracy requirements:<\/p>\n<pre><code>python generate_transit_grid.py \\\r\n  --boundary data\/nairobi_boundary.gpkg \\\r\n  --cell-size 300 \\\r\n  --metric-crs EPSG:32737<\/code><\/pre>\n<p>The <code>auto<\/code> option estimates a UTM CRS from the source boundary. For large study areas spanning multiple UTM zones, select a projection suitable for the full region rather than relying on automatic selection.<\/p>\n<p>The script produces:<\/p>\n<ul>\n<li><code>transit_grid.gpkg<\/code>: clipped polygon cells, unique IDs, cell areas, and sample coordinates.<\/li>\n<li><code>transit_grid_samples.csv<\/code>: one row per cell with the same ID, longitude, latitude, and grid metadata.<\/li>\n<\/ul>\n<p>Each clipped polygon component gets its own ID and its own representative sample point. This avoids assigning one travel-time observation to multiple disconnected polygon pieces within a single grid square.<\/p>\n<p>The CSV coordinates are in longitude, latitude order. Use the sample latitude and longitude for OTP requests, and retain <code>cell_id<\/code> to join routing results back to the matching polygons.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"5_Query_OTP_with_Python\"><\/span>5. Query OTP with Python<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Once the grid exists, Python can iterate over the sample CSV, query OTP, and persist the travel time for each sample.<\/p>\n<p>Use a fixed departure timestamp with the correct local timezone. Define the mode policy explicitly: for example, walking plus transit, or cycling access plus transit where the graph and routing configuration support it. Do not assume every combination of bicycle, walking, and public transport is valid in OTP.<\/p>\n<p>A minimal request pattern using OTP&#8217;s GraphQL API is illustrated below. Confirm the input types and fields against your installed OTP version&#8217;s GraphiQL schema.<\/p>\n<pre><code>import requests\r\n\r\nOTP_URL = \"http:\/\/localhost:8080\/otp\/gtfs\/v1\"\r\n\r\nQUERY = \"\"\"\r\nquery {\r\n  __typename\r\n}\r\n\"\"\"\r\n\r\ndef check_otp():\r\n    response = requests.post(\r\n        OTP_URL,\r\n        json={\"query\": QUERY},\r\n        timeout=30,\r\n    )\r\n    response.raise_for_status()\r\n\r\n    payload = response.json()\r\n    if payload.get(\"errors\"):\r\n        raise RuntimeError(payload[\"errors\"])\r\n\r\n    return payload[\"data\"]<\/code><\/pre>\n<p>This initial request checks API availability; it does not calculate a journey. Replace the query with the <code>planConnection<\/code> operation and supported origin, destination, departure-time, and mode inputs from the installed OTP schema. Extract the relevant itinerary duration and convert it to seconds. Store failed or route-less requests as <code>NULL<\/code>, together with the routing errors, rather than treating missing routes as zero-minute journeys.<\/p>\n<p>The travel-time query loop should perform these operations for each CSV row:<\/p>\n<ol start=\"1\">\n<li>Read <code>cell_id<\/code>, <code>sample_lon<\/code>, and <code>sample_lat<\/code>.<\/li>\n<li>Submit a destination query from the chosen origin to the sample point.<\/li>\n<li>Record the best valid itinerary duration under the specified mode and departure-time rules.<\/li>\n<li>Save the result against <code>cell_id<\/code>, then checkpoint progress.<\/li>\n<\/ol>\n<p>Begin with a small test set before running the complete grid. Add conservative concurrency, retries for temporary HTTP failures, request logging, and checkpoints so that a large study can resume after an interruption.<\/p>\n<p><strong>Important analytical limitation:<\/strong> OTP itinerary queries are not equivalent to exhaustive network analysis. Querying a sample of destinations can approximate a travel-time surface, but the result depends on the grid, the routing request, service calendars, and itinerary-selection behavior. For large regional analysis requiring more systematic travel-time surfaces, evaluate purpose-built tools such as <a href=\"https:\/\/docs.conveyal.com\/analysis\/regional\" target=\"_blank\" rel=\"noopener\">R5<\/a>.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"6_Store_and_analyze_the_samples_in_PostGIS\"><\/span>6. Store and analyze the samples in<a href=\"https:\/\/helpwithmygisassignment.com\/blog\/building-real-time-spatial-dashboards-in-qgis-with-geonode-and-postgis\/\"> PostGIS<\/a><span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Create a table to store sampled travel times and clipped cell geometries:<\/p>\n<pre><code>CREATE TABLE transit_samples (\r\n    run_id           text NOT NULL,\r\n    cell_id          bigint NOT NULL,\r\n    origin_id        text NOT NULL,\r\n    departure_time   timestamptz NOT NULL,\r\n    travel_seconds   integer,\r\n    cell_geom        geometry(Polygon, 4326) NOT NULL,\r\n    PRIMARY KEY (run_id, origin_id, departure_time, cell_id),\r\n    CHECK (travel_seconds IS NULL OR travel_seconds &gt;= 0)\r\n);\r\n\r\nCREATE INDEX transit_samples_geom_gix\r\n    ON transit_samples USING GIST (cell_geom);<\/code><\/pre>\n<p>Load the generated GeoPackage into a staging table, then join routing results by <code>cell_id<\/code> and insert them into <code>transit_samples<\/code>. If using <code>ogr2ogr<\/code> to import the grid, retain the polygon geometry and sample-coordinate attributes. Keep the routing results in a separate table until they have been matched and validated.<\/p>\n<p>Next, dissolve cells that meet each travel-time threshold:<\/p>\n<pre><code>CREATE TABLE transit_isochrones (\r\n    run_id          text NOT NULL,\r\n    origin_id       text NOT NULL,\r\n    departure_time  timestamptz NOT NULL,\r\n    cutoff_seconds  integer NOT NULL,\r\n    geom            geometry(MultiPolygon, 4326) NOT NULL,\r\n    PRIMARY KEY (run_id, origin_id, departure_time, cutoff_seconds)\r\n);\r\n\r\nCREATE INDEX transit_isochrones_geom_gix\r\n    ON transit_isochrones USING GIST (geom);<\/code><\/pre>\n<p>Generate the catchments:<\/p>\n<pre><code>INSERT INTO transit_isochrones\r\n    (run_id, origin_id, departure_time, cutoff_seconds, geom)\r\nSELECT\r\n    s.run_id,\r\n    s.origin_id,\r\n    s.departure_time,\r\n    c.cutoff_seconds,\r\n    ST_Multi(\r\n        ST_CollectionExtract(ST_Union(s.cell_geom), 3)\r\n    )::geometry(MultiPolygon, 4326)\r\nFROM transit_samples AS s\r\nCROSS JOIN (VALUES (900), (1800), (2700)) AS c(cutoff_seconds)\r\nWHERE s.travel_seconds IS NOT NULL\r\n  AND s.travel_seconds &lt;= c.cutoff_seconds\r\nGROUP BY\r\n    s.run_id,\r\n    s.origin_id,\r\n    s.departure_time,\r\n    c.cutoff_seconds;<\/code><\/pre>\n<p>The three cutoffs represent 15, 30, and 45 minutes. <code>ST_Union<\/code> dissolves boundaries between neighboring reachable cells, while <code>ST_CollectionExtract<\/code> retains polygonal components.<\/p>\n<p>Because each input cell is clipped to the study area, the final catchment is also constrained by that boundary. Document this when interpreting results near the edge of the modeled region.<\/p>\n<p>Check geometry validity and area:<\/p>\n<pre><code>SELECT\r\n    origin_id,\r\n    cutoff_seconds,\r\n    ST_IsValid(geom) AS valid_geometry,\r\n    ST_Area(geom::geography) \/ 1000000.0 AS area_km2\r\nFROM transit_isochrones;<\/code><\/pre>\n<p>Spatial indexes are important when testing catchments against large opportunity datasets. PostGIS documents how index-aware predicates can accelerate spatial filtering in its <a href=\"https:\/\/postgis.net\/documentation\/faq\/spatial-indexes\/\" target=\"_blank\" rel=\"noopener\">spatial index FAQ<\/a>.<\/p>\n<p>For example, count clinics intersecting the 30-minute catchment:<\/p>\n<pre><code>SELECT COUNT(*) AS clinics_reachable\r\nFROM clinics AS c\r\nJOIN transit_isochrones AS i\r\n  ON ST_Intersects(i.geom, c.geom)\r\nWHERE i.run_id = 'weekday-am-walk-transit'\r\n  AND i.origin_id = 'central_station'\r\n  AND i.cutoff_seconds = 1800;<\/code><\/pre>\n<p>For population coverage, overlay the catchment with census grids or administrative boundaries. Counting a whole district because any part intersects can overstate coverage. Where appropriate, use area-weighted population allocation or a finer population grid, documenting any assumption that population is uniformly distributed within source polygons.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"7_Validate_the_catchments\"><\/span>7. Validate the catchments<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Validate the workflow before interpreting its results.<\/p>\n<p><strong>Routing checks:<\/strong> Compare selected sample travel times with manually inspected OTP itineraries. Test walk-only, bus, rail, and transfer journeys wherever these modes exist.<\/p>\n<p><strong>Network checks:<\/strong> Inspect unreachable sample points, disconnected areas, and cells associated with missing services. Confirm that these reflect genuine network limitations rather than invalid feeds or missing OSM links.<\/p>\n<p><strong>Spatial checks:<\/strong> Ensure catchments expand with increasing time thresholds under consistent assumptions. Check polygon validity, unexpected holes, isolated fragments, and the effect of grid resolution.<\/p>\n<p>Departure time is part of the model. Repeat the analysis for representative weekday peak, weekday off-peak, and weekend departures when the objective is to compare accessibility patterns. A single 08:00 output should be described as accessibility for that departure time and date, not as a universal measure of transit access.<\/p>\n<p>Finally, distinguish <strong>reachability<\/strong> from <strong>accessibility<\/strong>. An isochrone estimates where travel may be possible within a given time; it does not directly measure how many jobs, clinics, schools, or residents are reachable. Combine the polygons with appropriate opportunity datasets to answer those questions.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"Image_placeholder\"><\/span>Image placeholder<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-large wp-image-1697\" src=\"https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_i790nai790nai790-1024x559.jpg\" alt=\"multi-modal public transit isochrones travel time rings map\" width=\"1024\" height=\"559\" srcset=\"https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_i790nai790nai790-1024x559.jpg 1024w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_i790nai790nai790-300x164.jpg 300w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_i790nai790nai790-768x419.jpg 768w, https:\/\/helpwithmygisassignment.com\/blog\/wp-content\/uploads\/2026\/10\/Gemini_Generated_Image_i790nai790nai790.jpg 1408w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/p>\n<p>The map legend should explain the travel-time thresholds, departure date and time, permitted modes, and grid resolution.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"SEO_best_practices_and_internal_linking\"><\/span>SEO best practices and internal linking<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Use the primary keyword naturally in the title, introduction, and a relevant heading. Support it with variations such as \u201cpublic transport catchment areas,\u201d \u201ctransit accessibility mapping,\u201d and \u201cschedule-aware travel-time surfaces\u201d rather than repeating the same phrase unnaturally.<\/p>\n<p>Add contextual internal links to related guides:<\/p>\n<ul>\n<li><a href=\"https:\/\/chatgpt.com\/c\/multimodal-accessibility-modeling-gtfs-gis\/\" target=\"_blank\" rel=\"noopener\">Multimodal accessibility modeling with GTFS in GIS<\/a><\/li>\n<li><a href=\"https:\/\/chatgpt.com\/c\/healthcare-desert-analysis-gis\/\" target=\"_blank\" rel=\"noopener\">Healthcare desert analysis with GIS<\/a><\/li>\n<\/ul>\n<p>Replace the example paths with the actual published URLs. The first link provides a broader GTFS accessibility workflow; the second shows how catchments can support healthcare-equity analysis.<\/p>\n<h2><span class=\"ez-toc-section\" id=\"Summary\"><\/span>Summary<span class=\"ez-toc-section-end\"><\/span><\/h2>\n<p>Building multi-modal public transit isochrones requires more than drawing buffers around stops. GTFS schedules and OSM network data feed OTP, Python generates a spatial sampling grid and retrieves journey durations, and PostGIS stores the results and constructs approximate catchment polygons.<\/p>\n<p>The grid-generation script provides the missing spatial foundation: a reproducible grid in a metre-based CRS, clipped polygons, unique cell IDs, and sample coordinates suitable for OTP requests. The accuracy of the final catchments still depends on network completeness, departure-time assumptions, routing configuration, and grid resolution.<\/p>\n<p>With explicit assumptions, validated inputs, indexed spatial tables, and traceable outputs, this workflow provides a practical starting point for assessing access to jobs, schools, healthcare, and other essential urban services.<\/p>\n<p><strong>Technical references:<\/strong> <a href=\"https:\/\/docs.opentripplanner.org\/en\/latest\/apis\/Apis\/\" target=\"_blank\" rel=\"noopener\">OTP API overview<\/a>, <a href=\"https:\/\/docs.opentripplanner.org\/en\/latest\/Container-Image\/\" target=\"_blank\" rel=\"noopener\">OTP container documentation<\/a>, <a href=\"https:\/\/gtfs.org\/getting-started\/validate\/\" target=\"_blank\" rel=\"noopener\">GTFS validation<\/a>, <a href=\"https:\/\/geopandas.org\/en\/stable\/docs\/reference\/io.html\" target=\"_blank\" rel=\"noopener\">GeoPandas vector I\/O<\/a>, <a href=\"https:\/\/shapely.readthedocs.io\/en\/stable\/manual.html\" target=\"_blank\" rel=\"noopener\">Shapely geometry operations<\/a>, and <a href=\"https:\/\/postgis.net\/documentation\/faq\/spatial-indexes\/\" target=\"_blank\" rel=\"noopener\">PostGIS spatial indexes<\/a>.<\/p>\n<\/div>\n<div>\n<div>\n<div>Add a runnable OTP query loopShow the grid import commands<\/div>\n<\/div>\n<\/div>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>Building Multi-Modal Public Transit Isochrones with PostGIS and OpenTripPlanner Introduction A 15-minute public transport catchment is not simply a circle around a bus stop. A traveler might walk to a stop, wait for a scheduled bus, transfer to rail, and walk the final block. The same destination may be reachable in 20 minutes at 08:00 &#8230; <a title=\"Building Multi-Modal Public Transit Isochrones with PostGIS and OpenTripPlanner\" class=\"read-more\" href=\"https:\/\/helpwithmygisassignment.com\/blog\/multi-modal-public-transit-isochrones\/\" aria-label=\"Read more about Building Multi-Modal Public Transit Isochrones with PostGIS and OpenTripPlanner\">Read more<\/a><\/p>\n","protected":false},"author":1,"featured_media":1695,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1,4],"tags":[],"class_list":["post-1574","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-uncategorized","category-gis-articles"],"_links":{"self":[{"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/posts\/1574","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/comments?post=1574"}],"version-history":[{"count":3,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/posts\/1574\/revisions"}],"predecessor-version":[{"id":1699,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/posts\/1574\/revisions\/1699"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/media\/1695"}],"wp:attachment":[{"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/media?parent=1574"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/categories?post=1574"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/helpwithmygisassignment.com\/blog\/wp-json\/wp\/v2\/tags?post=1574"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}