diff --git a/CITATION.cff b/CITATION.cff index c752405..9ad9c62 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -3,10 +3,15 @@ message: "If you use this cookbook, please cite it as below." authors: # add additional entries for each author -- see https://github.com/citation-file-format/citation-file-format/blob/main/schema-guide.md - family-names: Kent - given-names: Julia - orcid: https://orcid.org/0000-0002-5611-8986 - website: https://github.com/jukent - affiliation: UCAR/NCAR + given-names: Julia + orcid: https://orcid.org/0000-0002-5611-8986 + website: https://github.com/jukent + affiliation: UCAR/NCAR +- family-names: Clyne + given-names: John + orcid: https://orcid.org/0000-0003-2788-9017 + website: https://github.com/clyne + affiliation: UCAR/NCAR - name: "Advanced Visualization Cookbook contributors" # use the 'name' field to acknowledge organizations website: "https://github.com/ProjectPythia/advanced-viz-cookbook/graphs/contributors" title: "Advanced Visualization Cookbook" diff --git a/_toc.yml b/_toc.yml index e28a684..ab3fe00 100644 --- a/_toc.yml +++ b/_toc.yml @@ -12,6 +12,13 @@ parts: - caption: Specialty Plots chapters: - file: notebooks/taylor-diagrams + - file: notebooks/skewt - caption: Visualization of Structured Grids chapters: - file: notebooks/spagetti + - caption: Interactive Visualization + chapters: + - file: notebooks/mpas-datshader + - caption: Animation + chapters: + - file: notebooks/animatioin diff --git a/environment.yml b/environment.yml index 9a1ca7f..a1e61ec 100644 --- a/environment.yml +++ b/environment.yml @@ -16,6 +16,7 @@ dependencies: - seaborn - bokeh - uxarray + - datashader - geocat-datafiles - tropycal - pip diff --git a/notebooks/animation.ipynb b/notebooks/animation.ipynb new file mode 100644 index 0000000..e694b4d --- /dev/null +++ b/notebooks/animation.ipynb @@ -0,0 +1,238 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Animation" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "source": [ + "time stamp at 1:19" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "NCL_animate_1\n", + "\n", + "Please note:\n", + " - Executing this script will not display a gif, but you have the option to uncomment a line at the bottom that will save a gif in the same directory as this script.\n", + "\n", + "[GeoCAT-examples](https://geocat-examples.readthedocs.io/en/latest/gallery/Animations/NCL_animate_1.html)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Import packages:\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "outputs": [], + "source": [ + "import cartopy.crs as ccrs\n", + "import matplotlib.animation as animation\n", + "import numpy as np\n", + "import xarray as xr\n", + "from matplotlib import pyplot as plt\n", + "\n", + "import geocat.datafiles as gdf\n", + "import geocat.viz as gv" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Read in data:\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "outputs": [], + "source": [ + "# Open a netCDF data file using xarray default engine and load the data into xarrays\n", + "# Disable time decoding due to missing necessary metadata\n", + "ds = xr.open_dataset(gdf.get(\"netcdf_files/meccatemp.cdf\"), decode_times=False)\n", + "\n", + "tas = ds.t\n", + "tas" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Create animation:\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "outputs": [], + "source": [ + "# Set up Axes with Cartopy Projection\n", + "fig = plt.figure(figsize=(10, 8))\n", + "ax = plt.axes(projection=ccrs.PlateCarree(central_longitude=150))\n", + "ax.coastlines(linewidths=0.5)\n", + "ax.set_extent([-180, 180, -90, 90], ccrs.PlateCarree())\n", + "\n", + "# Use geocat.viz.util convenience function to set axes limits & tick values\n", + "gv.set_axes_limits_and_ticks(ax,\n", + " xlim=(-180, 180),\n", + " ylim=(-90, 90),\n", + " xticks=np.linspace(-180, 180, 13),\n", + " yticks=np.linspace(-90, 90, 7))\n", + "\n", + "# Use geocat.viz.util convenience function to add minor and major tick lines\n", + "gv.add_major_minor_ticks(ax, labelsize=10)\n", + "\n", + "# Use geocat.viz.util convenience function to make latitude, longitude tick labels\n", + "gv.add_lat_lon_ticklabels(ax)\n", + "\n", + "# create initial plot that establishes a colorbar\n", + "tas[0, :, :].plot.contourf(ax=ax,\n", + " transform=ccrs.PlateCarree(),\n", + " vmin=195,\n", + " vmax=328,\n", + " levels=53,\n", + " cmap=\"inferno\",\n", + " cbar_kwargs={\n", + " \"extendrect\": True,\n", + " \"orientation\": \"horizontal\",\n", + " \"ticks\": np.arange(195, 332, 9),\n", + " \"label\": \"\",\n", + " \"shrink\": 0.90\n", + " })\n", + "\n", + "\n", + "# animate function for matplotlib FuncAnimation\n", + "def animate(i):\n", + " tas[i, :, :].plot.contourf(\n", + " ax=ax,\n", + " transform=ccrs.PlateCarree(),\n", + " vmin=195,\n", + " vmax=328,\n", + " levels=53,\n", + " cmap=\"inferno\",\n", + " add_colorbar=False,\n", + " )\n", + "\n", + " gv.set_titles_and_labels(\n", + " ax,\n", + " maintitle=\"January Global Surface Temperature (K) - Day \" +\n", + " str(tas.coords['time'].values[i])[:13],\n", + " xlabel=\"\",\n", + " ylabel=\"\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "jupyter": { + "outputs_hidden": false + } + }, + "source": [ + "### Run:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "outputs": [], + "source": [ + "# runs the animation initiated with the frame from init and progressed with the animate function\n", + "anim = animation.FuncAnimation(fig, animate, frames=30, interval=200)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "jupyter": { + "outputs_hidden": false + } + }, + "source": [ + "### Save:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "outputs": [], + "source": [ + "# Uncomment this line to save the created animation\n", + "anim.save('images/animate_1.gif', writer='pillow', fps=5);" + ] + } + ], + "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.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/notebooks/mpas-datashader.ipynb b/notebooks/mpas-datashader.ipynb new file mode 100644 index 0000000..b38457f --- /dev/null +++ b/notebooks/mpas-datashader.ipynb @@ -0,0 +1,414 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "tags": [] + }, + "source": [ + "# MPAS with Datashader and Geoviews\n", + "\n", + "\n", + "## Interactively plotting unstructured grid MPAS data with Datashader and Geoviews\n", + "\n", + "This example demonstrates several key points:\n", + "\n", + "1. Making use of the MPAS file's connectivity information to render data on the native grid, and also\n", + "avoid costly Delaunay triangulation that is required if the MPAS connectivity information is not used.\n", + "2. Rendering data that is sampled on both the 'primal' and 'dual' MPAS mesh.\n", + "3. Using geoviews/holoviews for interactive plotting in a Jupyter Notebook. The plotting is interactive\n", + "in the sense that you can pan and zoom the data. Doing so will reveal greater and greater data fidelity.\n", + "2. Using Datashader to perform background rendering in place of Matplotlib. Unlike Matplotlib, Datashader\n", + "was designed for performance with large data sets.\n", + "\n", + "\n", + "## The data\n", + "The global data sets used in this example are from the same experiment, but run at several resolutions from\n", + "30km to 3.75km. Due to their size, the higher resolution data sets are only distributed with two variables\n", + "in them:\n", + "+ relhum_200hPa: Relative humidity vertically interpolated to 200 hPa\n", + "+ vorticity_200hPa: Relative vorticity vertically interpolated to 200 hPa\n", + "\n", + "The relhum_200hPa is computed on the MPAS 'primal' mesh, while the vorticity_200hPa is computed on the MPAS\n", + "'dual' mesh. Note that data may also be sampled on the edges of the primal mesh. This example does not\n", + "include/cover edge-centered data.\n", + "\n", + "These data are courtesy of NCAR's Falko Judt, and were produced as part of the DYAMOND initiative: \n", + " http://dx.doi.org/10.1186/s40645-019-0304-z.\n", + " " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import cartopy.crs as ccrs\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "import math as math\n", + "\n", + "import geocat.datafiles as gdf # Only for reading-in datasets\n", + "\n", + "from xarray import open_mfdataset\n", + "\n", + "from numba import jit\n", + "\n", + "import dask.dataframe as dd\n", + "\n", + "import holoviews as hv\n", + "from holoviews import opts\n", + "\n", + "from holoviews.operation.datashader import rasterize as hds_rasterize \n", + "#import geoviews.feature as gf # only needed for coastlines\n", + "\n", + "hv.extension(\"bokeh\",\"matplotlib\")\n", + "\n", + "opts.defaults(\n", + " opts.Image(frame_width=600, data_aspect=1),\n", + " opts.RGB(frame_width=600, data_aspect=1))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Utility functions\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# This funtion splits a global mesh along longitude\n", + "#\n", + "# Examine the X coordinates of each triangle in 'tris'. Return an array of 'tris' where only those triangles\n", + "# with legs whose length is less than 't' are returned. \n", + "# \n", + "def unzipMesh(x,tris,t):\n", + " return tris[(np.abs((x[tris[:,0]])-(x[tris[:,1]])) < t) & (np.abs((x[tris[:,0]])-(x[tris[:,2]])) < t)]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# Compute the signed area of a triangle\n", + "#\n", + "def triArea(x,y,tris):\n", + " return ((x[tris[:,1]]-x[tris[:,0]]) * (y[tris[:,2]]-y[tris[:,0]])) - ((x[tris[:,2]]-x[tris[:,0]]) * (y[tris[:,1]]-y[tris[:,0]]))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# Reorder triangles as necessary so they all have counter clockwise winding order. CCW is what Datashader and MPL\n", + "# require.\n", + "#\n", + "def orderCCW(x,y,tris):\n", + " tris[triArea(x,y,tris)<0.0,:] = tris[triArea(x,y,tris)<0.0,::-1]\n", + " return(tris)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# Create a Holoviews Triangle Mesh suitable for rendering with Datashader\n", + "#\n", + "# This function returns a Holoviews TriMesh that is created from a list of coordinates, 'x' and 'y',\n", + "# an array of triangle indices that addressess the coordinates in 'x' and 'y', and a data variable 'var'. The\n", + "# data variable's values will annotate the triangle vertices\n", + "#\n", + "\n", + "def createHVTriMesh(x,y,triangle_indices, var,n_workers=1):\n", + " # Declare verts array\n", + " verts = np.column_stack([x, y, var])\n", + "\n", + "\n", + " # Convert to pandas\n", + " verts_df = pd.DataFrame(verts, columns=['x', 'y', 'z'])\n", + " tris_df = pd.DataFrame(triangle_indices, columns=['v0', 'v1', 'v2'])\n", + "\n", + " # Convert to dask\n", + " verts_ddf = dd.from_pandas(verts_df, npartitions=n_workers)\n", + " tris_ddf = dd.from_pandas(tris_df, npartitions=n_workers)\n", + "\n", + " # Declare HoloViews element\n", + " tri_nodes = hv.Nodes(verts_ddf, ['x', 'y', 'index'], ['z'])\n", + " trimesh = hv.TriMesh((tris_ddf, tri_nodes))\n", + " return(trimesh)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# Triangulate MPAS primary mesh:\n", + "#\n", + "# Triangulate each polygon in a heterogenous mesh of n-gons by connecting\n", + "# each internal polygon vertex to the first vertex. Uses the MPAS\n", + "# auxilliary variables verticesOnCell, and nEdgesOnCell.\n", + "#\n", + "# The function is decorated with Numba's just-in-time compiler so that it is translated into\n", + "# optimized machine code for better peformance\n", + "#\n", + "\n", + "@jit(nopython=True)\n", + "def triangulatePoly(verticesOnCell, nEdgesOnCell):\n", + "\n", + " # Calculate the number of triangles. nEdgesOnCell gives the number of vertices for each cell (polygon)\n", + " # The number of triangles per polygon is the number of vertices minus 2.\n", + " #\n", + " nTriangles = np.sum(nEdgesOnCell - 2)\n", + "\n", + " triangles = np.ones((nTriangles, 3), dtype=np.int64)\n", + " nCells = verticesOnCell.shape[0]\n", + " triIndex = 0\n", + " for j in range(nCells):\n", + " for i in range(nEdgesOnCell[j]-2):\n", + " triangles[triIndex][0] = verticesOnCell[j][0]\n", + " triangles[triIndex][1] = verticesOnCell[j][i+1]\n", + " triangles[triIndex][2] = verticesOnCell[j][i+2]\n", + " triIndex += 1\n", + "\n", + " return triangles" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load data and coordinates\n", + "\n", + "The dyamond_1 data set is available in several resolutions, ranging from 30 km to 3.75 km.\n", + "\n", + "Currently, the 30-km resolution dataset used in this example is available at geocat-datafiles.\n", + "However, the other resolutions of these data are stored on Glade because of their size.\n" + ] + }, + { + "cell_type": "raw", + "metadata": { + "tags": [] + }, + "source": [ + "# Load data\n", + "\n", + "datafiles = (gdf.get(\"netcdf_files/MPAS/FalkoJudt/dyamond_1/30km/diag.2016-08-20_00.00.00_subset.nc\"),\n", + " gdf.get(\"netcdf_files/MPAS/FalkoJudt/dyamond_1/30km/x1.655362.grid_subset.nc\") )\n", + "\n", + "primalVarName = 'relhum_200hPa'\n", + "dualVarName = 'vorticity_200hPa'\n", + "central_longitude = 0.0\n", + "\n", + "ds = open_mfdataset(datafiles, decode_times=False)\n", + "primalVar = ds[primalVarName].isel(Time=0).values\n", + "dualVar = ds[dualVarName].isel(Time=0).values\n", + "\n", + "# Fetch lat and lon coordinates for the primal and dual mesh.\n", + "lonCell = ds['lonCell'].values * 180.0 / math.pi\n", + "latCell = ds['latCell'].values * 180.0 / math.pi\n", + "lonCell = ((lonCell - 180.0) % 360.0) - 180.0\n", + "\n", + "lonVertex = ds['lonVertex'].values * 180.0 / math.pi\n", + "latVertex = ds['latVertex'].values * 180.0 / math.pi\n", + "lonVertex = ((lonVertex - 180.0) % 360.0) - 180.0" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "tags": [] + }, + "source": [ + "## Example 1 - Using MPAS's cell connectivity array to plot primal mesh data\n", + "\n", + "In this example we use the MPAS `cellsOnVertex` auxilliary variable, which defines mesh connectivity for the MPAS grid.\n", + "Specifically, this variable tells us the cell IDs for each cell that contains each vertex.\n", + "\n", + "The benefits of this are twofold: 1. We're using the actual mesh description from the MPAS output file; and 2. \n", + "For large grid this is *much* faster than synthesizing the connectivity information as is done\n", + "in the next example\n" + ] + }, + { + "cell_type": "raw", + "metadata": { + "tags": [] + }, + "source": [ + "# Get triangle indices for each vertex in the MPAS file. Note, indexing in MPAS starts from 1, not zero :-(\n", + "#\n", + "tris = ds.cellsOnVertex.values - 1\n", + "\n", + "# The MPAS connectivity array unforunately does not seem to guarantee consistent clockwise winding order, which\n", + "# is required by Datashader (and Matplotlib)\n", + "#\n", + "tris = orderCCW(lonCell,latCell,tris)\n", + "\n", + "# Lastly, we need to \"unzip\" the mesh along a constant line of longitude so that when we project to PCS coordinates\n", + "# cells don't wrap around from east to west. The function below does the job, but it assumes that the \n", + "# central_longitude from the map projection is 0.0. I.e. it will cut the mesh where longitude \n", + "# wraps around from -180.0 to 180.0. We'll need to generalize this\n", + "#\n", + "tris = unzipMesh(lonCell,tris,90.0)\n", + "\n", + "\n", + "# Project verts from geographic to PCS coordinates\n", + "#\n", + "projection = ccrs.Robinson(central_longitude=central_longitude)\n", + "xPCS, yPCS, _ = projection.transform_points(ccrs.PlateCarree(), lonCell, latCell).T\n", + "\n", + "\n", + "trimesh = createHVTriMesh(xPCS,yPCS,tris, primalVar,n_workers=n_workers)" + ] + }, + { + "cell_type": "raw", + "metadata": { + "tags": [] + }, + "source": [ + "# Use precompute so it caches the data internally\n", + "rasterized = hds_rasterize(trimesh, aggregator='mean', precompute=True)\n", + "rasterized.opts(tools=['hover'], colorbar=True, cmap='coolwarm') * gf.coastline(projection=projection)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 2 - Synthesizing triangles from points using Delaunay triangulation\n", + "\n", + "In this second example we do not use the triangle connectivity information stored in the MPAS file. Instead we\n", + "use Delaunay triangulation to artifically create a triangle mesh. The benefit of this approach is that we do not\n", + "need the MPAS cellsOnVertex variable if it is not available. Also, since the triangulation algorithm is run on the \n", + "coordinates after they are projected to meters we do not have to worry about wraparound. The downside is that for\n", + "high-resolution data Delaunay triangulation is prohibitively expensive. The highest resolution data set included\n", + "in this notebook (3.75km) will not triangulate in a reasonable amount of time, if at all \n" + ] + }, + { + "cell_type": "raw", + "metadata": {}, + "source": [ + "# Use Delaunay triangulation to generate the triangle connectivity. Note, it's important that the coordinate \n", + "# arrays already be in PCS coordinates (not lat-lon) for the triangulation to perform optimally\n", + "#\n", + "\n", + "from matplotlib.tri import Triangulation\n", + "\n", + "tris = Triangulation(xPCS,yPCS).triangles\n", + "\n", + "trimesh = createHVTriMesh(xPCS,yPCS,tris, primalVar,n_workers=n_workers)" + ] + }, + { + "cell_type": "raw", + "metadata": {}, + "source": [ + "# Use precompute so it caches the data internally\n", + "rasterized = hds_rasterize(trimesh, aggregator='mean', precompute=True)\n", + "rasterized.opts(tools=['hover'], colorbar=True, cmap='coolwarm') * gf.coastline(projection=projection)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 3 - Using MPAS's cell connectivity array to plot dual mesh data\n", + "\n", + "In this example we use the MPAS `verticesOnCell` and `nEdgesOnCell` auxilliary variables, which defines mesh connectivity for the\n", + "MPAS dual grid.\n", + "\n", + "As with the first example using the MPAS primal grid, data on the dual grid could be plotted by first\n", + "triangulating them with, for example, Delaunay triangulation. But using grid's native connectivity information \n", + "is faster.\n" + ] + }, + { + "cell_type": "raw", + "metadata": {}, + "source": [ + "verticesOnCell = ds.verticesOnCell.values - 1\n", + "nEdgesOnCell = ds.nEdgesOnCell.values\n", + "\n", + "# For the dual mesh the data are located on triangle centers, which correspond to cell (polygon) vertices. Here\n", + "# we decompose each cell into triangles\n", + "#\n", + "tris = triangulatePoly(verticesOnCell, nEdgesOnCell)\n", + "\n", + "tris = unzipMesh(lonVertex,tris,90.0)\n", + "\n", + "# Project verts from geographic to PCS coordinates\n", + "#\n", + "projection = ccrs.Robinson(central_longitude=central_longitude)\n", + "xPCS, yPCS, _ = projection.transform_points(ccrs.PlateCarree(), lonVertex, latVertex).T\n", + "\n", + "trimesh = createHVTriMesh(xPCS,yPCS,tris, dualVar,n_workers=n_workers)" + ] + }, + { + "cell_type": "raw", + "metadata": {}, + "source": [ + "rasterized = hds_rasterize(trimesh, aggregator='mean', precompute=True)\n", + "rasterized.opts(tools=['hover'], colorbar=True, cmap='coolwarm') * gf.coastline(projection=projection)" + ] + } + ], + "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.10.12" + }, + "widgets": { + "application/vnd.jupyter.widget-state+json": { + "state": {}, + "version_major": 2, + "version_minor": 0 + } + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/notebooks/skewt.ipynb b/notebooks/skewt.ipynb new file mode 100644 index 0000000..4c9c0be --- /dev/null +++ b/notebooks/skewt.ipynb @@ -0,0 +1,40 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Skew T Diagrams" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "advanced-viz-cookbook", + "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.10.12" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +}