{ "cells": [ { "cell_type": "markdown", "id": "d62259d8-54a4-4f98-8faa-91bd35350919", "metadata": {}, "source": [ "# Interacting with HDF5 files\n", "`cait` uses HDF5 files to store data and intermediate results for later use. This can be voltage traces of triggered events, timestamps of testpulses, calculcated recoil energies and many more. The `DataHandler` class makes interacting with these files very convenient and safe. You should never be in need of opening the HDF5 file yourself -- a practice that we heavily discourage as it can lead to corrupted files.\n", "In the following we explore the core features of the `DataHandler` class.\n", "\n", "We start by importing `cait`" ] }, { "cell_type": "code", "execution_count": 2, "id": "80097d9b-3357-4c9b-b4e6-c4eff3b29203", "metadata": {}, "outputs": [], "source": [ "import cait as ai" ] }, { "cell_type": "markdown", "id": "9b57e197-e90e-4e86-88a6-71784c84066d", "metadata": {}, "source": [ "## Mock data and DataHandler object\n", "The HDF5 file used in this notebook can be created using the following commands. If you already have an HDF5 file and a corresponding `DataHandler` object, you can skip this step." ] }, { "cell_type": "code", "execution_count": null, "id": "02a3379b-0077-40fa-ae7e-21e962812afb", "metadata": {}, "outputs": [], "source": [ "test_data = ai.data.TestData(filepath='./mock_001', duration=10000)\n", "test_data.generate()\n", "dh = ai.DataHandler(channels=[0,1])\n", "dh.convert_dataset(path_rdt='./', fname='mock_001', path_h5='./')\n", "dh.set_filepath(path_h5='./', fname='mock_001')\n", "\n", "dh.calc_mp()\n", "dh.calc_mp(type='testpulses')\n", "dh.calc_mp(type='noise')\n", "\n", "dh.calc_additional_mp(type='events', no_of=True)\n", "dh.calc_additional_mp(type='testpulses', no_of=True)\n", "dh.calc_additional_mp(type='noise', no_of=True)" ] }, { "cell_type": "markdown", "id": "9e2937a0-dea7-47df-a0ab-f9dc7fe4b6a6", "metadata": {}, "source": [ "Finally, we create the `DataHandler` object:" ] }, { "cell_type": "code", "execution_count": 3, "id": "0d43049a-9396-4507-9eb4-fcd430499050", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "DataHandler Instance created.\n" ] } ], "source": [ "# create DataHandler instance\n", "dh = ai.DataHandler(channels=[0,1])\n", "\n", "# set the path to the desired HDF5 file\n", "dh.set_filepath(path_h5='../testdata', fname='mock_001', appendix=False)" ] }, { "cell_type": "markdown", "id": "1d8a4ed7-bf89-4553-a2bf-26f130ff45cf", "metadata": {}, "source": [ "## Inspecting File\n", "A quick way to get a summary of a `DataHandler`'s properties is to print it." ] }, { "cell_type": "code", "execution_count": 4, "id": "f571a50c-cacb-439c-999d-ad5e9bbbf901", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "DataHandler linked to HDF5 file '../testdata/mock_001.h5'\n", "HDF5 file size on disk: 278.7 MiB\n", "Groups in file: ['events', 'noise', 'testpulses'].\n", "\n", "Time between first and last testpulse: 2.77 h\n", "First testpulse on/at: 2020-10-16 20:22:06+00:00 (UTC)\n", "Last testpulse on/at: 2020-10-16 23:08:36+00:00 (UTC)\n", "\n" ] } ], "source": [ "print(dh)" ] }, { "cell_type": "markdown", "id": "db6beef7-3d03-41d9-b48e-d84db6173257", "metadata": {}, "source": [ "Depending on the available groups/datasets in the HDF5 file, this summary is more or less informative (e.g. the testpulse stats can of course only be printed if available in the file).\n", "The filename/path/directory of the HDF5 file can be accessed via the following methods which support both relative and absolute paths. We highly recommend to use those methods when referencing the HDF5 file and to *not* directly use properties of the `DataHandler`." ] }, { "cell_type": "code", "execution_count": null, "id": "767354dd-a03f-4fca-a424-50bca804134f", "metadata": {}, "outputs": [], "source": [ "dh.get_filepath() # ../testdata/mock_001.h5\n", "dh.get_filename() # mock_001\n", "dh.get_filedirectory() # ../testdata\n", "dh.get_filedirectory(absolute=True) #/Users/.../testdata" ] }, { "cell_type": "markdown", "id": "33018ea6-e678-45e9-a24b-92dbff017f57", "metadata": {}, "source": [ "We can get a more detailed view into the contents of the HDF5 file with the `content`-method which takes the group of interest as an argument (you can also use *wildcards* like `events*` to list all groups with names starting with `events`). If no group is specified, the datasets of all groups are listed.\n", "This method can be used to inspect the datasets' shapes and dtypes. \n", "If you want to get an explanation on how to interpret the output, set `print_info=True`." ] }, { "cell_type": "code", "execution_count": 6, "id": "a86d7935-0241-4e78-87b8-2f1b88664f94", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "\u001b[1m\u001b[95mevents\u001b[0m\n", " \u001b[1m\u001b[36madd_mainpar \u001b[0m\u001b[93m \u001b[0m (2, 444, 16) float64\n", " |array_max (2, 444)\n", " |array_min (2, 444)\n", " |var_first_eight (2, 444)\n", " |mean_first_eight (2, 444)\n", " |var_last_eight (2, 444)\n", " |mean_last_eight (2, 444)\n", " |var (2, 444)\n", " |mean (2, 444)\n", " |skewness (2, 444)\n", " |max_derivative (2, 444)\n", " |ind_max_derivative (2, 444)\n", " |min_derivative (2, 444)\n", " |ind_min_derivative (2, 444)\n", " |max_filtered (2, 444)\n", " |ind_max_filtered (2, 444)\n", " |skewness_filtered_peak (2, 444)\n", " \u001b[1m\u001b[36mdac_output \u001b[0m\u001b[93m \u001b[0m (444,) float64\n", " \u001b[1m\u001b[36mevent \u001b[0m\u001b[93m \u001b[0m (2, 444, 16384) float32\n", " \u001b[1m\u001b[36mhours \u001b[0m\u001b[93m \u001b[0m (444,) float64\n", " \u001b[1m\u001b[36mmainpar \u001b[0m\u001b[93m \u001b[0m (2, 444, 10) float64\n", " |pulse_height (2, 444)\n", " |onset (2, 444)\n", " |rise_time (2, 444)\n", " |decay_time (2, 444)\n", " |slope (2, 444)\n", " \u001b[1m\u001b[36mtime_mus \u001b[0m\u001b[93m \u001b[0m (444,) int32\n", " \u001b[1m\u001b[36mtime_s \u001b[0m\u001b[93m \u001b[0m (444,) int32\n" ] } ], "source": [ "dh.content(\"events\", print_info=False)" ] }, { "cell_type": "markdown", "id": "38c9c537-4a4a-4bc1-8e6a-221ef3df19f9", "metadata": {}, "source": [ "## Access Data in the HDF5\n", "\n", "The groups and datasets in the HDF5 file underlying the `DataHandler` are accessed through the `get` method. To read out the full content of a dataset you specify the group and the dataset name.\n", "Alternatively, you can also slice the dataset already in the call to `get`. E.g. you can access only the first channel of the data, or provide a boolean flag (or a list of indices). The `get` method supports up to 3 indices corresponding to the (up to) 3 dimensions of the data, and they are understood in increasing order. Therefore, to index only the first and third dimension for example, you have to provide `None` for the second dimension.\n", "Notice that due to a `h5py` limitation, only one dimension at a time can be indexed with boolean flags/index lists. " ] }, { "cell_type": "code", "execution_count": null, "id": "c5d47a4c-7973-41b6-91c7-712f6c3e1ae8", "metadata": {}, "outputs": [], "source": [ "# all pulse heights of all channel; shape: (2,444)\n", "dh.get(\"events\", \"pulse_height\")\n", "# voltage trace of the 37th event in first channel; shape: (16384,)\n", "dh.get(\"events\", \"event\", 0, 37) \n", "\n", "# voltage traces of all events in first channel with pulse height < 1; shape: (151, 16384)\n", "flag = dh.get(\"events\", \"pulse_height\", 0) < 1\n", "dh.get(\"events\", \"event\", 0, flag)\n", "\n", "# pulse heights for all channels where pulse height of first channel <1; shape: (2,151)\n", "dh.get(\"events\", \"pulse_height\", None, flag) " ] }, { "cell_type": "markdown", "id": "f2675a30-cdcd-474e-b9a4-d1960db604cc", "metadata": {}, "source": [ "Note that the following two calls to `get` yield the same values yet the second one is more memory-efficient because the slicing is done on the HDF5 file. The first option loads the entire dataset into memory and performs the slicing *afterwards*." ] }, { "cell_type": "code", "execution_count": null, "id": "b6abeff6-e483-47fe-9806-7f8a6c17cbcc", "metadata": {}, "outputs": [], "source": [ "dh.get(\"events\", \"event\")[0,flag] # slicing of numpy.ndarray\n", "dh.get(\"events\", \"event\", 0, flag) # slicing of HDF5 file" ] }, { "cell_type": "markdown", "id": "8255e4e6", "metadata": {}, "source": [ "For seasoned `cait`-users, the following **short-cut notation** could be a huge time save. It is equivalent to using the `dh.get()` method but it allows for more concise code:" ] }, { "cell_type": "code", "execution_count": null, "id": "9d7d5981", "metadata": {}, "outputs": [], "source": [ "dh[\"events/pulse_height\"] # equivalent to dh.get(\"events\", \"pulse_height\")\n", "dh[\"events/event\", 0, 37] # equivalent to dh.get(\"events\", \"event\", 0, 37)\n", "dh[\"events/event\", 0, flag] # equivalent to dh.get(\"events\", \"event\", 0, flag)\n", "dh[\"events/pulse_height\", :, flag] # equivalent to dh.get(\"events\", \"pulse_height\", None, flag) " ] }, { "cell_type": "markdown", "id": "5f1e548f", "metadata": {}, "source": [ "I.e. you can slice the `DataHandler` object with keys of the form `'/'` and possibly additional indices. Notice, that here you can actually use the colon (`:`) operator, which in the `get()` method had to be replaced by `None`.\n", "**Furthermore**, this notation allows for **TAB-auto-completion** in iPython/notebook contexts (type `dh[` and press `TAB`)." ] }, { "cell_type": "markdown", "id": "56920ca1-8c32-4580-a6a3-e8b8d5c4605e", "metadata": {}, "source": [ "## Write Data to the HDF5 File\n", "To write data to the HDF5 file, you use the `set` method for which there are two main use cases:\n", "- writing an array into the file as is (i.e. we keep the shape and dtype the same)\n", "- creating a multi-channel array and writing only to one channel (e.g. you calculate some property for the phonon and light channel separately but want to write them to the same HDF5 dataset)\n", "\n", "The two use cases are illustrated below.\n", "Notice that you can write multiple datasets at once as long as they are in the same group. Dataset names and their content are parsed as keyword arguments.\n", "If you want the datasets to have a specific dtype, you can hand it to `set` as well." ] }, { "cell_type": "code", "execution_count": null, "id": "ee41b518-1bf4-4a68-9bce-75e40dbea30f", "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", "\n", "data1, data2 = np.random.rand(1,37,100), np.random.rand(1,37,100)\n", "\n", "# Include 'data1' and 'data2' as datasets 'new_ds1' and 'new_ds2' in group 'new_group' \n", "# ('new_ds1' and 'new_ds2' do not yet exist)\n", "dh.set(group=\"new_group\", new_ds1=data1, new_ds2=data2)\n", "\n", "# Include 'data1' and 'data2' as datasets 'ds1_mc' and 'ds2_mc' in group 'new_group2' \n", "# ('data1' and 'data2' are 1-dimensional but we want to create 2-dimensional \n", "# datasets (for different channels e.g.) and write the data into the 0-th channel. This also\n", "# works for writing single channels to already existing multi-channel datasets.)\n", "dh.set(group=\"new_group2\", n_channels=2, channel=0, ds1_mc=data1, ds2_mc=data2)" ] }, { "cell_type": "markdown", "id": "c7ec4a69-1658-477e-9812-22e01064d671", "metadata": {}, "source": [ "If the dataset(s) already exist in the given group, you have to explicitly allow `set` to change them:" ] }, { "cell_type": "code", "execution_count": null, "id": "06df8808-464a-493c-b233-d0dcb1f136fd", "metadata": {}, "outputs": [], "source": [ "# Include 'data1' and 'data2' as datasets 'ds1' and 'ds2' in group 'new_group' \n", "# (either or both of 'ds1' and 'ds2' already exist and have correct shape/dtype for new\n", "# data)\n", "dh.set(group=\"new_group\", ds1=data1, ds2=data2, change_existing=True) \n", "\n", "# Include 'data1' and 'data2' as datasets 'ds1' and 'ds2' in group 'new_group' \n", "# (either or both of 'ds1' and 'ds2' already exist and have incorrect shape/dtype for new\n", "# data, but we want to force the new dtype/shape)\n", "dh.set(group=\"new_group\", ds1=data1, ds2=data2, overwrite_existing=True)" ] }, { "cell_type": "markdown", "id": "4a3103c5-e17c-4959-8a57-a8647fe3ede7", "metadata": {}, "source": [ "By calling `content` again, we can look at the newly created datasets." ] }, { "cell_type": "markdown", "id": "46b659fc-de01-4f24-83c0-feea3a57643e", "metadata": {}, "source": [ "## Renaming and Deleting Datasets/Groups, Repackaging\n", "You can rename and delete datasets/groups using the methods `rename` and `drop` as follows. Just like with `set`, you can do this for multiple datasets simultaneously as long as they are within the same group. The syntax once more uses keyword arguments.\n", "\n", "*Disclaimer:* Due to the possibility of changing both the group name and dataset names with a single method, renaming groups called `group` is obviously not possible." ] }, { "cell_type": "code", "execution_count": null, "id": "e98e7af6-2eb9-4358-bddf-7c23fa01107f", "metadata": {}, "outputs": [], "source": [ "# Rename groups 'new_group' and 'new_group2' to 'new_group_renamed' and 'new_group2_renamed'\n", "dh.rename(new_group='new_group_renamed', new_group2='new_group2_renamed')\n", "\n", "# Rename datasets 'ds1' and 'ds2' in group 'new_group_renamed' to 'new_ds1' and 'new_ds2'\n", "dh.rename(group='new_group_renamed', ds1='ds1_renamed', ds2='ds2_renamed')" ] }, { "cell_type": "markdown", "id": "f671f322-8a82-4e66-a9fe-8abc07c9ebc8", "metadata": {}, "source": [ "Groups/datasets are deleted as follows:" ] }, { "cell_type": "code", "execution_count": null, "id": "fd4b6c0d-1d8d-4c72-afbf-5f7e62c68be7", "metadata": {}, "outputs": [], "source": [ "# Drop group 'new_group_renamed'\n", "dh.drop(\"new_group_renamed\")\n", "\n", "# Drop dataset 'ds1' from group 'new_group_renamed'\n", "dh.drop(\"new_group2_renamed\", \"ds1_mc\")" ] }, { "cell_type": "markdown", "id": "67cc72c5-68fd-4cfd-a4bb-c64d5ac09cdb", "metadata": {}, "source": [ "**Important note:** Deleting groups/datasets does *not* decrease the HDF5 file's size due to the HDF5 file's data structure. To decrease the size, repackaging has to be performed for which we provide the method `repackage`. Alternatively, this method can automatically be called when deleting a dataset/group using the `repackage=True` argument of the `drop` method." ] }, { "cell_type": "code", "execution_count": 52, "id": "59b57375-e6cd-4afe-830b-67531a7ceace", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Successfully repackaged '../testdata/mock_001.h5'. Memory saved: 194.4 KiB\n" ] } ], "source": [ "dh.repackage()" ] }, { "cell_type": "markdown", "id": "29a57ae6-c661-43a8-a3d1-84de8b46f688", "metadata": {}, "source": [ "## Combining multiple HDF5 files\n", "The HDF5 library allows to link separate files into a single file *without* copying the data. The resulting file looks identical to a regular HDF5 file. To avoid confusion in such cases, a number of quality-of-life features are included in `DataHandler`.\n", "The need for [virtual datasets](https://support.hdfgroup.org/HDF5/docNewFeatures/NewFeaturesVirtualDatasetDocs.html) arises for example when analysing more than one file at a time. Below we show how files can be combined (see also dedicated tutorial for merging files) and how to interact with them." ] }, { "cell_type": "code", "execution_count": null, "id": "a55a222a-5406-463a-9c9a-265d7a5d2eba", "metadata": {}, "outputs": [], "source": [ "import shutil # just used to create a quick copy of our already existing mock data file\n", "import os # just used to create a quick copy of our already existing mock data file\n", "import cait.versatile as vai\n", "\n", "shutil.copy(dh.get_filepath(), os.path.join(dh.get_filedirectory(),'to_merge1.h5'))\n", "shutil.copy(dh.get_filepath(), os.path.join(dh.get_filedirectory(),'to_merge2.h5'))" ] }, { "cell_type": "code", "execution_count": 59, "id": "a565c1c1-4ebb-474e-a55d-792aad8e281d", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Successfully combined files ['to_merge1', 'to_merge2'] into '../testdata/merged_file.h5' (16.8 KiB).\n" ] } ], "source": [ "ai.data.combine_h5(fname=\"merged_file\",\n", " files=[\"to_merge1\", \"to_merge2\"],\n", " src_dir=\"../testdata\",\n", " out_dir=\"../testdata\",\n", " groups_combine=[\"testpulses\",\"noise\"],\n", " groups_include=[])" ] }, { "cell_type": "markdown", "id": "6b044ec0-dad8-4ab4-a362-9e7e1af61445", "metadata": {}, "source": [ "As you can see, the resulting file size is only a few KiB due to no data being copied. We can now create a `DataHandler` to the combined file and inspect it with our usual methods:" ] }, { "cell_type": "code", "execution_count": null, "id": "bde72b2a-561d-498b-84b2-3a15cd30f392", "metadata": {}, "outputs": [], "source": [ "dh_combined = ai.DataHandler(channels=[0,1])\n", "dh_combined.set_filepath(path_h5='../testdata', fname='merged_file', appendix=False)" ] }, { "cell_type": "code", "execution_count": 65, "id": "bbd820cd-e14e-4916-b124-4ae966ede530", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "DataHandler linked to HDF5 file '../testdata/merged_file.h5'\n", "HDF5 file size on disk: 16.8 KiB\n", "Groups in file: ['noise', 'testpulses'].\n", "\n", "The HDF5 file contains virtual datasets linked to the following files: {'../testdata/to_merge2.h5', '../testdata/to_merge1.h5'}\n", "All of the external sources are currently available.\n", "\n", "Time between first and last testpulse: 2.77 h\n", "First testpulse on/at: 2020-10-16 20:22:06+00:00 (UTC)\n", "Last testpulse on/at: 2020-10-16 23:08:36+00:00 (UTC)\n", "\n" ] } ], "source": [ "print(dh_combined)" ] }, { "cell_type": "code", "execution_count": 66, "id": "c0eb86fb-85f5-4764-aa38-337ae2fbd4b2", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "The HDF5 file contains the following \u001b[1m\u001b[95mgroups\u001b[0m and \u001b[1m\u001b[36mdatasets\u001b[0m, which can be accessed through get(group, dataset). If present, some contents of the mainpar and add_mainpar datasets are displayed as well. For convenience, they can also be accessed through get(), even though they are not separate datasets in the HDF5 file.\n", "Datasets marked with \u001b[93m(v)\u001b[0m are virtual datasets, i.e. they are stored in another (or multiple other) HDF5 file(s). They are treated like regular datasets but be aware that writing to such datasets actually writes to the respective original files.\n", "\n", "\u001b[1m\u001b[95mtestpulses\u001b[0m\n", " \u001b[1m\u001b[36madd_mainpar \u001b[0m\u001b[93m (v)\u001b[0m (2, 2666, 16) float64\n", " |array_max (2, 2666)\n", " |array_min (2, 2666)\n", " |var_first_eight (2, 2666)\n", " |mean_first_eight (2, 2666)\n", " |var_last_eight (2, 2666)\n", " |mean_last_eight (2, 2666)\n", " |var (2, 2666)\n", " |mean (2, 2666)\n", " |skewness (2, 2666)\n", " |max_derivative (2, 2666)\n", " |ind_max_derivative (2, 2666)\n", " |min_derivative (2, 2666)\n", " |ind_min_derivative (2, 2666)\n", " |max_filtered (2, 2666)\n", " |ind_max_filtered (2, 2666)\n", " |skewness_filtered_peak (2, 2666)\n", " \u001b[1m\u001b[36mdac_output \u001b[0m\u001b[93m (v)\u001b[0m (2666,) float64\n", " \u001b[1m\u001b[36mevent \u001b[0m\u001b[93m (v)\u001b[0m (2, 2666, 16384) float32\n", " \u001b[1m\u001b[36mhours \u001b[0m\u001b[93m (v)\u001b[0m (2666,) float64\n", " \u001b[1m\u001b[36mmainpar \u001b[0m\u001b[93m (v)\u001b[0m (2, 2666, 10) float64\n", " |pulse_height (2, 2666)\n", " |onset (2, 2666)\n", " |rise_time (2, 2666)\n", " |decay_time (2, 2666)\n", " |slope (2, 2666)\n", " \u001b[1m\u001b[36mtestpulseamplitude \u001b[0m\u001b[93m (v)\u001b[0m (2666,) float64\n", " \u001b[1m\u001b[36mtime_mus \u001b[0m\u001b[93m (v)\u001b[0m (2666,) int32\n", " \u001b[1m\u001b[36mtime_s \u001b[0m\u001b[93m (v)\u001b[0m (2666,) int32\n" ] } ], "source": [ "dh_combined.content(\"testpulses\")" ] }, { "cell_type": "markdown", "id": "cb7652e3-e347-466d-97f9-290403059101", "metadata": {}, "source": [ "We see that `print(dh_combined)` tells us which files are linked to the HDF5 file of our `DataHandler` object. In principle it could happend that some of these source files get deleted. In this case, you could still create the `DataHandler` but subsequent calculations can have unexpected behaviour. This is why `print(dh_combined)` also tells you whether all sources are available or not.\n", "\n", "Using `content` now also changed slightly as it shows you an indicator next to datasets which are virtual.\n", "\n", "In principle, this is all the difference there is. You can do analysis with `dh_combined` in the same way you would do with `dh`. The only thing worth mentioning is that *writing* to virtual datasets is special. Depending on your use case you could either want to change the data in all source files (which could lead to confusion and unexpected results), or you could want to detach the virtual dataset and write to a regular dataset of that name. In most cases, the latter will probably be the desired behaviour. As a safety feature, the `set` method checks whether you are attempting to write to a virtual dataset or not and you *have* to make this decision upon calling it." ] }, { "cell_type": "code", "execution_count": null, "id": "76190e13-b5a6-43bb-a5f2-49ad93582605", "metadata": {}, "outputs": [], "source": [ "# Include 'new_data' as dataset 'hours' in group 'noise' \n", "# ('hours' already exists and is a virtual dataset with matching shape but dtype 'float64'.\n", "# We want to write to the original data in the respective source files.)\n", "old_data = dh_combined.get(\"noise\",\"hours\")\n", "new_data = old_data + 2 # shift in time-axis\n", "dh_combined.set(group=\"noise\", hours=new_data, dtype='float64', write_to_virtual=True)\n", "\n", "# Include 'new_data' as dataset 'hours' in group 'noise' \n", "# ('hours' already exists and is a virtual dataset. We want to overwrite it and create a \n", "# non-virtual dataset instead)\n", "dh_combined.set(group=\"noise\", hours=new_data, write_to_virtual=False)" ] }, { "cell_type": "markdown", "id": "f55daf43", "metadata": {}, "source": [ "**To actually merge** the files, use `cait.data.merge_h5` instead of `cait.data.combine_h5`. This will create a new file containing all the data, i.e. you could delete the individual files afterwards. We still recommend to use `cait.data.combine_h5`." ] }, { "cell_type": "markdown", "id": "a998ce54", "metadata": {}, "source": [] } ], "metadata": { "kernelspec": { "display_name": "venv_cait", "language": "python", "name": "venv_cait" }, "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.9.6" } }, "nbformat": 4, "nbformat_minor": 5 }