# ---
# jupyter:
#   jupytext:
#     text_representation:
#       extension: .py
#       format_name: light
#       format_version: '1.5'
#       jupytext_version: 1.14.4
#   kernelspec:
#     display_name: Python 3
#     language: python
#     name: python3
# ---

# + [markdown] toc-hr-collapsed=false
# # Choosing the right data file format for numerical computing
#
# This notebook will go over the pros and cons of various data file formats common in numerical python workflows. It'll cover various concerns when storing data on-disk and how popular formats address these challenges; the what and why of these formats.

# + [markdown] toc-hr-collapsed=false
# ## Who am I?
#
# * <NAME> <sub>pronounced like Haze</sub>
# * Software Developer (Sr. Instrumentation Technologist)<br>
#   Space Science and Engineering Center (SSEC)<br>
#   University of Wisconsin - Madison
# * Satpy and Vispy developer
# * @djhoese on Twitter and GitHub
#
# This notebook:
# https://github.com/djhoese/data-file-formats

# + [markdown] toc-hr-collapsed=true
# ## Introduction

# + [markdown] toc-hr-collapsed=false
# ### Why write data to disk?
#
# 1. State or Cache
#    * a long running application needs to start where it left off
#    * reuse calculation in future executions
#    * user settings/preferences are saved for later use
# 2. Data Archival/Distribution/Sharing
#    * results are shared with other people
#    * results are shared with other software

# + [markdown] toc-hr-collapsed=false
# ### What kind of files are we working with?
#
# * Plain text or binary file formats
# * Primarily numeric data
# * Optionally add custom metadata

# + [markdown] toc-hr-collapsed=false
# ### What are we **NOT** talking about?
#
# * Databases
# * Python's pickle format
# * Storing user application settings
# * **Custom** binary formats (no community/organization support)
# -

# ### Why does file format matter?
#
# There are many different formats that we can use in Python and we have
# different ways to access them. Each one comes with various advantages and
# disadvantages. When it comes to choosing a file format for a task we should
# be concerned with a few key things:
#
# * **Write speeds**<br>
#   How long does it take to get data from it's in-memory format to disk?
# * **Read speeds**<br>
#   How long does it take to get data from disk to a usable in-memory format?
# * **On-disk size**<br>
#   How big are the files?
# * **In-memory size**<br>
#   How much memory does it take to open the file and get data out? Are there multiple copies of the data in memory?
# * **Flexibility/Usefulness**<br>
#   Can I do anything else with the format? Store metadata? Can I use it for this other use case? Can I lazily load data? Can I access data remotely? Can I compress the data? How much data can I store in one file?
# * **Format Availability**<br>
#   Is the format only readable by Python software? Do my users need to learn something new to read it? If the format isn't "built in" to a programming environment, can I easily install the necessary libraries?

# + [markdown] toc-hr-collapsed=true
# ## Warmup - Plain Text
#
# Let's start with an example that uses plain text to write the last time that
# the function was called. We first need to import a few modules and then
# create our function to append to our data file called `last_time.txt`.

# +
import os
import sys
from datetime import datetime, timedelta

DATA_FILE = 'last_time.txt'

def write_time():
    with open(DATA_FILE, 'a') as time_file:
        now = datetime.utcnow()
        data_line = "{:f}\n".format(now.timestamp())
        time_file.write(data_line)


# -

# In our function we get the current time in
# [UTC](https://en.wikipedia.org/wiki/Coordinated_Universal_Time). We convert
# it to epoch time (a.k.a. POSIX time), seconds since `1970-01-01 00:00:00`, and
# write the string representation to the file as a single line.
#
# Let's add a couple times to the file by running the `write_time` a couple
# times below.

write_time()


# And we can use the command `head` to print out the first couple lines of the file's contents:

# !head $DATA_FILE

# We've written data to the file, now let's read it back out. The below function
# will read the content's of the file in to a list.

def read_times():
    exec_times = []
    with open(DATA_FILE, 'r') as time_file:
        for line in time_file:
            time_val = float(line.strip())
            exec_times.append(time_val)
    return exec_times


read_times()[:10]

# The code above gives us a simple example of saving data from software to a
# file on disk. We wrote a single value at a time and accumulate more
# information as time went on. We were able to read these data back
# in to python at a later time. Could we have done anything differently?

# ### Plain Text - Pros/Cons
#
# Let's take the above concerns and look at our text file from before and the
# code we used to access it.
#
# <details>
# <summary>Pros</summary>
#     
# * Human readable
# * Simple code (no external libraries)
# * Easily usable by other languages/tools
# * Could read one value at a time (but we didn't)
#     
# </details>
#
# <details>
#     <summary>Cons</summary>
#
# * Have to convert data to/from string and float (slow)
# * Representing each 8 byte float (64-bit) as ~17 ASCII bytes
# * Unknown precision of data values, how many decimal points?
# * Don't know how many elements until it is fully read
# * Can't easily seek to a specific index/element
# * Code: Read as a list instead of a numpy array and used a python for loop (potentially slow)
#     
# </details>
# <br>
#
# And here's what a single value of our text file looks like on disk (8-bit ASCII character):
#
# <table>
#     <tr>
#         <th>Byte Offset</th>
#         <th>Value</th>
#     </tr>
#     <tr><td>0</td><td>1</td></tr>
#     <tr><td>1</td><td>5</td></tr>
#     <tr><td>2</td><td>6</td></tr>
#     <tr><td>3</td><td>8</td></tr>
#     <tr><td>4</td><td>6</td></tr>
#     <tr><td>5</td><td>6</td></tr>
#     <tr><td>6</td><td>7</td></tr>
#     <tr><td>7</td><td>8</td></tr>
#     <tr><td>8</td><td>3</td></tr>
#     <tr><td>9</td><td>2</td></tr>
#     <tr><td>10</td><td>.</td></tr>
#     <tr><td>11</td><td>8</td></tr>
#     <tr><td>12</td><td>2</td></tr>
#     <tr><td>13</td><td>7</td></tr>
#     <tr><td>14</td><td>4</td></tr>
#     <tr><td>15</td><td>6</td></tr>
#     <tr><td>16</td><td>8</td></tr>
#     <tr><td>17</td><td>\n</td></tr>
#     </table>

# We can perform a quick timing to see how long it takes to read the file:

# txt_time_write = %timeit -o -n 10000 -r 1 write_time()

# txt_time_read = %timeit -o -n 100 -r 1 read_times()

# The NumPy library also provides a function for loading data from text files.
# Let's try it and see how it compares.

import numpy as np
# txt_time_read_np = %timeit -o -n 100 -r 1 np.loadtxt(DATA_FILE)

# It seems that in this specific case and with all of the extra checks numpy
# performs, it actually takes longer to read the data with numpy. When it comes
# to simple human-readable formats, we couldn't have gone much simpler.
#
# The remainder of this document will go through different use cases and
# the file formats that we have as options. We'll apply this type of
# performance analysis to our format choices.

# + [markdown] toc-hr-collapsed=true
# ## Flat Binary
#
# When human-readability isn't necessary, another option for storing simple
# data structures is a flat binary file. A flat binary file consists of the
# raw data values stored contiguously as a flat array. Let's rewrite our code
# from above to write to a flat binary file using the numpy library.

# +
import numpy as np

BIN_DATA_FILE = 'last_time.dat'

def write_time_binary():
    with open(BIN_DATA_FILE, 'a') as time_file:
        now = datetime.utcnow()
        np.array([now.timestamp()], dtype=np.float64).tofile(time_file)


# -

def read_times_binary():
     return np.fromfile(BIN_DATA_FILE, dtype=np.float64)


# bin_time_write = %timeit -o -n 100000 -r 1 write_time_binary()

# bin_time_read = %timeit -o -n 100 -r 7 read_times_binary()

# ### Perform Tip: Memory Maps
#
# By default, when reading a file from disk we have to transfer data from
# the hard disk to system memory. That means that when creating something
# like a numpy array from a binary data file we are transferring **all** of the
# file's contents from disk in to memory when we might not use it right away; a very slow operation. There is a
# lazier, generally more efficient, method called memory mapping (a.k.a. mmap in
# C, memmap by numpy). By creating a memory map, we allocate the virtual memory
# space for our data, but the operating system won't load the data from disk
# until we need it. Memory mapping avoids extra copies of file data in memory, works very well with random accesses to data in a file, can be cached more efficiently, shared between processes more effectively, and is generally your best option for reading large files that are difficult to hold in memory at a single time.
#
# Further reading:
#
# * [Stackoverflow answer discussing memory maps](https://stackoverflow.com/a/6383253/433202)
# * [Memory-mapped File - Wikipedia](https://en.wikipedia.org/wiki/Memory-mapped_file)
#
# Going back to the above binary file usage...

def read_times_binary_memmap():
    return np.memmap(BIN_DATA_FILE, mode='r', dtype=np.float64)


# bin_time_read_mmap = %timeit -o -n 100 -r 7 read_times_binary_memmap()

bin_time_read.average / bin_time_read_mmap.average

# Keep in mind that memory mapping isn't **loading** the data in to memory so this isn't technically a fair comparison. However, as we use the memory mapped array object we should see better performance than a traditional read of the file.
#
# Note that we could also use memory maps to write data. This is most beneficial if we are writing to random locations in the file.

# + [markdown] toc-hr-collapsed=false
# ### Flat Binary - Pros/Cons
#
# <details>
#     <summary>Pros</summary>
#     
# * Simple code
# * Readable by any programming language (*see Cons)
# * Minimum on-disk size without compression
# * Fast reading and memory mappable
# * Supports N-dimensional data
# * Subsettable
#
# </details>
#     
# <details>
#     <summary>Cons</summary>
#     
# * Not human readable
# * No shape, data type, or byte order information stored
# * Platform dependent (not shareable)
#
# Note from numpy docs:
#     
# > Do not rely on the combination of tofile and fromfile for data storage, as the binary files generated are are not platform independent. In particular, no byte-order or data-type information is saved. Data can be stored in the platform independent .npy format using save and load instead.
#     
# </details>
#
# Storage layout (64-bit float):
#
# <table>
#     <tr>
#         <th>Byte Offset</th>
#         <th>Value</th>
#     </tr>
#     <tr><td>0</td><td>1568667832.827468</td></tr>
#     <tr><td>8</td><td>1568667832.827796</td></tr>
#     <tr><td>16</td><td>1568667832.827835</td></tr>
#     </table>
# -

# ### Bonus - Numpy's .npy format
#
# As mention in the cons above, a flat binary file *only* has the data and no
# other information. This means we have to keep track of this information
# ourselves. To help with this numpy provides a `.npy` format which stores this
# information inside the file alongside the binary data. Here's a quick example
# to create a `.npy` file using
# [`np.save`](https://docs.scipy.org/doc/numpy/reference/generated/numpy.save.html#numpy.save).

np.save('last_time.npy', np.array([1., 2., 3.]))

# When we are ready to read the data back we can use
# [`np.load`](https://docs.scipy.org/doc/numpy/reference/generated/numpy.load.html#numpy.load).
# This method gives us the option to open the data as a memory map, giving us
# the space and speed advantages of a flat binary while avoiding the format
# metadata issues. Keep in mind that this format is only readable by numpy and
# would require an additional library in any other language.

np.load('last_time.npy', mmap_mode='r')

# + [markdown] toc-hr-collapsed=true
# ## Comma-Separated Values (CSV)
#
# So far we've dealt with a single stream (a.k.a. field or variable) of data,
# but what if we need to store more? When it comes to multiple 1-dimensional
# variables one of the more common solutions is a Comma-Separate Values (CSV)
# file. We could use numpy again, but instead we'll use the `pandas` library
# for its more powerful handling of tabular data like we would store in a CSV.
#
# We'll start by loading some example data used by the seaborn python package.
# Their example data is stored as a
# [CSV file on GitHub](https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv).
# We use the pandas
# [`read_csv`](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.read_csv.html)
# function. This function has a lot of options, but we'll use the defaults for now.

# +
import pandas as pd
import numpy as np

seaborn_iris_url = 'https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv'
data = pd.read_csv(seaborn_iris_url)
print(data.shape)
data.head()
# -

# If we were making our CSV file from a pandas dataframe we can use `to_csv`:

my_df = pd.DataFrame(np.random.random((150, 5)), columns=['A', 'B', 'C', 'D', 'E'])
my_df.head()

my_df.to_csv('randoms.csv', index=False)

# Pandas also provides options for memory mapping the text file to reduce I/O
# overhead.

my_df = pd.read_csv('randoms.csv', memory_map=True)
my_df.head()

# ### Performance Tip: Chunking and Iterating
#
# So far we've been loading all data at once or depending on memory mapping to
# reduce the amount of data that was loaded at any one time. Another possible
# improvement can come from loading chunks of data at a time. This is similar
# to what we did with the original plain text file iterating over the lines of
# the file one at a time.

# By chunking and iterating over the data we can process files that would not
# fit in memory otherwise. For more info on chunking, see the
# [pandas documentation](https://pandas.pydata.org/pandas-docs/stable/user_guide/io.html#iterating-through-files-chunk-by-chunk).

reader = pd.read_csv('randoms.csv', iterator=True, chunksize=4)
for idx, chunk_df in enumerate(reader):
    print(chunk_df)
    if idx >= 2: break

# This reduces the amount of memory being used at any one time while we work with a single chunk.
#
# We can change how big of a chunk we get by calling `get_chunk` with the number of
# rows to put in the DataFrame returned. Note how we are continuing our reading from the above cells since the reader is an iterator.

reader.get_chunk(6)

# ### CSV - Pros/Cons
#
# <details>
#     <summary>Pros</summary>
#
# * Human readable
# * Can be read in Microsoft Excel (non-programmer collaboration)
# * Lazy/iterable loading possible
# * Row-based operations are relatively fast
#
# </details>
#     
# <details>
#     <summary>Cons</summary>
#
# * Slow to read/write
# * Wasted disk space
# * Require reading all columns to get value for a single column
# * Column-based operations are slow (see below)
#
# </details>
#
# An unfortunate storage layout for a 3 column CSV (8-bit ASCII characters):
#
# <table>
#     <tr>
#         <th>Byte Offset</th>
#         <th>Value</th>
#         <th>Column</th>
#     </tr>
#     <tr><td>0</td><td>0.4433,</td><td>1</td></tr>
#     <tr><td>7</td><td>0.623,</td><td>2</td></tr>
#     <tr><td>13</td><td>setosa\n</td><td>3</td></tr>
#     <tr><td>20</td><td>0.8866,</td><td>1</td></tr>
#     <tr><td>27</td><td>0.31,</td><td>2</td></tr>
#     <tr><td>32</td><td>virginica\n</td><td>3</td></tr>
#     <tr><td>42</td><td>0.6644,</td><td>1</td></tr>
#     </table>
#
# The above table shows how a CSV file may be stored on disk. If we don't force all floating point numbers to have the same number of digits or string fields are not all the same size then we can't be sure how to quickly get all values for a single column (by calculating offsets) without reading every value for every column. This makes doing column-based calculations, like the average of an entire field, very slow and wasteful. If we are doing a calculation that requires all values in a single row, then this structure is fairly convenient; we can parse one row at a time efficiently.
#
# ### Further Reading
#
# * [Dask DataFrames](https://docs.dask.org/en/latest/dataframe.html) for distributed and lazy pandas dataframes
#
# ## Review 1
#
# * Store as text: Human-readable but slow
# * Store as binary: Easily indexable and fast to read/write
# * Memory maps: Better I/O in most cases
# * Chunking: Good when working on one chunk at a time
# * Flat binary: Simple, quick solution, multiple dimensions, no structure

# + [markdown] toc-hr-collapsed=true
# ## Parquet
#
# Parquet is another tabular data format and can be thought of as the high
# performance binary version of the CSV file.
# Analytics workflows typically don't need to read every column from a tabular
# format and storing data in something like a CSV file can be very wasteful.
# The first difference in what Parquet brings to the table (pun intended) is storing data by
# column (image right) instead of by row (image left).
#
# <img src="https://www.kdnuggets.com/wp-content/uploads/dremio-table-1.jpg">
# <center><sub>Credit: https://www.kdnuggets.com/2017/02/apache-arrow-parquet-columnar-data.html</sub></center>
# <br>
#
# If we keep the columns together it is easier to access one column more easily
# and more quickly than data stored row by row.
#
# ## Performance Tip: Spatial and Temporal Locality
#
# Modern CPUs have multiple levels of caching. The closer a cache level is to the CPU (the computation) the smaller it is and less it can store at any one time. These closer cache levels are also much faster to get data from. Conversely, caches that are further from the CPU are larger and slower to access. The diagram shows the various caching levels common in modern computers where L1 caches are the smallest and fastest, then L2, L3, main RAM memory, and finally the hard disk; the largest and slowest storage on the local machine. 
#
# <img width="350px" src="https://softwarerajivprab.files.wordpress.com/2019/07/cache.png" alt="CPU L1 L2 L3 cache diagram">
# <center><sub>Credit: https://software.rajivprab.com/2018/04/29/myths-programmers-believe-about-cpu-caches/</sub></center><br>
#
# If we want the best performance out of the file format we are using then we want to do as many operations as possible with what is in the L1 cache before replacing it with new data. Otherwise, we could suffer from reloading the same data from slower caches. **Temporal locality** is the idea that if we access a particular memory location, we are very likely to access that same location again in the near future. **Spatial locality** is the idea that if we access a particular memory location, an operation in the near future is probably going to involve the data right next to it. Modern computers will assume this to be true and will predictively cache things to get the best performance. That means our best option is to use the memory the way the computer thinks we are going to use it.
#
# Further Reading: [Wikipedia](https://en.wikipedia.org/wiki/Locality_of_reference)
# -

# ### Performance Tip: Single Instruction, Multiple Data (SIMD)
#
# In addition to the multiple cache levels, modern CPUs can also operate on multiple pieces of data at the same time with a single CPU instruction. These vectorized instructions are referred to as SIMD. The CPU is told to do one operation on many values (ex. add 5 to every value) and can perform it on multiple values at a time, in parallel. 
#
# Even though SIMD instructions are low-level, NumPy does a lot of the work for us by telling the CPU to use SIMD instructions when possible. However, this usually depends on taking advantage of locality (see above) so that the CPU has all the values it is going to operate on.
#
# Further Reading: [Wikipedia](https://en.wikipedia.org/wiki/SIMD)
#
# <br>
#
# Parquet's design for how data is stored on disk and operated on in-memory tries to take advantage of these concepts. Let's run through a basic parquet example to see how we can read and write a parquet file and how we can control some aspects of these complex topics. We'll start by generating some random data to play with. We'll re-assign the `'B'` column so not all of our data is random (to show the results of compression better).

# +
import numpy as np
import pandas as pd

data = pd.DataFrame(np.random.random((100_000, 5)), columns=['A', 'B', 'C', 'D', 'E'])
data['B'] = np.repeat(np.arange(100), 1000)
print(data.shape)
data.head()
# -

# Let's write this data to a local parquet file using the `fastparquet` library (there are others):

import fastparquet as fp
fp.write('randoms_default.parq', data)

# !ls -lh randoms_default.parq

parq_file = fp.ParquetFile('randoms_default.parq')
parq_file.to_pandas().head()

# By default fastparquet makes a single file with no compression and only stores entire columns together (no row-groups). Although these defaults may not provide the best performance for a particular use case, the format itself provides us some nice conviences. For example, since the data is stored by column we can quickly and efficiently load a limited set of the columns:

data2 = parq_file.to_pandas(['C', 'D'])
data2.head()

# Let's take advantage of some of the other features of parquet by writing a new file from our original iris data:

fp.write('randoms_gzip.parq', data, compression='GZIP')

# !ls -lh randoms*.parq

# We can see that using compression can save us a lot of space in our simple data file.
#
# So far we've been storing entire columns contiguously, one large block of data. It is also possible to define "row groups" to better control how our data is organized on disk. Row groups are grouping a certain number of rows together, but still by column. Let's look at what a parquet file with row groups might look like:
#
# <img width="400px" src="https://www.dremio.com/img/blog/parquet_block1.png" alt="Parquet basic row group diagram">
# <center><sub>Credit: https://www.dremio.com/tuning-parquet/</sub></center>
#
# By defining row groups (the orange groups in the above image), we allow ourselves better performance when working on row-based operations while still getting the advantages of storing data by column. We can define our row group sizes using `row_group_offsets` keyword argument in `fastparquet`:

fp.write('randoms_row_group.parq', data, compression='GZIP', row_group_offsets=1000)

# !ls -lh randoms*.parq

# Pandas has its own methods for reading parquet files (using the PyArrow library underneath):

import pandas as pd
# %timeit pd.read_parquet('randoms_row_group.parq').head()

# And Dask can also load parquet files (using fastparquet underneath). By using Dask we can load the data lazily and in parallel with multiple worker threads or processes:

import dask.dataframe as dd
# %timeit dd.read_parquet('randoms_row_group.parq').head()

# ### Performance Tip: Block sizes
#
# Each storage device (cache, RAM, disk) will typically have a default size for the single "chunks" or "units" of data that they will operate on or store in one contiguous location or provide to another storage element. This is typically referred to as the "block size" and can have different names depending on what is being talked about and by whom. To get the best performance we want to try to operate on and store our data in a way that is compatible with the block size of our storage device. If we want to work with 5KB chunks, but our storage device operates on 4KB blocks, we will need to load two 4KB blocks for every 5KB chunk we want to work with. By aligning your workflow with the block size of your storage you can avoid unnecessary I/O operations and delays. However, this isn't always something you have control over or knowlege of.
#
# For example, with parquet we want to store our row groups with sizes that take advantage of the block size of the storage while getting the most out of the parquet format. See [Tuning Parquet](https://www.dremio.com/tuning-parquet/) for more details.
#
# <img src="https://www.dremio.com/img/blog/parquet_block2.png" alt="Parquet row group block sizes">
# <center><sub>Credit: https://www.dremio.com/tuning-parquet/</sub></center>
#
# <br>
#
# The last important parquet feature we should talk about is the ability to store a single parquet dataset as a directory of files instead of one single file. This structure can allow better performance when storing data on cloud file systems or other high performance storage solutions (ex. Apache Hive). We can control this in `fastparquet` by using the `file_scheme` keyword argument:

