diff --git a/book/_toc.yml b/book/_toc.yml index 4ded3b8..67266c4 100644 --- a/book/_toc.yml +++ b/book/_toc.yml @@ -54,6 +54,7 @@ parts: title: Albedo sections: - file: tutorials/albedo/aviris-ng-data + - file: tutorials/albedo/snow_albedo - file: tutorials/NN_with_Pytorch/intro title: Neural Networks with Pytorch sections: diff --git a/book/tutorials/albedo/images_for_notebook/albedo_measure.png b/book/tutorials/albedo/images_for_notebook/albedo_measure.png new file mode 100644 index 0000000..7e65071 Binary files /dev/null and b/book/tutorials/albedo/images_for_notebook/albedo_measure.png differ diff --git a/book/tutorials/albedo/images_for_notebook/em_spectrum.png b/book/tutorials/albedo/images_for_notebook/em_spectrum.png new file mode 100644 index 0000000..5a82838 Binary files /dev/null and b/book/tutorials/albedo/images_for_notebook/em_spectrum.png differ diff --git a/book/tutorials/albedo/images_for_notebook/field_specs.png b/book/tutorials/albedo/images_for_notebook/field_specs.png new file mode 100644 index 0000000..058ea50 Binary files /dev/null and b/book/tutorials/albedo/images_for_notebook/field_specs.png differ diff --git a/book/tutorials/albedo/images_for_notebook/field_specs2.png b/book/tutorials/albedo/images_for_notebook/field_specs2.png new file mode 100644 index 0000000..8336e79 Binary files /dev/null and b/book/tutorials/albedo/images_for_notebook/field_specs2.png differ diff --git a/book/tutorials/albedo/images_for_notebook/refl_measure.png b/book/tutorials/albedo/images_for_notebook/refl_measure.png new file mode 100644 index 0000000..ccbc4b6 Binary files /dev/null and b/book/tutorials/albedo/images_for_notebook/refl_measure.png differ diff --git a/book/tutorials/albedo/images_for_notebook/spectralon.jpg b/book/tutorials/albedo/images_for_notebook/spectralon.jpg new file mode 100644 index 0000000..0ed7493 Binary files /dev/null and b/book/tutorials/albedo/images_for_notebook/spectralon.jpg differ diff --git a/book/tutorials/albedo/snow_albedo.ipynb b/book/tutorials/albedo/snow_albedo.ipynb new file mode 100644 index 0000000..d514597 --- /dev/null +++ b/book/tutorials/albedo/snow_albedo.ipynb @@ -0,0 +1,531 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Introduction to the NASA SnowEx Snow Albedo 2023 Dataset\n", + "# author: Anton Surunis\n", + "# date: 2024-09-10" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "![Field spectrometer measurements in the SnowEx 2023 Snow Albedo Campaign](./images_for_notebook/field_specs.png)\n", + "\n", + "# The NASA SnowEx 2023 Snow Albedo Field Campaign Dataset\n", + "\n", + "The NASA SnowEx 2023 Snow Albedo Field Campaign took place in burned and unburned boreal forests around Fairbanks, Alaska. The goal of the campaign was to improve understanding of the spatial, temporal, and process-based variability of snow albedo and the uncertainty of snow albedo measurements across scales in boreal forests. The campaign objectives were to capture snow albedo across scales of snow accumulation and snowmelt with coincident snow albedo from ground-based spectrometer measurements, tower-mounted and drone-based radiation measurements, and airborne AVIRIS-NG overflights across boreal forest disturbance history.\n", + "\n", + "Over five weeks from April 1st to May 5th 2023, several teams visited field sites around Fairbanks and collected spectral measurements over 500m-1km transects capturing snow reflectance and snow albedo over gradients of landscape, topography, and forest disturbance variability. During days with favorable weather/clear sky conditions, teams walked transects in teams of three collecting observations of snow spectra using field spectrometers coincident with hyperspectral aerial and satellite observations from above.\n", + "\n", + "The purpose of this tutorial is to provide an introduction to accessing and using the resulting field dataset. First, a review of background information is provided. Then, we cover how to prepare and access the different data points provided in the dataset. Finally, we provide an example of how to calculate derived statistics from the dataset.\n", + "\n", + "# Review of Hyperspectral Data\n", + "\n", + "Incoming solar radiation is either reflected, absorbed, or transmitted (or a combination of all three) depending on the surface material. This spectral response allows us to identify varying surface types (e.g. vegetation, snow, water, etc.) in a remote sensing image. The spectral resolution, or the wavelength interval, determines the amount of detail recorded in the spectral response: finer spectral resolutions have bands with narrow wavelength intervals, while coarser spectral resolutions have bands with larger wavelength intervals, and therefore, less detail in the spectral response (Credit: \"Introduction to AVIRS-NG\", Joachim Meyer, Chelsea Ackroyd, McKenzie Skiles, Phil Dennison, Keely Roth). ![https://www.neonscience.org/resources/learning-hub/tutorials/hyper-spec-intro](./images_for_notebook/em_spectrum.png)\n", + "\n", + "# Surface Reflectance vs Albedo\n", + "\n", + "Hyperspectral data is often captured as either albedo or surface reflectance.\n", + "\n", + "Albedo is the proportion of solar radiation that is reflected by a surface integrated over all incoming solar angles. This is accomplished by taking the ratio of down- and up-facing measurements of hemispherical radiation using a wide (180 degree) lens called a remote cosine receptor (RCR). Albedo is a very important property in calculating land surface energy exchange and snow-mass energy balance.\n", + "\n", + "![<https://www.scielo.br/j/rbg/a/98BNzSBYtyyw8YLPxVM9KTL/?lang=en>](./images_for_notebook/albedo_measure.png)\n", + "\n", + " In contrast, surface reflectance is the proportion of solar radiation reflected over a single or very narrow incoming solar angle (usually 4-8 degrees). Surface reflectance is calculated by taking the ratio of reflected solar radiation from a surface relative and that of a white reference.\n", + "\n", + "![<https://www.mps.mpg.de/planetary-science/moon-surface>](./images_for_notebook/refl_measure.png)\n", + "\n", + "White references are usually small panels covered in Spectralon - a highly reflective, near-Lambertian substance that reflects and scatters nearly all incoming light equally in all directions.\n", + "\n", + "![<https://www.labsphere.com/product/spectralon-reflectance-targets/>](./images_for_notebook/spectralon.jpg)\n", + "\n", + "Surface reflectance allows us to identify varying surface types (e.g. vegetation, snow, water, etc.) as well as specific qualities of those surfaces (e.g., grain size or grain type in a snowpack). While similar measures, the surface reflectance and albedo of a surface can differ considerably, especially at low solar angles where the angle of direct incident light is far off nadir. Further, since snow reflectance is based on reflected light from a white reference, it is essential that the white reference is kept pristine for accurate measurement of surface reflectance.\n", + "\n", + "# Field Spectrometers\n", + "\n", + "![More field spectrometer measurements in the SnowEx 2023 Snow Albedo Campaign](./images_for_notebook/field_specs2.png)\n", + "\n", + "Field spectrometers are remote sensing instruments that are carried into the field by operators and used to measure surface reflectance and albedo. Field spectrometers are manufactured by many different companies and come in many more different models using different spectral ranges, attachments, and processing software. While it is difficult to account for these differences without instrument intercomparison studies, it is an important fact to keep in mind when comparing hyperspectral data from different spectrometers.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Data Loading and Description" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Load python packages\n", + "import pandas as pd\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import matplotlib.lines as mlines\n", + "import folium" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# INSERT YOUR PATH HERE\n", + "path = '/Users/brent/Code/AVIRIS/field_albedo/NASA_THP2020_spec_all_v1_20240906_nsidc.csv'\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Read dataframe\n", + "df = pd.read_csv(path)\n", + "df" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Display types\n", + "df.dtypes" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The dataset includes field spectrometer measurements of snow reflectance, snow albedo, and up- and down-facing bihemispherical radiance/irradiance measurements along with lots of associated metadata. The column descriptions are as follows:\n", + "\n", + "* __id__: Unique ID number of measurement \n", + "\n", + "* __date__: The date of measurement collection\n", + "\n", + "\n", + "* __instrument__: Code corresponding to the spectrometer identifier (S1 = Spectral Evolution; S2 & S7 = ASD FieldSpec4)\n", + "* __site__: Code of study site (CARI = Caribou-Poker Creek; DEJU = Delta Junction, CRMF = Creamer’s Field)\n", + "* __transect__: Code corresponding to transect where the measurement was taken (T1 = burned forest; T2 = forested; T3 = open)\n", + "* __type__: The type of spectral measurement as recorded by the note taker (ssr = snow surface reflectance, albedo = calculated snow surface albedo, albedo_raw = up and down components of snow surface albedo, irr_raw = irradiance) attachment: The fiber-optic attachment (8deg = 8 degree optic, 4deg = 4 degree optic, rcr = remote cosine receptor)\n", + "* __orientation__: Facing of the fiber-optic attachment (down = down-facing, up = up-facing)\n", + "* __lat__: Latitude of measurement as recorded by the GPS unit (epsg:4269)\n", + "* __long__: Longitude of measurement as recorded by the GPS unit (epsg:4269)\n", + "* __spec_time__: Local date and time of measurement as reported by spectrometer\n", + "* __depth__: Snow depth in cm\n", + "* __depth_alt__: Altitude as given by the GPS unit\n", + "* __depth_acc__: Accuracy of GPS coordinates as recorded by the GPS unit\n", + "* __slope__: Slope of the ground surface in degrees calculated from USGS 3DEP DEM (10m spatial resolution) using GIS software\n", + "* __aspect__: Aspect of the ground surface in degrees calculated from USGS 3DEP DEM (10m spatial resolution) using GIS software \n", + "* __tags__: Notes taken by notetaker with discrete notes* seperated by “#”\n", + "* __rcr_group__: Grouping variable for albedo and irradiance calculations\n", + "* __wavelength__: Wavelength measured by spectrometer\n", + "* __value__: Value measured by spectrometer at the given wavelength" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Data Preparation\n", + "First, we replace -9999 (null) values with NA, set negative values to 0 and convert the date column to the “date” data type." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Replace -9999 with np.NaN\n", + "df = df.replace(-9999, np.nan)\n", + "\n", + "# Set negative values in the 'value' column to 0\n", + "df['value'] = df['value'].where(df['value'] >= 0, 0)\n", + "\n", + "# Convert the 'date' column to datetime format\n", + "df['date'] = pd.to_datetime(df['date'])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Finally, we add some grouping variables to our dataset. These variables are fairly abitrary, but, broadly, transect(s) 1 went through burned forests, transect(s) went through unburned forests, and transect(s) 3 went through open areas. The season variable splits the data into three times spans over the field campaign." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Landcover grouping column\n", + "df['landcover'] = np.where(df['site'] == \"CRMF\", \"open\",\n", + " np.where(df['transect'] == \"T1\", \"burn\",\n", + " np.where(df['transect'] == \"T2\", \"forest\", \"open\")))\n", + "\n", + "# Season grouping column\n", + "df['season'] = np.where(df['date'] < pd.to_datetime(\"2023-04-15\"), \"early\",\n", + " np.where((df['date'] >= pd.to_datetime(\"2023-04-15\")) & \n", + " (df['date'] < pd.to_datetime(\"2023-04-21\")), \"mid\", \"late\"))\n", + "\n", + "# Factor season so that it is in the right order\n", + "df['season'] = pd.Categorical(df['season'], categories=[\"early\", \"mid\", \"late\"], ordered=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Data Exploration: Measurement Locations\n", + "Let’s start exploring the data by mapping our measurement locations." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Filter and keep distinct rows\n", + "pts = df[(df['type'] == 'ssr') | (df['type'] == 'albedo')].drop_duplicates(subset='id')\n", + "pts = pts.dropna(subset=['lat', 'long']) # Remove rows with NaN in lat/lon\n", + "\n", + "# Define color mapping\n", + "color_map = {'ssr': 'blue', 'albedo': 'orange'}\n", + "pts['color'] = pts['type'].map(color_map)\n", + "\n", + "# Create a folium map centered around the mean location of your points\n", + "m = folium.Map()\n", + "\n", + "# Get the lat/lon bounds of the data\n", + "bounds = [[pts['lat'].min(), pts['long'].min()], [pts['lat'].max(), pts['long'].max()]]\n", + "\n", + "# Add circle markers\n", + "for _, row in pts.iterrows():\n", + " folium.CircleMarker(\n", + " location=[row['lat'], row['long']],\n", + " color=row['color'],\n", + " radius=5,\n", + " popup=row['type']\n", + " ).add_to(m)\n", + "\n", + "# Fit the map to the bounds of the points\n", + "m.fit_bounds(bounds)\n", + "\n", + "# Add OpenStreetMap tiles with attribution\n", + "folium.TileLayer(\n", + " 'OpenStreetMap',\n", + " name='OpenStreetMap',\n", + " attr='© <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors'\n", + ").add_to(m)\n", + "\n", + "# Display the map\n", + "m" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Data Exploration: Snow Surface Reflectance\n", + "Let’s take a look at just the reflectance data.\n", + "\n", + "First, we filter our data by snow surface reflectance (ssr) measurements only. Some of the measurements have exceptionally high reflectance values, so we filter out measurements where reflectance is too high in the visible range.\n", + "\n", + "For this example, it is looking at __just__ mid-season measurements at the CARI site." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Only types of snow surface reflectance\n", + "df_ssr = df[df['type'] == 'ssr']\n", + "\n", + "# Grouping, filtering, and then removing any that meet this condition\n", + "df_ssr = (df_ssr.groupby('id')\n", + " .filter(lambda x: not any((x['wavelength'] < 750) & (x['value'] >= 1.2)))\n", + " .reset_index(drop=True))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Creating a dataset for now just looking at CARI during mid season\n", + "df_ssr_cari = df_ssr[(df_ssr['site'] == 'CARI') & (df_ssr['season'] == 'mid')]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Here, we are grouping by wavelength and landcover, and taking the mean and standard deviation for each group\n", + "df_group = df_ssr_cari[['wavelength','value','landcover']].groupby(['wavelength','landcover']).agg(['mean','std']).reset_index()\n", + "df_group" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# These can each be plotted now with the following block\n", + "fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(10, 5), sharex=True, sharey=True)\n", + "plt.rcParams.update({'font.size': 12})\n", + "\n", + "landcovers = ['burn','forest','open']\n", + "colors = ['black', 'forestgreen', 'steelblue']\n", + "legend_handles = []\n", + "\n", + "for i in range(len(landcovers)):\n", + " df_group_lc = df_group[df_group['landcover'] == landcovers[i]]\n", + " c = colors[i]\n", + " ax.scatter(df_group_lc['wavelength'], df_group_lc['value']['mean'], c=c, s=15, alpha=1.0)\n", + " ax.fill_between(df_group_lc['wavelength'], df_group_lc['value']['mean']-df_group_lc['value']['std'],\n", + " df_group_lc['value']['mean']+df_group_lc['value']['std'], alpha=0.2, color=c)\n", + "\n", + " # Create custom legend handle for scatter points\n", + " legend_handles.append(mlines.Line2D([], [], color=c, marker='o', linestyle='None', markersize=8, label=landcovers[i]))\n", + "\n", + "\n", + "ax.set_ylim(0,1.25)\n", + "ax.legend(handles=legend_handles)\n", + "ax.set_xlabel('Wavelength [nm]')\n", + "ax.set_ylabel('Reflectance')\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Data Exploration: Snow Surface Albedo\n", + "We can repeat this same example but now for snow albedo.\n", + "\n", + "Once again, just looking at mid-season CARI collections." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Remove tags with bad and only include albedo\n", + "df_alb = df[(df['type'] == 'albedo') & (df['tags'].isna() | ~df['tags'].str.contains('bad', na=False))]\n", + "\n", + "df_alb" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "similar to the reflectance example, we will just grab CARI mid-season for now" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Creating a dataset for now just looking at CARI during mid season\n", + "df_alb_cari = df_alb[(df_alb['site'] == 'CARI') & (df_alb['season'] == 'mid')]\n", + "\n", + "# Here, we are grouping by wavelength and landcover, and taking the mean and standard deviation for each group\n", + "df_alb_group = df_alb_cari[['wavelength','value','landcover']].groupby(['wavelength','landcover']).agg(['mean','std']).reset_index()\n", + "\n", + "# And plotting\n", + "fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(10, 5), sharex=True, sharey=True)\n", + "plt.rcParams.update({'font.size': 12})\n", + "\n", + "landcovers = ['burn','forest','open']\n", + "colors = ['black', 'forestgreen', 'steelblue']\n", + "legend_handles = []\n", + "\n", + "for i in range(len(landcovers)):\n", + " df_group_lc = df_alb_group[df_alb_group['landcover'] == landcovers[i]]\n", + " c = colors[i]\n", + " ax.scatter(df_group_lc['wavelength'], df_group_lc['value']['mean'], c=c, s=15, alpha=1.0)\n", + " ax.fill_between(df_group_lc['wavelength'], df_group_lc['value']['mean']-df_group_lc['value']['std'],\n", + " df_group_lc['value']['mean']+df_group_lc['value']['std'], alpha=0.2, color=c)\n", + "\n", + " # Create custom legend handle for scatter points\n", + " legend_handles.append(mlines.Line2D([], [], color=c, marker='o', linestyle='None', markersize=8, label=landcovers[i]))\n", + "\n", + "\n", + "ax.set_ylim(0,1.25)\n", + "ax.legend(handles=legend_handles)\n", + "ax.set_xlabel('Wavelength [nm]')\n", + "ax.set_ylabel('Albedo')\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Broadband Albedo\n", + "Broadband albedo (BBA) is the ratio of upward and downward bi-hemispherical reflectance over a specific wavelength range. To calculate BBA, we weight the albedo at each band by the amount of incoming solar radiation (called irradiance) at that band and sum all results over the wavelength range. While albedo is calculated individually over each measurement band, calculations of BBA produce a single value of albedo over a given spectral range. Some common spectral ranges are shortwave BBA (0.25 μm to 5.0 μm), ultraviolet BBA (0.4 μm to 0.7 μm), and visible BBA (0.4 μm to 0.7 μm). Broadband albedo is important for calculating impurities in snowpack, especially ones that absorb light at all short wavelengths such as black carbon.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Remove tags with bad and only include albedo and upward radiation\n", + "df_bba = df[(df['type'] == 'albedo') | (df['orientation'] == 'up')]\n", + "df_bba = df_bba[(df_bba['tags'].isna() | ~df_bba['tags'].str.contains('bad', na=False))]\n", + "\n", + "df_bba\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To be brief in this document, we will show the irradiance and broadband albedo for one paired measurement (taken about the same time)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "df_pair = df_bba[(df_bba['date'] == '2023-04-20') & (df_bba['rcr_group'] == 6) & (df_bba['instrument'] == 'S1') ]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Plotting this, we can see that the signal is a bit messy in the longer wavelengths due to clouds and atmosphere." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "# Get the data and save to a simpler dataframe\n", + "df_bba_example_albedo = df_pair[df_pair['type'] == 'albedo']\n", + "df_bba_example_up = df_pair[df_pair['orientation'] == 'up']\n", + "df_test = pd.DataFrame(data=df_bba_example_albedo.wavelength.values, columns=['wavelength'])\n", + "df_test['albedo'] = df_bba_example_albedo.value.values\n", + "df_test['irrad'] = df_bba_example_up.value.values\n", + "\n", + "# Plot albedo\n", + "plt.scatter(df_test.wavelength, df_test.albedo)\n", + "\n", + "plt.ylim(0,1)\n", + "plt.ylabel('Albedo')\n", + "plt.xlabel('Wavelength [nm]')\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "And so we must remove these bands before integrating for BBA." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# In this code we are removing noise from these windows of bands\n", + "df_test = df_test[~df_test.iloc[:, 0].between(300, 400, inclusive='neither')]\n", + "df_test = df_test[~df_test.iloc[:, 0].between(1300, 1450, inclusive='neither')]\n", + "df_test = df_test[~df_test.iloc[:, 0].between(1750, 2000, inclusive='neither')]\n", + "df_test = df_test[~df_test.iloc[:, 0].between(2200, 2600, inclusive='neither')]\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Now plotting again to show the difference,\n", + "# Plot albedo\n", + "plt.scatter(df_test.wavelength, df_test.albedo)\n", + "plt.ylim(0,1)\n", + "plt.ylabel('Albedo')\n", + "plt.xlabel('Wavelength [nm]')\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Finally, we can use numerical integration for all of the valid bands to solve for BBA " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Integrating broadband albedo\n", + "broadband = np.trapz(df_test.albedo * df_test.irrad, dx=1) / np.trapz(df_test.irrad, dx=1)\n", + "\n", + "print(f'BBA: {round(broadband,3)}')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "goshawk", + "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": 2 +}