{ "cells": [ { "cell_type": "markdown", "id": "77a8342d-96a2-406a-96b9-f0fbb2ec08ab", "metadata": {}, "source": [ "# xdggs-dggrid4py: A xdggs plugin for IGEO7 DGGS\n", "\n", "`xdggs-dggrid4py` aims to provides IGEO7 DGGS indexing for xarray datasets through xdggs. It consists of 2 main functions:\n", "\n", "1. Easy access to IGEO7 indexed xarray datasets, including query the datasets with points in WGS84, or zone ID.\n", "2. Convert raster dataset in GeoTiff format to IGEO7 indexed xarray datasets\n", "\n", "## Converting a GeoTiff into an IGEO7 DGGS indexed xarray dataset\n", "There are many ways to convert a GeoTiff into a DGGS indexed dataset. The main idea is to assign a pixel with a DGGS zone ID.\n", "- Nearest zone centroid: Assign the pixel to the nearest zone centroid, measured by the CRS supported by the DGGS. For IGEO7 DGGS, it uses WGS84. \n", "- Zonal statistics: Assign pixels that are within a zone of the DGGS. \n", "- Weighted average: Overlay the DGGS zone's grid on the raster grid, then, for each zone, calculate the percentage of area overlapped with pixels as the weights. Finally, calculate the zone's value using the weights and raster value.\n", "\n", "### General steps of the nearest-neighbourhood method. \n", "\n", "This notebook demonstrates conversion using the nearest neighbourhood method with IGEO7 DGGS.\n", "\n", "1. The first step is to determine the refinement level to use. It affects the number of rows of the dataset after conversion.\n", " - For example, if the refinement level approximately matches the spatial resoultion (in square meters), then the expected number of rows in the resulting dataset should be roughly equal to the number of zones of the region being converted. But if a coarser refinement level is used, the number of rows in the converted result should equal the number of pixels. \n", "\n", "2. Then, generate the zone centroids together with ID that exist in the region of the GeoTiff\n", "\n", "3. Assign each pixel to the nearest zone by measuring the distance between the pixel and zone centroids.\n", "\n", "\n", "\n", "### Demonstration \n", "\n", "#### Install the package from `pypi` or install it from GitHub " ] }, { "cell_type": "code", "execution_count": null, "id": "f1f79c88-03fa-4619-b957-f2c80ec203eb", "metadata": {}, "outputs": [], "source": [ "# install it from pypi : \n", "# !pip install xdggs-dggrid4py\n", "# or install it from GitHub for the lastest commits.\n", "# pip install git+https://github.com/LandscapeGeoinformatics/xdggs-dggrid4py.git\n", "\n", "#At the time of preparing this notebook, a bug was found in dggrid4py (https://github.com/allixender/dggrid4py/issues/46) \n", "# that affect some functions of xdggs (e.g. `sel_lonlat`). It was fixed in the main branch, but has not yet been built into a release. \n", "# It needs to install the dggrid4py from git. \n", "#!pip uninstall -y dggrid4py \n", "#!pip install git+https://github.com/allixender/dggrid4py.git" ] }, { "cell_type": "markdown", "id": "b9fdf530-e9f9-449a-812d-6d2fef4fefdc", "metadata": {}, "source": [ "#### Import libraries" ] }, { "cell_type": "code", "execution_count": 1, "id": "50935bd5-b1a1-4c3a-b678-8ae23bb51042", "metadata": {}, "outputs": [], "source": [ "import xarray as xr\n", "import rioxarray as rio\n", "import os\n", "import warnings\n", "warnings.filterwarnings(\"ignore\")\n", "\n", "# xdggs-dggrid4py require both dggrid4py and DGGRID installed\n", "# export the path of DGGRID binary with the environment variable \"DGGRID_PATH\"\n", "os.environ['DGGRID_PATH']= \"/home/dick/micromamba/envs/xdggs/bin/dggrid\"" ] }, { "cell_type": "markdown", "id": "3572b4fb-3a71-4d4d-ae2b-340988b61611", "metadata": {}, "source": [ "#### Using rioxarray to load a GeoTiff into xarray dataset\n", "\n", "In this example, we use a sample collection prepared by the [Landscape and Geoinformatics Lab](https://landscape-geoinformatics.ut.ee/) of the University of Tartu, which is available on Zenodo.\n", "\n", "The collection consists of three GeoTiff files (DEM, TWI, Slope) located around Elva, Estonia. [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.18877265.svg)](https://doi.org/10.5281/zenodo.18877265)" ] }, { "cell_type": "code", "execution_count": 2, "id": "c631bcf5-7747-4282-8cd2-5ff94c6fa891", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "
<xarray.Dataset> Size: 8MB\n",
       "Dimensions:      (x: 1779, y: 1081)\n",
       "Coordinates:\n",
       "  * x            (x) float64 14kB 6.334e+05 6.335e+05 ... 6.512e+05 6.512e+05\n",
       "  * y            (y) float64 9kB 6.468e+06 6.468e+06 ... 6.457e+06 6.457e+06\n",
       "    spatial_ref  int64 8B 0\n",
       "Data variables:\n",
       "    band_1       (y, x) float32 8MB ...\n",
       "Attributes:\n",
       "    AREA_OR_POINT:  Area
" ], "text/plain": [ " Size: 8MB\n", "Dimensions: (x: 1779, y: 1081)\n", "Coordinates:\n", " * x (x) float64 14kB 6.334e+05 6.335e+05 ... 6.512e+05 6.512e+05\n", " * y (y) float64 9kB 6.468e+06 6.468e+06 ... 6.457e+06 6.457e+06\n", " spatial_ref int64 8B 0\n", "Data variables:\n", " band_1 (y, x) float32 8MB ...\n", "Attributes:\n", " AREA_OR_POINT: Area" ] }, "execution_count": 2, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Using DEM sample to demonstrate the conversion, you can also try with others sample using the following links\n", "# Slope: https://zenodo.org/records/18877265/files/est_topo_slope_10m_clipped.tif?download=1\n", "# TWI: https://zenodo.org/records/18877265/files/est_topo_twi_10m_clipped.tif?download=1\n", "\n", "tiff_path = \"https://zenodo.org/records/18877265/files/est_topo_dem_10m_clipped.tif?download=1\"\n", "raster_ds = rio.open_rasterio(tiff_path, band_as_variable=True, masked=True)\n", "raster_ds" ] }, { "cell_type": "markdown", "id": "12d156e6-5096-4c10-8f77-8f8ce05a4272", "metadata": {}, "source": [ "### Using xdggs-dggrid4py regridding module to convert Raster dataset with the nearest zone centroid method.\n", "\n", "`xdggs-dggrid4py` follows the general steps described above for the nearest centroid method, it used S2PointIndex from [pys2Index](https://github.com/benbovy/pys2index) to calculate the distance between pixel coordinates and the zone centroids in WGS84. \n", "\n", "#### Import the conversion method \n", " - `xdggs-dggrid4py.regridding.centroid_based.nearestcentroid`: nearest centroid method implementation\n", " - `xdggs-dggrid4py.regridding.centroid_based.mapblocks_nearestcentroid`: nearest centroid method implementation for parallelism\n", " \n", "#### Import the regridding method\n", "`xdggs-dggrid4py` supports conversion with parallelism or not.\n", "\n", " - `xdggs_dggrid4py.regridding`\n", " - `regridding`: perform regridding with a single process \n", " - `mapblocks_regridding`: perform regridding with mapblocks parallelism\n", "\n", "**Mapblocks parallelism**\n", "\n", "To run conversion in parallel, it first partitions the GeoTiff into smaller blocks. Partition is done using xarray chunks; each block is then processed individually, as described in the general steps. Then, it combines the results from each block to form the final dataset to return. The parallelism is achieved using `dask.array.map_blocks`.\n", "\n", "**Input parameters for regridding:**\n", " - `ds`: the xarray dataset to be converted\n", " - `grid_name`: the name of DGGS to be used for conversion. `IGEO7`\n", " - `method`: method that used for regridding\n", " - `coordinates`: the coordinate variables name of the ds in list. Default to `[x, y]`\n", " - `original_crs`: CRS in wkt format. xdggs-dggrid4py will use the CRS from `crs_wkt` if it exist in the spatial_ref of `ds`. Otherwise, using the wkt supplied. Defualt to `None`. \n", " - `refinement_level`: An integer to indicate at which refinement level to convert. Default to `-1`\n", " - If set to `-1`. It automatically calculates the finest refinement level that matches the spatial resolution. It is done by calculating the surface area of a sphere that represents the GeoTiff region, then dividing that area by the number of pixels to obtain the average area per pixel. The average area per pixel is used to determine the refinement level to be used.\n", " - `wgs84_geodetic_conversion`: Default to `True`.\n", " - convert the input clip bounds from WGS84 geodetic coordinates to the authalic sphere coordinates.\n", " - convert hexagon centroids from the authalic sphere to WGS84 geodetic before assigning pixels to zone centroids.\n", " - `dggs_vert0_lon`: The longitude of the initial vertex. It is set to 11.20 by default; users can change it to other values by passing the parameter to the function. Default to `11.20`\n", " - `zone_id_repr`: Which representation is used for the zone ID, it supports : `['int', 'hexstring', 'textual']`, default to 'int'." ] }, { "cell_type": "code", "execution_count": 3, "id": "9bfcacc2-5cba-4744-8811-8608c76f8e1f", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Registered regridding method centerpoint\n", "Registered regridding method nearestcentroid\n", "Registered regridding method mapblocks_nearestcentroid\n", "xdggs_dggrid4py.utils area of extent (km^2): 206.60011408860592\n", "xdggs_dggrid4py.utils average area per square grid (km^2): 0.00010743082602019237\n", "CPU times: user 12.4 s, sys: 456 ms, total: 12.8 s\n", "Wall time: 15.6 s\n" ] }, { "data": { "text/html": [ "
\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "
<xarray.Dataset> Size: 23MB\n",
       "Dimensions:      (zone_id: 1923099)\n",
       "Coordinates:\n",
       "  * zone_id      (zone_id) int64 15MB 18804137500082175 ... 18669025747795967\n",
       "    spatial_ref  int64 8B 0\n",
       "Data variables:\n",
       "    band_1       (zone_id) float32 8MB 39.01 39.21 39.33 ... 109.7 110.0 110.2\n",
       "Attributes:\n",
       "    AREA_OR_POINT:  Area
" ], "text/plain": [ " Size: 23MB\n", "Dimensions: (zone_id: 1923099)\n", "Coordinates:\n", " * zone_id (zone_id) int64 15MB 18804137500082175 ... 18669025747795967\n", " spatial_ref int64 8B 0\n", "Data variables:\n", " band_1 (zone_id) float32 8MB 39.01 39.21 39.33 ... 109.7 110.0 110.2\n", "Attributes:\n", " AREA_OR_POINT: Area" ] }, "execution_count": 3, "metadata": {}, "output_type": "execute_result" } ], "source": [ "%%time\n", "\n", "# To perform conversion using nearest centroid mehtod as a single process\n", "refinement_level=11\n", "from xdggs_dggrid4py.regridding.methods.centroid_based import nearestcentroid\n", "from xdggs_dggrid4py.regridding import regridding\n", "igeo7_indexed_ds = regridding(ds=raster_ds,\n", " grid_name='igeo7', \n", " method='nearestcentroid',\n", " refinement_level=refinement_level,\n", " wgs84_geodetic_conversion=True, \n", " zone_id_repr='int')\n", "\n", "# To perform conversion using nearest centroid mehtod with mapblocks parallelism\n", "# Try with auto finer refinement level (-1), uncomment below to run\n", "#\n", "#from xdggs_dggrid4py.regridding.methods.centroid_based import mapblocks_nearestcentroid\n", "#from xdggs_dggrid4py.regridding import mapblocks_regridding\n", "#refinement_level=-1\n", "#raster_ds = raster_ds.chunk({'x':200, 'y':200}) # create partitions using chunks\n", "#igeo7_indexed_ds = mapblocks_regridding(ds=raster_ds,\n", "# grid_name='igeo7', \n", "# method='mapblocks_nearestcentroid',\n", "# refinement_level=refinement_level, \n", "# wgs84_geodetic_conversion=True, \n", "# zone_id_repr='int')\n", "igeo7_indexed_ds" ] }, { "cell_type": "markdown", "id": "58cf5573-a1a6-48e8-9790-1802b822a3e4", "metadata": {}, "source": [ "Now, the 2D dataset is converted into a 1D dataset indexed by IGEO7 DGGRS. The related grid info is stored as the `zone_id` attributes." ] }, { "cell_type": "code", "execution_count": 4, "id": "794a158e-5e81-4299-9025-6753c6984353", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "
<xarray.Dataset> Size: 92kB\n",
       "Dimensions:      (zone_id: 7664)\n",
       "Coordinates:\n",
       "  * zone_id      (zone_id) int64 61kB 18667920867459071 ... 18805349754601471\n",
       "    spatial_ref  int64 8B 0\n",
       "Data variables:\n",
       "    band_1       (zone_id) float32 31kB 73.59 73.35 74.41 ... 68.39 68.5 68.68\n",
       "Attributes:\n",
       "    AREA_OR_POINT:  Area
" ], "text/plain": [ " Size: 92kB\n", "Dimensions: (zone_id: 7664)\n", "Coordinates:\n", " * zone_id (zone_id) int64 61kB 18667920867459071 ... 18805349754601471\n", " spatial_ref int64 8B 0\n", "Data variables:\n", " band_1 (zone_id) float32 31kB 73.59 73.35 74.41 ... 68.39 68.5 68.68\n", "Attributes:\n", " AREA_OR_POINT: Area" ] }, "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Apply mean aggregation to the dataset by zone ID\n", "igeo7_indexed_ds = igeo7_indexed_ds.groupby('zone_id').mean()\n", "igeo7_indexed_ds" ] }, { "cell_type": "markdown", "id": "d6d03741-22a4-4ec9-8b0b-5dc6f477283a", "metadata": {}, "source": [ "### Optional, create multi-refinement level dataset using IGEO7 z7 hierarchical structure\n", "z7 indexing provides a hierarchical structure between refinement levels, known as the 1 to 7 parent-child hierarchy. Using the zone ID, it is easy to calculate the parent zone at a coarser refinement level it belongs to. Therefore, using this relation, it is simple to perform aggregation at different refinement levels for a multi-refinement level dataset." ] }, { "cell_type": "code", "execution_count": 5, "id": "059e211b-66bc-4375-b6fa-2f4bf300cbe3", "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", "# function to get the parent zone id of the input zone ID \n", "def get_parent_z7int(z7_int_zone_id, refinement_level=int):\n", " binary_repr = np.binary_repr(z7_int_zone_id, width=64)\n", " binary_repr = binary_repr[:(refinement_level*3)+4]\n", " binary_repr = binary_repr.ljust(64, '1')\n", " return int(binary_repr,2)" ] }, { "cell_type": "code", "execution_count": 16, "id": "84d959ed-3db3-4ae0-9ab4-3d1aa3663834", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "done refinement level 1 (0.014177799224853516)\n", "done refinement level 2 (0.024408817291259766)\n", "done refinement level 3 (0.03421354293823242)\n", "done refinement level 4 (0.008975744247436523)\n", "done refinement level 5 (0.008719444274902344)\n", "done refinement level 6 (0.008643150329589844)\n", "done refinement level 7 (0.008930683135986328)\n", "done refinement level 8 (0.009763240814208984)\n", "done refinement level 9 (0.010070085525512695)\n", "done refinement level 10 (0.010570526123046875)\n", "done refinement level 11 (0.011226892471313477)\n" ] }, { "data": { "text/plain": [ "" ] }, "execution_count": 16, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Uisng xarray datatree to store dataset at each refinement level as a group\n", "ds_datatree = xr.DataTree(name=\"Mutli_refinement_levels_datatree\")\n", "\n", "import time\n", "import zarr\n", "encode = {}\n", "compressor = zarr.Blosc(cname=\"zstd\", clevel=3, shuffle=2)\n", "for rf_level in range(1, refinement_level+1):\n", " s = time.time()\n", " #print(f'working on refinement level {rf_level}')\n", " tmp = igeo7_indexed_ds.copy()\n", " tmp['zone_id'] = xr.apply_ufunc(get_parent_z7int, tmp.zone_id, dask='parallelized', vectorize=True,\n", " kwargs={'refinement_level': rf_level})\n", " tmp = tmp.groupby('zone_id').mean(skipna=True)\n", " tmp = tmp.chunk('auto')\n", " enc = {x: {\"compressor\": compressor} for x in list(tmp.data_vars.keys())}\n", " enc.update({\"zone_id\": {\"compressor\": compressor}})\n", " ds_datatree = ds_datatree.assign({f\"refinement_level_{rf_level}\": xr.DataTree(dataset=tmp)})\n", " encode.update({f\"/refinement_level_{rf_level}\": enc})\n", " print(f'done refinement level {rf_level} ({time.time()-s})')\n", "ds_datatree.to_zarr(f'./multi_refinement_level_ds.zarr', encoding=encode)" ] }, { "cell_type": "markdown", "id": "1ee0ae99-625c-4730-ac09-4085b2f3dc2e", "metadata": {}, "source": [ "## Accessing IGEO7 indexed DGGS xarray datasets using `xdggs-dggrid4py`\n", "\n", "After the conversion is done, the dataset is indexed with IGEO7 DGGS. However, the index doesn't mean anything to others. Therefore, `xdggs-dggrid4py` provides the `IGEO7Index` as an interface between the user and the IGEO7 DGGS index. It is implemented as a plugin of [xdggs](https://github.com/xarray-contrib/xdggs/tree/main)." ] }, { "cell_type": "code", "execution_count": 27, "id": "f28d67e9-017f-4bc6-919c-8bb3c4439a68", "metadata": {}, "outputs": [], "source": [ "# import xdggs and register IGEO7Index by importing the IGEO7Index\n", "import xdggs\n", "from xdggs_dggrid4py.index import IGEO7Index" ] }, { "cell_type": "code", "execution_count": 30, "id": "5407cbfd-5aed-464a-8303-33509b30a4dc", "metadata": {}, "outputs": [], "source": [ "# since the xdggs convention uses the name `cell_ids` as the DGGS zone ID, we have to rename the `zone_id` to `cell_ids`\n", "igeo7_indexed_ds = igeo7_indexed_ds.rename({'zone_id': 'cell_ids'})" ] }, { "cell_type": "markdown", "id": "7c371a75-a76f-4d9f-a323-ae257b3fef4c", "metadata": {}, "source": [ "### Create a IGEO7Index instance for the IGEO7 DGGS dataset\n", "- Invoke the xdggs.decode function to create an IGEO7Index index object for the dataset\n", "- From the output, the `decode` function created an IGEO7Index object using the attributes from `cell_ids` (zone_id)" ] }, { "cell_type": "code", "execution_count": 31, "id": "f58b1af4-dadc-4f03-869d-6ffe3dec33d7", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "
<xarray.Dataset> Size: 92kB\n",
       "Dimensions:      (cell_ids: 7664)\n",
       "Coordinates:\n",
       "  * cell_ids     (cell_ids) int64 61kB 18667920867459071 ... 18805349754601471\n",
       "    spatial_ref  int64 8B 0\n",
       "Data variables:\n",
       "    band_1       (cell_ids) float32 31kB dask.array<chunksize=(144,), meta=np.ndarray>\n",
       "Indexes:\n",
       "    cell_ids  IGEO7Index(level=11)\n",
       "Attributes:\n",
       "    AREA_OR_POINT:  Area
" ], "text/plain": [ " Size: 92kB\n", "Dimensions: (cell_ids: 7664)\n", "Coordinates:\n", " * cell_ids (cell_ids) int64 61kB 18667920867459071 ... 18805349754601471\n", " spatial_ref int64 8B 0\n", "Data variables:\n", " band_1 (cell_ids) float32 31kB dask.array\n", "Indexes:\n", " cell_ids IGEO7Index(level=11)\n", "Attributes:\n", " AREA_OR_POINT: Area" ] }, "execution_count": 31, "metadata": {}, "output_type": "execute_result" } ], "source": [ "igeo7_indexed_ds = igeo7_indexed_ds.pipe(xdggs.decode)\n", "igeo7_indexed_ds" ] }, { "cell_type": "markdown", "id": "ecb41279-20f7-4dd4-b92a-8de6c0b91b58", "metadata": {}, "source": [ "### Using IGEO7Index to access the dataset\n", "- xdggs provides several accessor functions through `.dggs`\n", " - `sel_latlon`:\n", " - `cell_boundaries`:\n", " - `explore`:" ] }, { "cell_type": "code", "execution_count": 32, "id": "e14de106-4e3c-4aa3-ad8c-55a41d7f4859", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "[########################################] | 100% Completed | 103.37 ms\n", "[########################################] | 100% Completed | 203.46 ms\n" ] }, { "data": { "text/html": [ "
\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "
<xarray.Dataset> Size: 44B\n",
       "Dimensions:      (cell_ids: 3)\n",
       "Coordinates:\n",
       "  * cell_ids     (cell_ids) int64 24B 18802036187332607 ... 18801924518182911\n",
       "    spatial_ref  int64 8B 0\n",
       "Data variables:\n",
       "    band_1       (cell_ids) float32 12B dask.array<chunksize=(3,), meta=np.ndarray>\n",
       "Indexes:\n",
       "    cell_ids  IGEO7Index(level=11)\n",
       "Attributes:\n",
       "    AREA_OR_POINT:  Area
" ], "text/plain": [ " Size: 44B\n", "Dimensions: (cell_ids: 3)\n", "Coordinates:\n", " * cell_ids (cell_ids) int64 24B 18802036187332607 ... 18801924518182911\n", " spatial_ref int64 8B 0\n", "Data variables:\n", " band_1 (cell_ids) float32 12B dask.array\n", "Indexes:\n", " cell_ids IGEO7Index(level=11)\n", "Attributes:\n", " AREA_OR_POINT: Area" ] }, "execution_count": 32, "metadata": {}, "output_type": "execute_result" } ], "source": [ "lats = [58.28459946, 58.27637829, 58.26980135]\n", "lons = [26.41758320, 26.42799669, 26.36551576]\n", "igeo7_indexed_subset = igeo7_indexed_ds.dggs.sel_latlon(lats, lons)\n", "igeo7_indexed_subset" ] }, { "cell_type": "code", "execution_count": 33, "id": "6430690b-e092-499a-ab51-97a0ca369952", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "[########################################] | 100% Completed | 433.60 ms\n", "[########################################] | 100% Completed | 533.49 ms\n", "[########################################] | 100% Completed | 4.43 ss\n", "[########################################] | 100% Completed | 4.44 s\n", "CPU times: user 5.18 s, sys: 101 ms, total: 5.28 s\n", "Wall time: 5.51 s\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { "model_id": "b3093a61b74147d88c608d1816b09258", "version_major": 2, "version_minor": 1 }, "text/plain": [ "" ] }, "execution_count": 33, "metadata": {}, "output_type": "execute_result" } ], "source": [ "%%time\n", "igeo7_indexed_ds['band_1'].compute().dggs.explore()" ] }, { "cell_type": "code", "execution_count": null, "id": "0fee4d67-13ed-45cf-a5d5-ea259d5a9116", "metadata": {}, "outputs": [], "source": [] } ], "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.12.0" } }, "nbformat": 4, "nbformat_minor": 5 }