fp.write('randoms_row_group_hive.parq', data, compression='GZIP', row_group_offsets=1000, file_scheme='hive')
# !ls -lh randoms_row_group_hive.parq/ | head

# ### Parquet - Pros/Cons
#
# <details>
#     <summary>Pros</summary>
#
# * Column-based selection very fast
# * Multiple compression algorithms available
# * Row groups allow for more control over storage
# * Cloud storage friendly
# * Can be stored as single file or directory of files
#
# </details>
#     
# <details>
#     <summary>Cons</summary>
#
# * Complex (lots of options and choices)
# * Limited to tabular data (mostly)
#
# </details>
# <br>
# Further Reading:
#
# * [Tuning Parquet](https://www.dremio.com/tuning-parquet/)
# * [fastparquet Documentation](https://fastparquet.readthedocs.io/en/latest/index.html)
# * [fastparquet Usage Notes](https://fastparquet.readthedocs.io/en/latest/details.html)
# * [Apache Arrow](https://arrow.apache.org/)
# * [Pandas - Scaling to large datasets](https://dev.pandas.io/docs/user_guide/scale.html)

# + [markdown] toc-hr-collapsed=true
# ## HDF5
#
# So far we've been dealing with tabular data where our entire dataset can be represented by a 2D array (rows, columns). Sometimes your data may require a more complex hierarchical structure. One good solution for these more complex structures is the Hierarchical Data Format (HDF); specifically the HDF5 file format. HDF5 is a self-describing format which means that in addition to the raw data you can store all related metadata (when it was recorded, where it was recorded, etc).
#
# Pandas and other python libraries do provide utility functions for writing and reading HDF5 files (.h5 extension), but we will be using the `h5py` library here to step through the structure of the files.
# -

# The HDF5 data model is made up of a few key components or ideas:
#
# * File: The single object stored on disk
# * Datasets: Multidimensional data array with attributes and other metadata
# * Groups: A collection of objects (datasets or groups)
# * Attributes: Key-value store of metadata on groups or datasets
#
# <img width="700px" src="https://www.neonscience.org/sites/default/files/images/HDF5/hdf5_structure3.jpg" alt="Complex HDF5 layout">
# <center><sub>Credit: https://www.neonscience.org/about-hdf5</sub></center>

import h5py
h = h5py.File('test.h5', 'w')
h

# HDF5 files have an implicit "root" group where groups or datasets can be added or attributes can be set.

h['/']

h.attrs

dict(h.attrs)

from datetime import datetime
h.attrs['start_time'] = datetime.utcnow().isoformat(" ")
h.attrs['source'] = "Dave's Notebook"
h.attrs['revision'] = 1
h.attrs['inputs'] = ['a.txt', 'b.txt', 'c.text']
dict(h.attrs)

# We can store most builtin Python data types in our attributes to store various pieces of metadata about our file. These are typically referred to as "global" attributes.
#
# We can also add data to our file as Datasets. A Dataset can be created with the data specified:

ds1 = h.create_dataset('dataset1', data=np.random.random((10, 100, 100)))
ds1

ds1.attrs['valid_range'] = [0, 1]
ds1.attrs['missing_value'] = np.nan
dict(ds1.attrs)

# A dataset can also be created and have data filled in later. There are a lot of options available when creating a dataset:

# +
# h.create_dataset?
# -

ds2 = h.create_dataset('dataset2', shape=(5, 100), dtype=np.uint8,
                       compression='gzip', chunks=(2, 100), fillvalue=255)

ds2[2:4] = np.arange(100)
ds2[0] = np.arange(100, 200)
ds2[-1] = np.arange(150, 250)

# Whether we are reading or writing we can use slicing syntax to access parts of the data array (only loading/writing what's needed) or the entire thing.
#
# If we look at the data we wrote to the `"dataset2"` dataset, notice how the second row of data uses the `fillvalue` we set above.

