From 0f60640bc09fab142815d1a8eaea44653c3fc467 Mon Sep 17 00:00:00 2001 From: Alexander Hess Date: Mon, 13 Sep 2021 11:30:17 +0200 Subject: [PATCH] Show how the Google Maps API is integrated - show an example order's path from the restaurant to the customer's delivery address as it is travelled by a courier - explain how to use the Google Maps API directly - show how the API is integrated into the data model --- research/08_google_maps.ipynb | 922 ++++++++++++++++++++++++++++++++++ 1 file changed, 922 insertions(+) create mode 100644 research/08_google_maps.ipynb diff --git a/research/08_google_maps.ipynb b/research/08_google_maps.ipynb new file mode 100644 index 0000000..9c6e0aa --- /dev/null +++ b/research/08_google_maps.ipynb @@ -0,0 +1,922 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "3d0f2ad3", + "metadata": {}, + "source": [ + "# Google Maps Integration" + ] + }, + { + "cell_type": "markdown", + "id": "e3e91187", + "metadata": {}, + "source": [ + "This notebook shows how the [Google Maps API](https://developers.google.com/maps/) is integrated to plot a courier's path from a restaurant to a customer." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "3bb8099f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\u001b[32murban-meal-delivery\u001b[0m, version \u001b[34m0.4.0\u001b[0m\n" + ] + } + ], + "source": [ + "!umd --version" + ] + }, + { + "cell_type": "markdown", + "id": "5fbc6ccb", + "metadata": {}, + "source": [ + "### Imports" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "6b5c3483", + "metadata": {}, + "outputs": [], + "source": [ + "import googlemaps as gm\n", + "\n", + "from urban_meal_delivery import config, db" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "3dd58c43", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext lab_black" + ] + }, + { + "cell_type": "markdown", + "id": "5c7493c8", + "metadata": {}, + "source": [ + "### Settings" + ] + }, + { + "cell_type": "markdown", + "id": "2e76e261", + "metadata": {}, + "source": [ + "Choose `\"Bordeaux\"`, `\"Lyon\"`, or `\"Paris\"`." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "5c6fc1f0", + "metadata": {}, + "outputs": [], + "source": [ + "city_name = \"Paris\"" + ] + }, + { + "cell_type": "markdown", + "id": "435f8cfb", + "metadata": {}, + "source": [ + "### Load the Data" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "ac59dc73", + "metadata": {}, + "outputs": [], + "source": [ + "city = db.session.query(db.City).filter_by(name=city_name).one()" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "b15b36f2", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "city" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "6f76bece", + "metadata": {}, + "outputs": [], + "source": [ + "restaurants = (\n", + " db.session.query(db.Restaurant)\n", + " .join(db.Address)\n", + " .filter(db.Address.city == city)\n", + " .all()\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "7c3968be", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "1153" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "len(restaurants)" + ] + }, + { + "cell_type": "markdown", + "id": "baaf60b1", + "metadata": {}, + "source": [ + "## Visualization" + ] + }, + { + "cell_type": "markdown", + "id": "16176dc0", + "metadata": {}, + "source": [ + "Let's choose a restaurant and then one of its orders." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "baf34d52", + "metadata": {}, + "outputs": [], + "source": [ + "restaurant = restaurants[0]" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "2e13f48d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "3297" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "len(restaurant.orders)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "9c70a621", + "metadata": {}, + "outputs": [], + "source": [ + "order = restaurant.orders[10]" + ] + }, + { + "cell_type": "markdown", + "id": "ff273184", + "metadata": {}, + "source": [ + "Plot the courier's path from the restaurant to the customer's delivery address." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "d91b07a5", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
Make this Notebook Trusted to load map: File -> Trust Notebook
" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "order.draw()" + ] + }, + { + "cell_type": "markdown", + "id": "753ee9a5", + "metadata": {}, + "source": [ + "## Behind the Scenes" + ] + }, + { + "cell_type": "markdown", + "id": "2ae9598c", + "metadata": {}, + "source": [ + "The code above integrates the [googlemaps](https://github.com/googlemaps/google-maps-services-python) PyPI package into the application running the UDP's routing optimization.\n", + "\n", + "Let's first look at how we can use the [googlemaps](https://github.com/googlemaps/google-maps-services-python) PyPI package directly to make indiviual API calls.\n", + "\n", + "Then, we'll see how this code is **abstracted** into the **application logic**." + ] + }, + { + "cell_type": "markdown", + "id": "988ff8c5", + "metadata": {}, + "source": [ + "### Direct Google Maps API Calls" + ] + }, + { + "cell_type": "markdown", + "id": "722f9932", + "metadata": {}, + "source": [ + "With an API key, one can create a new `client` object that handles all communication with the [Google Maps API](https://developers.google.com/maps/)." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "174f6746", + "metadata": {}, + "outputs": [], + "source": [ + "client = gm.Client(config.GOOGLE_MAPS_API_KEY)" + ] + }, + { + "cell_type": "markdown", + "id": "a02b9c5a", + "metadata": {}, + "source": [ + "For example, we can **geocode** an address: This means that we obtain the **latitude-longitude** coordinates for a given *postal* address, and some other structured info." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "4168a03c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[{'address_components': [{'long_name': '2',\n", + " 'short_name': '2',\n", + " 'types': ['street_number']},\n", + " {'long_name': 'Burgplatz', 'short_name': 'Burgpl.', 'types': ['route']},\n", + " {'long_name': 'Vallendar',\n", + " 'short_name': 'Vallendar',\n", + " 'types': ['locality', 'political']},\n", + " {'long_name': 'Mayen-Koblenz',\n", + " 'short_name': 'Mayen-Koblenz',\n", + " 'types': ['administrative_area_level_3', 'political']},\n", + " {'long_name': 'Rheinland-Pfalz',\n", + " 'short_name': 'RP',\n", + " 'types': ['administrative_area_level_1', 'political']},\n", + " {'long_name': 'Germany',\n", + " 'short_name': 'DE',\n", + " 'types': ['country', 'political']},\n", + " {'long_name': '56179', 'short_name': '56179', 'types': ['postal_code']}],\n", + " 'formatted_address': 'Burgpl. 2, 56179 Vallendar, Germany',\n", + " 'geometry': {'bounds': {'northeast': {'lat': 50.40070650000001,\n", + " 'lng': 7.6142806},\n", + " 'southwest': {'lat': 50.4001466, 'lng': 7.6128609}},\n", + " 'location': {'lat': 50.40035899999999, 'lng': 7.613506600000001},\n", + " 'location_type': 'ROOFTOP',\n", + " 'viewport': {'northeast': {'lat': 50.4017755302915,\n", + " 'lng': 7.614919730291503},\n", + " 'southwest': {'lat': 50.3990775697085, 'lng': 7.612221769708499}}},\n", + " 'place_id': 'ChIJjcydwDx9vkcR6sSdsYNtPOo',\n", + " 'types': ['premise']}]" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "client.geocode(\"Burgplatz 2, Vallendar\")" + ] + }, + { + "cell_type": "markdown", + "id": "dc2c1fe4", + "metadata": {}, + "source": [ + "For routing applications, we are in particular interested in collecting all the *pair-wise* distances in a **distance matrix** for all involved **origins** and **destinations**." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "961f6fc2", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "restaurants[0].address" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "5444d517", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(48.85313, 2.37461)" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "restaurants[0].address.location.lat_lng" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "7ca537e6", + "metadata": {}, + "outputs": [], + "source": [ + "origins = [\n", + " restaurants[0].address.location.lat_lng,\n", + " restaurants[1].address.location.lat_lng,\n", + "]" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "76eb7cb1", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "restaurants[0].orders[100].delivery_address" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "f3c4674c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(48.864269, 2.385568)" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "restaurants[0].orders[100].delivery_address.location.lat_lng" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "6a1dfee8", + "metadata": {}, + "outputs": [], + "source": [ + "destinations = [\n", + " restaurants[0].orders[100].delivery_address.location.lat_lng,\n", + " restaurants[1].orders[100].delivery_address.location.lat_lng,\n", + "]" + ] + }, + { + "cell_type": "markdown", + "id": "20dfd317", + "metadata": {}, + "source": [ + "The [Google Maps API](https://developers.google.com/maps/) provides a `.distance_matrix()` method for that." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "25575bfa", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'destination_addresses': ['83 Bd de Ménilmontant, 75011 Paris, France',\n", + " '7 Rue des Abbesses, 75018 Paris, France'],\n", + " 'origin_addresses': ['19 Rue de Charonne, 75011 Paris, France',\n", + " '64 Rue Saint-Lazare, 75009 Paris, France'],\n", + " 'rows': [{'elements': [{'distance': {'text': '2.4 km', 'value': 2389},\n", + " 'duration': {'text': '12 mins', 'value': 701},\n", + " 'status': 'OK'},\n", + " {'distance': {'text': '5.8 km', 'value': 5798},\n", + " 'duration': {'text': '24 mins', 'value': 1454},\n", + " 'status': 'OK'}]},\n", + " {'elements': [{'distance': {'text': '5.7 km', 'value': 5735},\n", + " 'duration': {'text': '22 mins', 'value': 1332},\n", + " 'status': 'OK'},\n", + " {'distance': {'text': '1.2 km', 'value': 1211},\n", + " 'duration': {'text': '10 mins', 'value': 574},\n", + " 'status': 'OK'}]}],\n", + " 'status': 'OK'}" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "client.distance_matrix(\n", + " origins=origins,\n", + " destinations=destinations,\n", + " mode=\"bicycling\", # Choose an appropriate travelling mode\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "be52a0f3", + "metadata": {}, + "source": [ + "The `.directions()` method provides the **legs** (i.e., **waypoints**) of a **route** from *one* location to another." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "5a5e31b5", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[{'bounds': {'northeast': {'lat': 48.8643175, 'lng': 2.3887503},\n", + " 'southwest': {'lat': 48.8530147, 'lng': 2.3745434}},\n", + " 'copyrights': 'Map data ©2021',\n", + " 'legs': [{'distance': {'text': '2.0 km', 'value': 2008},\n", + " 'duration': {'text': '6 mins', 'value': 358},\n", + " 'end_address': '19 Rue de Charonne, 75011 Paris, France',\n", + " 'end_location': {'lat': 48.8530828, 'lng': 2.3745434},\n", + " 'start_address': '83 Bd de Ménilmontant, 75011 Paris, France',\n", + " 'start_location': {'lat': 48.8643175, 'lng': 2.3856677},\n", + " 'steps': [{'distance': {'text': '0.6 km', 'value': 554},\n", + " 'duration': {'text': '2 mins', 'value': 98},\n", + " 'end_location': {'lat': 48.85983239999999, 'lng': 2.3887503},\n", + " 'html_instructions': 'Head southeast on Bd de Ménilmontant toward Rue de Tlemcen/Rue Spinoza',\n", + " 'polyline': {'points': '_yfiHm}pMt@{@`D{DJMBCh@m@HGDCLEjJsD`@MPGB?`@QjCaA'},\n", + " 'start_location': {'lat': 48.8643175, 'lng': 2.3856677},\n", + " 'travel_mode': 'BICYCLING'},\n", + " {'distance': {'text': '0.8 km', 'value': 750},\n", + " 'duration': {'text': '2 mins', 'value': 127},\n", + " 'end_location': {'lat': 48.8573962, 'lng': 2.379628},\n", + " 'html_instructions': 'Turn right onto Rue de la Roquette
Go through 1 roundabout
',\n", + " 'maneuver': 'turn-right',\n", + " 'polyline': {'points': '}|eiHupqMTrA\\\\pBHd@TtADZ@NBN\\\\xBHp@BJ`@~B`@~BNz@^~B@FRjAf@xCP|@Fd@RhA@N@JHh@@NBNBNBV@N?H?TA^A@A??@A@A@?@A@EJ?BAB?B?B?B?@?B@J@@?B@B?@@B@@@B@@@@@@@@@?@@@@@?@?@?@?@?@?@??A@?@?@ADFBFBF?BL`@BBBB\\\\P'},\n", + " 'start_location': {'lat': 48.85983239999999, 'lng': 2.3887503},\n", + " 'travel_mode': 'BICYCLING'},\n", + " {'distance': {'text': '0.5 km', 'value': 488},\n", + " 'duration': {'text': '2 mins', 'value': 96},\n", + " 'end_location': {'lat': 48.853281, 'lng': 2.3773315},\n", + " 'html_instructions': 'Continue onto Av. Ledru Rollin',\n", + " 'polyline': {'points': 'wmeiHuwoMh@XPFRJB@B?DAn@XHDl@Z`ChAHDFDxBdA`@R`@Rd@V`DxAz@^ZJ'},\n", + " 'start_location': {'lat': 48.8573962, 'lng': 2.379628},\n", + " 'travel_mode': 'BICYCLING'},\n", + " {'distance': {'text': '0.2 km', 'value': 205},\n", + " 'duration': {'text': '1 min', 'value': 35},\n", + " 'end_location': {'lat': 48.8530147, 'lng': 2.374655},\n", + " 'html_instructions': 'Turn right onto Rue de Charonne',\n", + " 'maneuver': 'turn-right',\n", + " 'polyline': {'points': '_tdiHiioM?~@@~@Av@ALAhAAn@?T?L?H@N@H@NJd@BLFNDNFPHNDH'},\n", + " 'start_location': {'lat': 48.853281, 'lng': 2.3773315},\n", + " 'travel_mode': 'BICYCLING'},\n", + " {'distance': {'text': '11 m', 'value': 11},\n", + " 'duration': {'text': '1 min', 'value': 2},\n", + " 'end_location': {'lat': 48.8530828, 'lng': 2.3745434},\n", + " 'html_instructions': 'Turn right onto Cr du Panier Fleuri
Destination will be on the right
',\n", + " 'maneuver': 'turn-right',\n", + " 'polyline': {'points': 'irdiHqxnMMT'},\n", + " 'start_location': {'lat': 48.8530147, 'lng': 2.374655},\n", + " 'travel_mode': 'BICYCLING'}],\n", + " 'traffic_speed_entry': [],\n", + " 'via_waypoint': []}],\n", + " 'overview_polyline': {'points': '_yfiHm}pMfFiGr@u@RIjJsD`@MTGlDsAr@dEf@fDn@fEbEfVZnBBZRxADf@?^C`@A@EFGVDb@LRLBDAB?FDTt@d@XrAn@HAx@^`EpBbFdC|ExBZJ?~@?vBCvAA|ABXLt@X~@NXMT'},\n", + " 'summary': 'Bd de Ménilmontant and Rue de la Roquette',\n", + " 'warnings': [\"Bicycling directions are in beta. Use caution – This route may contain streets that aren't suited for bicycling.\"],\n", + " 'waypoint_order': []}]" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "client.directions(\n", + " destination=restaurants[0].address.location.lat_lng,\n", + " origin=restaurants[0].orders[100].delivery_address.location.lat_lng,\n", + " mode=\"bicycling\", # Choose an appropriate travelling mode\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "e22404f4", + "metadata": {}, + "source": [ + "### Abstraction as `Path` Objects" + ] + }, + { + "cell_type": "markdown", + "id": "fb6a035d", + "metadata": {}, + "source": [ + "In the application's code base, the above API calls and the related data are modeled as `Path` objects connecting two `Address` objects (cf. [Path class](https://github.com/webartifex/urban-meal-delivery/blob/main/src/urban_meal_delivery/db/addresses_addresses.py) in the code).\n", + "\n", + "Let's look at two examples addresses, one from a `Restaurant` and one from a `Customer`." + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "23e5552c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "restaurants[0].address" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "80ff4e0f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "restaurants[0].orders[100].delivery_address" + ] + }, + { + "cell_type": "markdown", + "id": "754b5f16", + "metadata": {}, + "source": [ + "The `Path.from_addresses()` constructor method takes any number of `Address` objects and creates all entries of a *symmetric* **distance matrix** as `Path` objects.\n", + "\n", + "Here, we only get *one* `Path` object as there are only two `Address` objects." + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "15b16871", + "metadata": {}, + "outputs": [], + "source": [ + "paths = db.Path.from_addresses(\n", + " restaurants[0].address, restaurants[0].orders[100].delivery_address\n", + ")\n", + "\n", + "path = paths[0]" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "9554fd8e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 26, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "path" + ] + }, + { + "cell_type": "markdown", + "id": "84bf8534", + "metadata": {}, + "source": [ + "As we assume a *generic* and **symmetric** distance matrix, we call the two `Address` objects \"first\" and \"second\" and not \"restaurant\" and \"customer\". After all, a `Customer` may live in a house that has a `Restaurant` on the ground floor." + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "82f04cf4", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "path.first_address" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "fc081362", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 28, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "path.second_address" + ] + }, + { + "cell_type": "markdown", + "id": "9af847e4", + "metadata": {}, + "source": [ + "Because we have `.latitude`-`.longitue` values for each `Address`, we can calculate the path's `.air_distance` even *without* talking to the Google Maps API." + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "b4fbcca9", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "1475" + ] + }, + "execution_count": 29, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "path.air_distance" + ] + }, + { + "cell_type": "markdown", + "id": "e99d0883", + "metadata": {}, + "source": [ + "The `Path.sync_with_google_maps()` method loads all the data needed from Google but makes sure that we do not make another API call if we already have the data." + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "564fa98a", + "metadata": {}, + "outputs": [], + "source": [ + "path.sync_with_google_maps()" + ] + }, + { + "cell_type": "markdown", + "id": "d22864f7", + "metadata": {}, + "source": [ + "Google provides `.bicycle_distance` (in meters) and `.bicylce_duration` (in seconds) approximations for a courier's path from one location to another." + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "id": "c2532b98", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "2389" + ] + }, + "execution_count": 31, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "path.bicycle_distance" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "id": "b3b69c75", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "702" + ] + }, + "execution_count": 32, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "path.bicycle_duration" + ] + }, + { + "cell_type": "markdown", + "id": "2339190e", + "metadata": {}, + "source": [ + "In addition, the above `\"legs\"` values are stored as proper UTM coordinates for convenient plotting." + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "id": "1745b42e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[,\n", + " ,\n", + " ,\n", + " ,\n", + " ,\n", + " ,\n", + " ,\n", + " ,\n", + " ]" + ] + }, + "execution_count": 33, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "path.waypoints" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}