ds2[:]

# We can create groups to describe more complex structures of our data (groups with groups with datasets, etc):

g1 = h.create_group('geolocation')
g1.attrs['my_key'] = 'my_val'
# sub-group of g1
g12 = g1.create_group('geocentric')
# another group off of the root
g2 = h.create_group('observed_data')
# dataset as part of a group
obs_a = g2.create_dataset('observation_a', data=np.arange(10.))

h.close()

# The HDF5 C library comes with the `h5dump` command line tool for investigating an HDF5 file in different ways. Here we use the `-n` flag to list the contents of the file without their attributes.

# !h5dump -n test.h5

# ### HDF5 - Pros/Cons
#
# <details>
#     <summary>Pros</summary>
#
# * Complex structures
# * Per-dataset compression
# * Multidimensional arrays
# * Metadata
#
# </details>
#     
# <details>
#     <summary>Cons</summary>
#
# * Complex (lots of options and choices)
# * Limited parallel/thread-safe operations
# * Not cloud storage friendly
#
# </details>
# <br>
#
# HDF5 supports a lot of complex features including references HDF5 objects from external files or using references to point to other locations in the same file. These features are left as an exercise for the reader to explore.
#
# Further Reading:
#
# * [HDF5 Data Model](https://support.hdfgroup.org/HDF5/doc1.6/UG/03_Model.html)
# * [h5py Documentation](http://docs.h5py.org/en/stable/index.html)
# * [HDF5 Reference Objects](http://docs.h5py.org/en/stable/refs.html)

# + [markdown] toc-hr-collapsed=true
# ## NetCDF4
#
# NetCDF4 is another format that supports storing multidimensional arrays like HDF5, but provides a simpler/flatter structure to ease use of the files. Under the hood NetCDF4 is actually built on top of the HDF5 format. As such, almost all of the features provided by HDF5 are also available in NetCDF4 (chunking, per-dataset compression, etc). NetCDF4 is a very popular and common format for storing scientific data. When mixed with metadata standards like those defined by the [Climate and Forecast (CF) group](http://cfconventions.org/) files become much easier to distribute and use between different applications.
#
# The NetCDF4 C library not only allows you to read and write NetCDF4 files, but can act as a generic reading library for formats that fulfill the NetCDF4 Data Model. This means that the NetCDF4 C library can read NetCDF4, NetCDF3, and HDF4 files. In future versions it will also be able to read the Zarr format (see next section). The NetCDF4 data model is very similar to HDF5, but with a few key differences. The first major difference is that HDF5 Datasets are called "Variables". The second major addition is the more formal definition of labeled Dimensions.
#
# <img src="https://www.unidata.ucar.edu/software/netcdf/docs/nc4-model.png" alt="NetCDF4 Data Model">
# <center><sub>Credit: https://www.unidata.ucar.edu/software/netcdf/docs/netcdf_data_model.html</sub></center>
#
# We'll start exploring the NetCDF4 data model by creating a simple NetCDF4 file using the `netcdf4-python` library.

# +
import numpy as np
import netCDF4

nc = netCDF4.Dataset('test.nc', 'w')
nc
# -

# Now we'll add some data, but before we do that we need to define the dimensions for our variable.

y_dim = nc.createDimension('y')
x_dim = nc.createDimension('x', 100)

var1 = nc.createVariable('var1', np.uint16, dimensions=('y', 'x'))
var1[:] = np.random.randint(1, 65535, size=(200, 100))

# We can also define variables with the same name as a dimension. These are known as "coordinate" variables.

x_var = nc.createVariable('x', np.float32, dimensions=('x',))
x_var[:] = np.linspace(1000.0, 1500.0, 100)

# Just like HDF5 we can assign metadata attributes to our variables or to the global attributes of the file:

nc.created = 'now'
var1.valid_range = [0, 65535]
var1.standard_name = 'toa_brightness_temperature'

# When we defined the `y` dimension we didn't specify a size. In NetCDF4 this means the dimension is 'unlimited' and can grow as needed. We can check the size of a dimension by looking at `len(dim)`. Let's add more data to the `var1` variable and see how the dimensions change.

print(len(y_dim))
var1[205] = np.arange(100)
print(len(y_dim))

nc

nc['var1']

nc.close()

# One of the easiest ways to work with NetCDF files in python is through the Xarray library by using it's `open_dataset` and `to_netcdf` functions. Xarray can take advantage of the dask library for lazy loading our data and computing things in parallel. By specifying the `chunks` keyword argument we can tell Xarray to use dask.
#
# <img width="700px" src="http://xarray.pydata.org/en/stable/_images/dataset-diagram.png">
# <center><sub>Credit: http://xarray.pydata.org/en/stable/data-structures.html</sub></center>

import xarray as xr
xnc = xr.open_dataset('test.nc', chunks={'y': 50, 'x': 100})
xnc

print(xnc.dims)
print(xnc.attrs)
print(xnc['var1'].dims)
print(xnc['var1'].coords)

xnc['var1']

# Xarray helps us when slicing data by applying the same slices to our coordinates.

xnc['var1'][:50, 50:75]

xnc.close()

# Similar to `h5dump` the NetCDF4 library comes with a `ncdump` utility:

# !ncdump -h test.nc

# ### NetCDF4 - Pros/Cons
#
# <details>
#     <summary>Pros</summary>
#
# * Simpler HDF5 structures
# * Per-variable compression and chunking
# * Multidimensional arrays
# * Named dimensions and coordinate variables
# * Data model compatible with Xarray (limited support for groups)
# * Metadata
#
# </details>
#     
# <details>
#     <summary>Cons</summary>
#
# * Limited parallel/thread-safe operations
# * Not cloud storage friendly
#
# </details>
# <br>
#
# Further Reading:
#
# * [xarray.open_mfdatasets](http://xarray.pydata.org/en/stable/generated/xarray.open_mfdataset.html) - Load multiple files as one Dataset
# * [OpenDAP](https://en.wikipedia.org/wiki/OPeNDAP) - Read remote NetCDF files without loading the entire file
#

# + [markdown] toc-hr-collapsed=true
# ## Zarr
#
# One of the major downsides to HDF5 and NetCDF4 is that they do not allow for parallel processing or do not work well with cloud storage solutions. To address these limitations and others the Zarr format was developed. Zarr is developed heavily by the scientific Python community including the developers of dask and xarray. In short, Zarr is like a NetCDF4 file if it was stored as a directory of files instead of one single file.
#
# Since Zarr uses the same data model as NetCDF/Xarray, we can skip directly to what the format looks like and how it is used. We'll start by using the NetCDF file we made before.
# -

import xarray as xr
xnc = xr.open_dataset('test.nc', chunks={'y': 50, 'x': 100})
xnc

xnc.to_zarr('test.zarr')

# !tree -a test.zarr/

# Above we can see that `to_zarr` defaulted to using the chunking of our dask arrays when writing to zarr storage. Each chunk shows up as its own file with metadata (both user metadata and array information) as separate files.

# !cat test.zarr/var1/.zarray

# !cat test.zarr/var1/.zattrs

# To read zarr files we can either use the `zarr` python library or use Xarray again:

zds = xr.open_zarr('test.zarr')
zds

# ### Zarr - Pros/Cons
#
# <details>
#     <summary>Pros</summary>
#
# * Designed for efficient storage and use
# * Per-chunk compression
# * Multiple storage options (zip file, database, etc)
# * Multidimensional arrays
# * Named dimensions and coordinate variables
# * Data model compatible with Xarray (limited support for groups)
# * Metadata
# * Future support in NetCDF4 C library
#
# </details>
#     
# <details>
#     <summary>Cons</summary>
#
# * Relatively new
# * Library not available in every popular programming language
#
# </details>
# <br>
#
# Further Reading:
#
# * [zarr Documentation](https://zarr.readthedocs.io/en/stable/)
# * [Builtin zarr Storages](https://zarr.readthedocs.io/en/stable/api/storage.html)

# ## Conclusion
#
# * Text is slow, but might be needed if humans need to read it
# * Text is the easiest format to share with other languages/programs (excel)
# * Compression is good for space saving but not read/write speeds
# * Chunking data is good
# * Parquet and Zarr are cloud friendly
# * File formats are complex
# * There are so many file formats
#
# This Notebook:
# https://github.com/djhoese/data-file-formats
#
# ## Honarable Mentions
#
# These files are no less useful than the above formats, but we have to draw the line somewhere.
#
# * Cloud Optimized GeoTIFFs (COG)
# * Apache Avro (row-based)
# * Apache Arrow (in-memory)
# * ORC
# * JSON
# * XML

# !rm -r *.zarr *.parq *.csv *.dat *.txt *.nc *.h5 *.npy



StarcoderdataJupyterScriptsDedupFiltered