GenTS 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Cameron Cummins
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,179 @@
1
+ Metadata-Version: 2.1
2
+ Name: GenTS
3
+ Version: 0.1.0
4
+ Summary: A useful tool for post-processing CESM2 history files into the timeseries format.
5
+ Author-email: Cameron Cummins <cameron.cummins@utexas.edu>
6
+ Project-URL: Homepage, https://github.com/AgentOxygen/GenTS
7
+ Project-URL: Issues, https://github.com/AgentOxygen/GenTS
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.10.13
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+
15
+ # Time Series Generation
16
+
17
+ >Cameron Cummins<br>
18
+ Computational Engineer<br>
19
+ Contact: cameron.cummins@utexas.edu<br>
20
+ Webpage: [https://www.jsg.utexas.edu/student/cameron_cummins](https://www.jsg.utexas.edu/student/cameron_cummins)<br>
21
+ Affiliation: Persad Aero-Climate Lab, The University of Texas at Austin
22
+
23
+ >Adam Phillips<br>
24
+ Mentor and Advisor<br>
25
+ Contact: asphilli@ucar.edu<br>
26
+ Webpage: [https://staff.cgd.ucar.edu/asphilli/](https://staff.cgd.ucar.edu/asphilli/)<br>
27
+ Affiliation: Climate and Global Dynamics Lab, National Center for Atmospheric Research
28
+
29
+ ## *This Project is still in Development*
30
+ It works pretty well and can likely handle most cases, but some features aren't finished yet. Stay tuned for more!
31
+
32
+ ## Converting from History Files to Time Series Files
33
+ Original model output produced by CESM2 is stored by timestep, with multiple geophysical variables stored in a single netCDF3 file together. These files are referred to as **history files** as each file effectively represents a snapshot of many variables at separate moments in time. This is intuitive from the model's perspective because variables are computed together, after solving many differential equations, at each step in the time series. However, this format is cumbersome for scientists because analysis is often performed over a large number of timesteps for only a few variables. A more practical format is to store all the timesteps for single variables in separate files. Datasets stored in this manner are known as **time series files**.
34
+
35
+ ## Capabilities and Current Limitations
36
+ - [x] Functional core to convert history file to time series file in parallel with Dask
37
+ - [x] Automatic path parsing
38
+ - [x] Compatible with all model components of CESM
39
+ - [x] Compatible with large ensembles
40
+ - [x] Compatible with non-rectilinear grids
41
+ - [x] Automatic path resolving and group detection (detects different experiments, timesteps, and other sub-directories)
42
+ - [x] Customizable output directory structure that automatically reflects input directory structure
43
+ - [x] Adjustable time chunk size
44
+ - [x] Adjustable time range selection
45
+ - [x] Customizable variable selection
46
+ - [x] Adjustable file compression
47
+ - [x] Resumable process (such as exceeding wall time or encountering an error)
48
+ - [x] Documented API
49
+ - [x] Fully written in Python (no NCO, CDO, or other subprocess commands)
50
+ - [ ] (WIP) Verification tools for ensuring file integrity
51
+ - [ ] (WIP) Command line interface
52
+ - [ ] (WIP) Automatic cluster configuration/recommendation
53
+
54
+ ## Dependencies
55
+ Ensure that you have the following packages installed. If you are using Casper, activate conda environment "npl-2024b".
56
+ - `python >= 3.11.9`
57
+ - `dask >= 2024.7.0`
58
+ - `dask_jobqueue >= 0.8.5`
59
+ - `netCDF4 >= 1.7.1`
60
+ - `numpy >= 1.26.4`
61
+
62
+
63
+ ## How To Use
64
+ The process of converting history files into time series files is almost entirely I/O dependent and benefits heavily from parallelism that increases data throughput. We leverage Dask to parallelize this process: reading in history files and writing out their respective time series datasets across multiple worker processes. These functions can be run without Dask, but it will execute in serial and likely be significantly slower.
65
+
66
+ Start by cloning this repository to a directory of your choosing. This can be done by navigating to a directory and running the following command, assuming you have `git` installed:
67
+ ````
68
+ git clone https://github.com/AgentOxygen/timeseries_generation.git
69
+ ````
70
+ You can then either import `timeseries_generation.py` as a module in an interactive Jupyter Notebook (recommended) or in a custom Python script. ~~You may also execute it as a command from the terminal~~(not implemented yet).
71
+
72
+ For those that learn faster by doing than reading, there is a template notebook available called `template_notebook.ipynb`.
73
+
74
+ ### Jupyter Notebook
75
+ To use this package in an interactive Jupyter notebook, create a notebook in the repository directory and start a Dask cluster using either a LocalCluster or your HPC's job-queue. Be sure to activate an environment with the correct dependencies (this can be done either at the start of the Jupyter Server or by selecting the appropriate kernel in the bottom left bar). Here is an example of a possible cluster on Casper using the NPL 2024b conda environment and the PBS job scheduler:
76
+ ````
77
+ from timeseries_generation import ModelOutputDatabase
78
+ from dask_jobqueue import PBSCluster
79
+ from dask.distributed import Client
80
+
81
+
82
+ cluster = PBSCluster(
83
+ cores=20,
84
+ memory='40GB',
85
+ processes=20,
86
+ queue='casper',
87
+ resource_spec='select=1:ncpus=20:mem=40GB',
88
+ account='PROJECT CODE GOES HERE',
89
+ walltime='02:00:00',
90
+ local_directory="/local_scratch/"
91
+ )
92
+
93
+ cluster.scale(200)
94
+ client = Client(cluster)
95
+ ````
96
+ Here, we spin up 200 workers, each on one core with 2 GB of memory (so 200 total cores) with maximum wall clocks of 2 hours (note that this is done in PBS batches of 20 cores per machine). The `local_directory` parameter allows each worker to use their respective node's NVMe storage for scratch work if needed. Note that these cores may be spread across multiple machines. This process should not use more than two gigabytes of memory and does not utilize multi-threading (one process per core is optimal). How the other properties of the cluster should be configured (number of cores, number of different machines, and wall time) is difficult to determine, ~~but benchmarks are available below to get a good idea~~ (WIP). If generation process is interrupted, it can be resumed without re-generating existing time series data. Note that the ``Client`` object is important for connecting the Jupyter instance to the Dask cluster. You can monitor the Dask dashboard using the URL indicated by the `client` object (run just `client` in a cell block to get the Jupyter GUI). Once created, you can then initialize a ``ModelOutputDatabase`` class object with the appropriate parameters. Here is an example of common parameters you may use:
97
+ ````
98
+ model_database = ModelOutputDatabase(
99
+ hf_head_dir="PATH TO HEAD DIRECTORY FOR HISTORY FILES (MULTIPLE MODEL RUNS, ONE RUN, OR INDIVIDUAL COMPONENTS)",
100
+ ts_head_dir="PATH TO HEAD DIRECTORY FOR REFLECTIVE DIRECTORY STRUCTURE AND TIME SERIES FILES",
101
+ dir_name_swaps={
102
+ "hist": "proc/tseries"
103
+ },
104
+ file_exclusions=[".once.nc", ".pop.hv.nc", ".initial_hist."]
105
+ )
106
+ ````
107
+ The ``ModelOutputDatabase`` object centralizes the API and acts as the starting point for all history-to-time-series operations. Creating the database will automatically search the input directories under `hf_head_dir` and group the history files by ensemble member, model component, timestep, and any other differences between subdirectories and naming patterns. The structures and names are arbitrary, so you can input a directory containing an ensemble of model archives or a single model component from a single run (or a random directory with history files in it, it should be able to handle it). It does not trigger any computation. `ts_head_dir` tells the class where to create a directory structure that matches the one under `hf_head_dir`. Only directories with `.nc` files will be reflected and optionally any other file names that contain the keywords specified by `file_exclusions` will be excluded. `dir_name_swaps` takes a Python dictionary with keys equal to directory names found under `hf_head_dir` that should be renamed. In the example above, all directories named `hist/` are renamed to two new directories `proc/tseries` to remain consistent with current time series generation conventions. There are many other parameters that can be specified to filter out which history files/variables are converted and how the time series files are formatted (see the section below titled *ModelOutputDatabase Parameters*). To trigger the time series generation process, call ``.run()``:
108
+ ````
109
+ model_database.run()
110
+ client.shutdown()
111
+ ````
112
+ It is good practice to shutdown the cluster after the process is complete. If metadata of the history files needs to be inspected before generating time series (to verify which history files are being detected), ``.build()`` can be used to obtain metadata for all history files. This process is parallelized and is lightweight, typically taking less than a minute to run on a large-enough cluster. This function is automatically called by ``.run()`` before generating the time series datasets.
113
+
114
+ You can monitor the Dask dashboard using the URL provided by the `client` object mentioned previously. This is a good way to track progress and check for errors.
115
+
116
+ ### What to Expect
117
+
118
+ When you create ``ModelOutputDatabase``, it recursively searches the directory tree at `hf_head_dir`. If this tree is large, it may take some time. It is run in serial, so it won't show any information in the Dask dashboard, but you should see some print output. Calling `.run()` will first check to see if the database has been built, if not, it will automatically call `.build()` (which you can optionally call before `.run()` to inspect metadata). The `.build()` function aggregates the metadata for *all* of the history files under `hf_head_dir`. This process is parallelized using the Dask cluster and doesn't read much data, but opens a lot of files. This can take anywhere from less than a minute to 10 minutes depending on how the filesystem is organized. Once the metadata is aggregated in parallel, it is then processed in serial to for the arguments for time series generation. This step shouldn't take more than a few minutes and all progress will be communicated through print statements. After calculating the arguments, the `generateTimeSeries` is parallelized across the cluster with progress updated in the Dask dashboard. This is the final function call that actually preforms the time series generation by concatenating and merging variable data from the history files and writing them to disk. This process can take minutes to hours depending on the size of the history file output, the number of variables exported, the configuration of the Dask cluster, and traffic on the file system.
119
+
120
+ Errors may occur due to hiccups in file-reads, but they tend to be rare. These errors are not resolved in real-time, but reported to the user so that a smaller second-pass can be preformed to fix any incomplete or missing time series files. You might also notice that some tasks take longer than others or that the rate of progress sometimes slows down. This is likely due to different variable shapes/sizes and variability in disk I/O.
121
+
122
+ You may see a warning:
123
+ ````
124
+ UserWarning: Sending large graph of size ....
125
+ ````
126
+ It's a performance warning from Dask and doesn't mean anything is broken or that you did anything wrong. It might be the cause of some latency which I am hoping to eliminate in a future update, but just ignore it for now.
127
+
128
+ By default, existing files are not overwritten, but this can be configured as shown in *ModelOutputDatabase Parameters*. Immidiately before the program releases the handle for a time series file, an attribute is appended called `timeseries_process` as explained in *Additional Attributes*. This attribute is used to verify that a file was properly created (being the last thing added, it indicates the process completed successfully). If it does not exist in the file with the correct value, the program will assume it is broken and needs to be replaced. This is to protect against unexpected interruptions such as system crashes, I/O errors, or wall-clock limits.
129
+
130
+ ## ModelOutputDatabase Parameters
131
+
132
+ The ``ModelOutputDatabase`` constructor has multiple optional parameters that can be customized to fit your use-case:
133
+ ````
134
+ class ModelOutputDatabase:
135
+ def __init__(self,
136
+ hf_head_dir: str,
137
+ ts_head_dir: str,
138
+ dir_name_swaps: dict = {},
139
+ file_exclusions: list = [],
140
+ dir_exclusions: list = ["rest", "logs"],
141
+ include_variables: list = None,
142
+ exclude_variables: list = None,
143
+ year_start: int = None,
144
+ year_end: int = None,
145
+ compression_level: int = None,
146
+ variable_compression_levels: dict = None) -> None:
147
+ ````
148
+ - `hf_head_dir` : Required string/Path object, path to head directory to structure with subdirectories containing history files.
149
+ - `ts_head_dir` : Required string/Path object, path to head directory where structure reflecting `hf_head_dir` will be created and time series files will be written to.
150
+
151
+ Note that for a Python class, the `self` parameter is an internal parameter skipped when creating the class object (`hf_head_dir` is the first parameter you can pass to `ModelOutputDatabase()`). You are only *required* to provide the first two parameters, `hf_head_dir` and `ts_head_dir`, but many assumptions will be made which you can also deduce from the default values in the constructor:
152
+ 1. All netCDF files under `hf_head_dir`, including all sub-directories except for "rest" and "logs", will be treated as history files.
153
+ 3. An identical directory structure to that of `hf_head_dir` will be created under `ts_head_dir` with the exception of time-step sub-directories (such as "month_1" and "day_1")
154
+ 4. The entire time series for all history files will be concatenated.
155
+ 5. All variables containing the time dimension will be concatenated and have time series files.
156
+ 6. No compression will be used (compression level 0).
157
+
158
+ These defaults can be overwritten to customize how your time series files are generated:
159
+ - `dir_name_swaps` : Optional dictionary, dictionary for swapping out keyword directory names in the structure under `hf_head_dir` (e.g. `{"hist" : "proc/tseries"}`)
160
+ - `file_exclusions` : Optional list/array, file names containing any of the keywords in this list will be excluded from the database.
161
+ - `dir_exclusions` : Optional list, directory names containing any of the keywords in this list will be excluded from the database.
162
+ - `include_variables` : Optional list, variables to include in either creating individual time series files for adding as auxiliary variables.
163
+ - `exclude_variables` : Optional list, variables to exclude from either creating individual time series files for adding as auxiliary variables.
164
+ - `year_start` : Optional int, starting year for time series generation, must be later than first history file timestamp to have an effect.
165
+ - `year_end` : Optional int, ending year for time series generation, must be later than last history file timestamp to have an effect.
166
+ - `compression_level` : Optional int, compression level to pass to netCDF4 engine when generating time series files.
167
+ - `variable_compression_levels` : Optional dictionary, compression levels to apply to specific variables (variable name is key and the compression level is the value).
168
+
169
+ ## Additional Attributes
170
+ When generating a new time series file, two new attributes are created in the global attributes dictionary:
171
+ 1. ``timeseries_software_version`` - Indicates the version of this software used to produce the time series dataset. This is important for identifying if the data is subject to any bugs that may exist in previous iterations of the code.
172
+ 2. ``timeseries_process`` - If the process is interrupted, some files may exist but not contain all the data or possibly be corrupted due to early termination of the file handle. This boolean attribute is created at initialization with a ``False`` value that is only set to ``True`` immediately before the file handle closes (it is the very last operation). When re-building the database after an interruption, this flag is checked to determine whether the file should be skipped or removed and re-computed.
173
+
174
+ ## Benchmarks
175
+ WIP
176
+
177
+ ## Why not use Xarray?
178
+ Xarray is an amazing tool that makes *most* big data processes run significantly faster with a lot less hassle (and it works seemlessly with Dask). Time series generation, however, has zero analytical computations involved and is almost entirely I/O bound. Data is simply read, concatenated, merged, and written. I (Cameron) tried many different approaches with Xarray, but they all had performance issues that aren't a problem in most other situations. For example, reading the metadata of *all* of the history files is quite useful for determining which history files go together and what variables are primary versus auxiliary. Reading metadata using xarray is pretty slow at the moment, but that may be due to the fact that it adds so much more functionality on top of standard netCDF operations (which we don't really need for this problem). **Xarray calls the netcdf4-Python engine under the hood, so we just use the engine directly instead**. There are other features that could potentially make Xarray viable in the future, such as taking advantage of chunking. However, current model output is in the netCDF-3 format, which does not support inherently chunking, and I couldn't figure out how to control which workers recieved specific file-sized chunks using the Xarray API (which is important for synchronization in parallel, otherwise resulting in lots of latency and memory usage).
179
+
@@ -0,0 +1,7 @@
1
+ gents/__init__.py,sha256=moRfDtyoRmf2BTU-HMRQmFJKFCjBoRitdETfkPN4kF0,26
2
+ gents/ts_gen.py,sha256=g0q17W86YetWKtsRs5fQD7zuvoB3j82e4wOVF4MaA-Q,40094
3
+ GenTS-0.1.0.dist-info/LICENSE,sha256=wApUf6Jb9isL6obtiIiUfvBW0Ak5CFn-QdwmH_kO888,1072
4
+ GenTS-0.1.0.dist-info/METADATA,sha256=OEJcK8ZhvWo6CQZ9ZkKTNAh6iBNzjeGWk8wIeiVPJRY,17709
5
+ GenTS-0.1.0.dist-info/WHEEL,sha256=Mdi9PDNwEZptOjTlUcAth7XJDFtKrHYaQMPulZeBCiQ,91
6
+ GenTS-0.1.0.dist-info/top_level.txt,sha256=cknc8W-GnKZvhwglXIf9rXFYeyHbnftsljVD5qjGoYU,6
7
+ GenTS-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (73.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ gents
gents/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from gents.ts_gen import *
gents/ts_gen.py ADDED
@@ -0,0 +1,967 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ genTS.py
4
+
5
+ Automatic system for generating time-series datasets from history files
6
+ efficiently by leveraging the netCDF4 engine and Dask.
7
+
8
+ Developer: Cameron Cummins
9
+ Contact: cameron.cummins@utexas.edu
10
+ Last Header Update: 8/22/24
11
+ """
12
+ from time import time
13
+ import numpy as np
14
+ from pathlib import Path
15
+ from dask import delayed
16
+ import netCDF4
17
+ import cftime
18
+ from os.path import isfile, getsize
19
+ from os import remove
20
+ import dask.distributed
21
+ import dask.bag as db
22
+
23
+
24
+ def generateReflectiveOutputDirectory(input_head: Path,
25
+ output_head: Path,
26
+ parent_dir: Path,
27
+ swaps: dict = {}) -> str:
28
+ r"""Generates similar directory structure under new head directory.
29
+
30
+ This function allows for generating an output directory structure
31
+ that reflects the strucutre of the input directory structure,
32
+ typically containing the history files, specified by `input_head`
33
+ and `parent_dir`. The directory path return can then be created to
34
+ generate a similar structure for the timeseries files.
35
+
36
+ The parent directory is compared against the input head directory to
37
+ identify subdirectories. The subdirectories are then appended to the
38
+ output head directory. Swaps are preformed to replace keyword
39
+ subdirectories with a new directory name or sequence of subdirectories.
40
+
41
+ Parameters
42
+ ----------
43
+ input_head : Path
44
+ Path for head directory of input directory structure, typically
45
+ the head directory for the history files.
46
+ output_head : Path
47
+ Path for head directory of output directory structure, typically
48
+ the head directory for the timeseries files.
49
+ parent_dir : Path
50
+ Path for to subdirectory under `input_head`, typically containing
51
+ the history files (parent directory).
52
+ swaps: dict
53
+ Dictionary containing keyword swaps for the returned directory
54
+ structure. For example, ``hist`` may replace ``proc/tseries/``
55
+ (e.g. {``hist``: ``proc/tseries/``}).
56
+
57
+ Returns
58
+ -------
59
+ output_dir : str
60
+ New directory structure under `output_head` that reflects `parent_dir`
61
+ and contains any keywords swaps specified by `swaps`.
62
+ """
63
+ raw_sub_dir = str(parent_dir).split(str(input_head))[-1]
64
+ raw_sub_dir_parsed = raw_sub_dir.split("/")
65
+ for key in swaps:
66
+ for index in range(len(raw_sub_dir_parsed)):
67
+ if raw_sub_dir_parsed[index] == key:
68
+ raw_sub_dir_parsed[index] = swaps[key]
69
+ break
70
+ output_dir = str(output_head) + "/"
71
+ for dir in raw_sub_dir_parsed:
72
+ output_dir += dir + "/"
73
+ return output_dir
74
+
75
+
76
+ def solveForGroupsLeftToRight(file_names: list,
77
+ delimiter: str = ".") -> list:
78
+ r"""Compares file name substrings to identify groups.
79
+
80
+ Each string in `file_names` is converted to a string and parsed using
81
+ the delimiter specified by `delimiter`. Each sequence of resutling
82
+ substrings is compared from left to right, until a difference is
83
+ identified. Paths are then grouped based on these differences.
84
+
85
+ The ``nc`` substring is excluded from the comparison
86
+ of substrings since all history files will contain this substring.
87
+
88
+ Each groups unique substring is returned, but the list of history file
89
+ names for each group is not returned because some groups may be inclusive
90
+ of other groups, resulting in overlap between history files. This is solved
91
+ for in `generateTimeSeriesGroupPaths`.
92
+
93
+ Therefore, this function should be called for each unique output directory
94
+ to group history files that may be different by timestep or some other
95
+ unique substring in the file names.
96
+
97
+ Parameters
98
+ ----------
99
+ file_names : list(str)
100
+ List of string-like objects containing the names of history files.
101
+ delimiter : str
102
+ String to parse each file name by to produce substrings for
103
+ comparison.
104
+
105
+ Returns
106
+ -------
107
+ groups : list(str)
108
+ List of unique substrings identified in file names.
109
+ """
110
+ varying_parsed_names = [str(name).split(delimiter) for name in file_names]
111
+
112
+ lengths = {}
113
+ for parsed in varying_parsed_names:
114
+ if len(parsed) in lengths:
115
+ lengths[len(parsed)].append(parsed)
116
+ else:
117
+ lengths[len(parsed)] = [parsed]
118
+
119
+ groups = []
120
+ for length_index in lengths:
121
+ parsed_names = np.array(lengths[length_index])
122
+ unique_strs = []
123
+ for i in range(parsed_names.shape[1]):
124
+ unique_strs.append(np.unique(parsed_names[:, i]))
125
+ common_str = ""
126
+ for index in range(len(unique_strs)):
127
+ if len(unique_strs[index]) == 1:
128
+ if unique_strs[index][0] != "nc":
129
+ common_str += unique_strs[index][0] + delimiter
130
+ else:
131
+ break
132
+ if len(unique_strs[index]) != len(parsed_names):
133
+ for group in unique_strs[index]:
134
+ groups.append(common_str + group + delimiter)
135
+ else:
136
+ groups.append(common_str)
137
+ return groups
138
+
139
+
140
+ def generateTimeSeriesGroupPaths(paths: list,
141
+ input_head_dir: Path,
142
+ output_head_dir: Path,
143
+ dir_name_swaps: dict = {}) -> dict:
144
+ r"""Groups history files together by common substring patterns.
145
+
146
+ This function returns a dictionary that stores output templates
147
+ as keys and their corresponding history file paths as values.
148
+ Each output template is an incomplete path with a full parent
149
+ directory and partial file name. The parent directory path indicates
150
+ which directory the timeseries files produced by the respective history
151
+ file paths (stored in a list as the value to the output template which
152
+ serves as the key). The partial file name is the suffix for which
153
+ timeseries files produced from that particular collection of history
154
+ files will be named from.
155
+
156
+ This primiarly accomplishes two things:
157
+ 1. History files are appropriately grouped by differences in the
158
+ file names and that these groups are carried over into the
159
+ file names for the timeseries datasets.
160
+ 2. Multiple groups can exist together in the same directory. This
161
+ is typically the case for model output that contains more than
162
+ one average timestep.
163
+
164
+ Parameters
165
+ ----------
166
+ paths : list(Path)
167
+ List of Path objects that point to a large, unsorted, unfiltered list
168
+ of history files.
169
+ input_head_dir : Path
170
+ Path for head directory of input directory structure for the history
171
+ file locations stored in `path`, used to generate reflective directory
172
+ structure under `output_head_dir`.
173
+ output_head_dir : Path
174
+ Path to head direcory that reflective subdirectories will be generated
175
+ under for outputing timeseries files.
176
+ dir_name_swaps: dict
177
+ Dictionary containing keyword swaps for output directory structure.
178
+ For example, ``hist`` may replace ``proc/tseries/``
179
+ (e.g. {``hist``: ``proc/tseries/``}).
180
+
181
+ Returns
182
+ -------
183
+ timeseries_groups : dict
184
+ Dictionary containing output templates as keys and the paths to their
185
+ corresponding history files as values.
186
+ """
187
+ timeseries_groups = {}
188
+ parent_directories = {}
189
+ for path in paths:
190
+ if path.parent in parent_directories:
191
+ parent_directories[path.parent].append(path)
192
+ else:
193
+ parent_directories[path.parent] = [path]
194
+
195
+ for parent in parent_directories:
196
+ groups = solveForGroupsLeftToRight([path.name for path in parent_directories[parent]])
197
+ conflicts = {}
198
+ for group in groups:
199
+ for comparable_group in groups:
200
+ if group != comparable_group and group in comparable_group:
201
+ if group in conflicts:
202
+ conflicts[group].append(comparable_group)
203
+ else:
204
+ conflicts[group] = [comparable_group]
205
+ group_to_paths = {group: [] for group in groups}
206
+ for path in parent_directories[parent]:
207
+ for group in groups:
208
+ if group in str(path.name):
209
+ if group in conflicts:
210
+ conflict = False
211
+ for conflict_group in conflicts[group]:
212
+ if conflict_group in str(path.name):
213
+ conflict = True
214
+ break
215
+ if not conflict:
216
+ group_to_paths[group].append(path)
217
+ else:
218
+ group_to_paths[group].append(path)
219
+
220
+ group_output_dir = generateReflectiveOutputDirectory(input_head_dir, output_head_dir, parent, swaps=dir_name_swaps)
221
+ for group in group_to_paths:
222
+ timeseries_groups[Path(group_output_dir + "/" + group)] = group_to_paths[group]
223
+ return timeseries_groups
224
+
225
+
226
+ def isVariableAuxiliary(variable_meta: dict,
227
+ auxiliary_dims: list = ["nbnd", "chars", "string_length", "hist_interval"],
228
+ primary_dims: list = ["time"]) -> bool:
229
+ r"""Determines if a variable is auxiliary based on metadata.
230
+
231
+ This function determins which variables are deemed ``auxiliary``
232
+ and thus stored in every resulting timeseries file. They should
233
+ be small variables, typically of one dimension. Coordinate variables
234
+ should meet this criteria, but some descriptive variables may have
235
+ additional dimensions that are small in size.
236
+
237
+ For now, these variables are identified by containing any of the dimensions
238
+ specified in `auxiliary_dims`.
239
+
240
+ Some variables are also small enough to fit in every timeseries file
241
+ because they do not evolve with time (do not contain the time dimension).
242
+ Variables that do not contain dimensions in `auxiliary_dims` will still
243
+ be considered auxiliary if they do not any of the contain dimensions
244
+ specified in `primary_dims`.
245
+
246
+ The defautls are configured somewhat arbitrarily and will likely
247
+ need to be improved for comaptability with other model runs (and
248
+ definitely for other models). We may also need to implement a new method
249
+ for identifying auxiliary variables other than just dimensions.
250
+
251
+ Parameters
252
+ ----------
253
+ variable_meta : dict
254
+ Dictionary containing metadata produced by `getHistoryFileMetaData`
255
+
256
+ Returns
257
+ -------
258
+ bool
259
+ True if the variable is auxiliary, false if it is not.
260
+ """
261
+ dims = np.unique(variable_meta["dimensions"])
262
+
263
+ for tag in auxiliary_dims:
264
+ if tag in dims:
265
+ return True
266
+
267
+ if len(dims) > 1:
268
+ for tag in primary_dims:
269
+ if tag in dims:
270
+ return False
271
+
272
+ return True
273
+
274
+
275
+ def getHistoryFileMetaData(hs_file_path: Path) -> dict:
276
+ r"""Builds dictionary containing metadata information for history file.
277
+
278
+ This function reads coordinate and attribute information in the netCDF
279
+ file to obtian metadata quickly. No variable data is loaded, so this
280
+ function call is relatively lightweight and easily parallelized.
281
+
282
+ The metadata pulled is specific to the timeseries generation stack and
283
+ stored loosely in a dictionary. This may be converted into a class of
284
+ it's own in the future.
285
+
286
+ Parameters
287
+ ----------
288
+ hs_file_path : Path
289
+ Path to history file to pull metadata from.
290
+
291
+ Returns
292
+ -------
293
+ meta : dict
294
+ Dictionary containing useful metadata for specified history file.
295
+ """
296
+ meta = {}
297
+ ds = netCDF4.Dataset(hs_file_path, mode="r")
298
+ try:
299
+ meta["file_size"] = getsize(hs_file_path)
300
+ meta["variables"] = list(ds.variables)
301
+ meta["global_attrs"] = {key: getattr(ds, key) for key in ds.ncattrs()}
302
+ meta["variable_meta"] = {}
303
+ if "time" in meta["variables"]:
304
+ meta["time"] = cftime.num2date(ds["time"][:], units=ds["time"].units, calendar=ds["time"].calendar)
305
+ else:
306
+ meta["time"] = None
307
+
308
+ for variable in meta["variables"]:
309
+ meta["variable_meta"][variable] = {}
310
+ if type(ds[variable]) is netCDF4._netCDF4._Variable:
311
+ for key in ds[variable].ncattrs():
312
+ meta["variable_meta"][variable][key] = ds[variable].__getattr__(key)
313
+ else:
314
+ for key in ds[variable].ncattrs():
315
+ meta["variable_meta"][variable][key] = ds[variable].getncattr(key)
316
+
317
+ meta["variable_meta"][variable]["dimensions"] = list(ds[variable].dimensions)
318
+ meta["variable_meta"][variable]["data_type"] = ds[variable].dtype
319
+ meta["variable_meta"][variable]["shape"] = ds[variable].shape
320
+
321
+ meta["primary_variables"] = []
322
+ meta["auxiliary_variables"] = []
323
+ for variable in meta["variable_meta"]:
324
+ if isVariableAuxiliary(meta["variable_meta"][variable]):
325
+ meta["auxiliary_variables"].append(variable)
326
+ else:
327
+ meta["primary_variables"].append(variable)
328
+ except Exception as e:
329
+ meta["exception"] = e
330
+ ds.close()
331
+ return meta
332
+
333
+
334
+ def getYearSlices(years: list, slice_length: int) -> list:
335
+ r"""Generates list of index tuples for slicing time array into year chunks.
336
+
337
+ Given a list of years, this function will divide the list into chunks, with
338
+ maximum length specified by `slice_length`. The chunking is configured
339
+ to align boundary years to multiples of the slice length. In other words,
340
+ the timeseries may begin and end with a slice chunk shorter than
341
+ `slice length`. For example, if a timeseries begins in 2015 and ends in
342
+ 2092 is chunked by 10 years, the first chunk will be 2015-2019 and the last
343
+ chunk will be 2090-2092. Everything between will be 10 years (2020-2029,
344
+ 2030-2039, etc).
345
+
346
+ Parameters
347
+ ----------
348
+ years : list
349
+ Path to history file to pull metadata from.
350
+ slice_length : int
351
+ Number of years per chunk to slice.
352
+
353
+ Returns
354
+ -------
355
+ meta : dict
356
+ Dictionary containing useful metadata for specified history file.
357
+ """
358
+ slices = []
359
+ last_slice_yr = years[0]
360
+ for index in range(len(years)):
361
+ if years[index] % slice_length == 0 and years[index] != last_slice_yr:
362
+ if len(slices) == 0:
363
+ slices.append((0, index))
364
+ else:
365
+ slices.append((slices[-1][1], index))
366
+ last_slice_yr = years[index]
367
+ if len(slices) == 0:
368
+ slices.append((0, index + 1))
369
+ elif slices[-1][1] != index + 1:
370
+ slices.append((slices[-1][1], index + 1))
371
+ return slices
372
+
373
+
374
+ def generateTimeSeries(output_template: Path,
375
+ hf_paths: list,
376
+ auxiliary_variables: list,
377
+ primary_variables: list,
378
+ time_str_format: str,
379
+ compression_level : int = None,
380
+ overwrite: bool = False,
381
+ debug_timing: bool = True) -> tuple:
382
+ r"""Generates timeseries files from history files.
383
+
384
+ This is the primary function for generating the timeseries files and
385
+ controls most of the I/O processing. This function can operate on one
386
+ timeseries file at a time and access history files independently,
387
+ thus this function can be called in parallel across multiple workers.
388
+
389
+ All history files specified in `hf_paths` are opened and aggregated along
390
+ the time dimension. They are assumed to be of the same group. The
391
+ ``primary_variables`` key in `metadata` specifies which variables in each
392
+ of the history files should be aggregated. Each variable is read separately
393
+ and computed within this function call. Therefore, this list should be
394
+ short for each function call, therefore more calls can be made on other
395
+ workers for greater throughput. However, this list can be greater than one
396
+ variable if this process needs to be consolidated.
397
+
398
+ The naming of each timeseries files is determined via the `output_template`
399
+ and `time_str_format`.
400
+
401
+ Parameters
402
+ ----------
403
+ output_template : Path
404
+ Incomplete path with parent subdirectory to output timeseries file to
405
+ and name to use as suffix in naming it.
406
+ hf_paths : list
407
+ List of paths pointing to the history files from which these timeseries
408
+ files will be generated from.
409
+ auxiliary_variables : list
410
+
411
+ primary_variables : list
412
+
413
+ time_str_format : str
414
+ Date format code used to convert the CFTime object to a string. This
415
+ may vary for different timesteps and should be determined before
416
+ calling this function.
417
+ compression_level : int
418
+
419
+ overwrite : bool
420
+ Whether or not to overwrite timeseries files if they already exist at
421
+ the generated paths (Default: False).
422
+ debug_timing : bool
423
+ Includes the time to generate each resulting timeseries file in the
424
+ output tuples.
425
+
426
+ Returns
427
+ -------
428
+ ts_paths : list
429
+ Path to each timeseries file generated.
430
+ tuple (if debug_timing)
431
+ Time to compute timeseries at each path in `ts_paths`.
432
+ """
433
+ debug_start_time = time()
434
+ output_template.parent.mkdir(parents=True, exist_ok=True)
435
+
436
+ auxiliary_ds = netCDF4.MFDataset(hf_paths, aggdim="time", exclude=primary_variables)
437
+ auxiliary_ds.set_auto_mask(False)
438
+ auxiliary_ds.set_auto_scale(False)
439
+ auxiliary_ds.set_always_mask(False)
440
+
441
+ time_start_str = cftime.num2date(auxiliary_ds["time"][0],
442
+ units=auxiliary_ds["time"].units,
443
+ calendar=auxiliary_ds["time"].calendar).strftime(time_str_format)
444
+ time_end_str = cftime.num2date(auxiliary_ds["time"][-1],
445
+ units=auxiliary_ds["time"].units,
446
+ calendar=auxiliary_ds["time"].calendar).strftime(time_str_format)
447
+
448
+ auxiliary_variable_data = {}
449
+ for auxiliary_var in auxiliary_variables:
450
+ attrs = {}
451
+ for key in auxiliary_ds[auxiliary_var].ncattrs():
452
+ if type(auxiliary_ds[auxiliary_var]) is netCDF4._netCDF4._Variable:
453
+ attrs[key] = auxiliary_ds[auxiliary_var].__getattr__(key)
454
+ else:
455
+ attrs[key] = auxiliary_ds[auxiliary_var].getncattr(key)
456
+ auxiliary_variable_data[auxiliary_var] = {
457
+ "attrs": attrs,
458
+ "dimensions": auxiliary_ds[auxiliary_var].dimensions,
459
+ "shape": auxiliary_ds[auxiliary_var].shape,
460
+ "dtype": auxiliary_ds[auxiliary_var].dtype,
461
+ "data": auxiliary_ds[auxiliary_var][:],
462
+ }
463
+ auxiliary_ds.close()
464
+
465
+ primary_ds = netCDF4.MFDataset(hf_paths, aggdim="time", exclude=auxiliary_variables)
466
+ primary_ds.set_auto_mask(False)
467
+ primary_ds.set_auto_scale(False)
468
+ primary_ds.set_always_mask(False)
469
+
470
+ complevel = compression_level
471
+ compression = None
472
+ if complevel > 0:
473
+ compression = "bzip2"
474
+
475
+ ts_paths = []
476
+ for primary_var in primary_variables:
477
+ ts_path = output_template.parent / f"{output_template.name}{primary_var}.{time_start_str}.{time_end_str}.nc"
478
+ ts_paths.append(ts_path)
479
+ if isfile(ts_path) and not overwrite:
480
+ try:
481
+ ds = netCDF4.Dataset(ts_path, mode="r")
482
+ attrs = {key: getattr(ds, key) for key in ds.ncattrs()}
483
+ ds.close()
484
+ if "timeseries_process_complete" in attrs and attrs["timeseries_process"] == "complete":
485
+ continue
486
+ else:
487
+ remove(ts_path)
488
+ except OSError:
489
+ remove(ts_path)
490
+ elif isfile(ts_path) and overwrite:
491
+ remove(ts_path)
492
+ ts_ds = netCDF4.Dataset(ts_path, mode="w")
493
+ for dim_index, dim in enumerate(primary_ds[primary_var].dimensions):
494
+ if dim not in ts_ds.dimensions:
495
+ if dim == "time":
496
+ ts_ds.createDimension(dim, None)
497
+ else:
498
+ ts_ds.createDimension(dim, primary_ds[primary_var].shape[dim_index])
499
+
500
+ var_data = ts_ds.createVariable(primary_var,
501
+ primary_ds[primary_var].dtype,
502
+ primary_ds[primary_var].dimensions, complevel=complevel, compression=compression)
503
+ var_data.set_auto_mask(False)
504
+ var_data.set_auto_scale(False)
505
+ var_data.set_always_mask(False)
506
+
507
+ attrs = {}
508
+ if type(primary_ds[primary_var]) is netCDF4._netCDF4._Variable:
509
+ for key in primary_ds[primary_var].ncattrs():
510
+ attrs[key] = primary_ds[primary_var].__getattr__(key)
511
+ else:
512
+ for key in primary_ds[primary_var].ncattrs():
513
+ attrs[key] = primary_ds[primary_var].getncattr(key)
514
+
515
+ ts_ds[primary_var].setncatts(attrs)
516
+
517
+ time_chunk_size = 1
518
+ if len(primary_ds[primary_var].shape) > 0 and "time" in primary_ds[primary_var].dimensions:
519
+ for i in range(0, primary_ds[primary_var].shape[0], time_chunk_size):
520
+ if i + time_chunk_size > primary_ds[primary_var].shape[0]:
521
+ time_chunk_size = primary_ds[primary_var].shape[0] - i
522
+ var_data[i:i + time_chunk_size] = primary_ds[primary_var][i:i + time_chunk_size]
523
+ else:
524
+ var_data[:] = primary_ds[primary_var][:]
525
+
526
+ for auxiliary_var in auxiliary_variable_data:
527
+ for dim_index, dim in enumerate(auxiliary_variable_data[auxiliary_var]["dimensions"]):
528
+ if dim not in ts_ds.dimensions:
529
+ ts_ds.createDimension(dim, auxiliary_variable_data[auxiliary_var]["shape"][dim_index])
530
+ aux_var_data = ts_ds.createVariable(auxiliary_var,
531
+ auxiliary_variable_data[auxiliary_var]["dtype"],
532
+ auxiliary_variable_data[auxiliary_var]["dimensions"])
533
+ aux_var_data.set_auto_mask(False)
534
+ aux_var_data.set_auto_scale(False)
535
+ aux_var_data.set_always_mask(False)
536
+ ts_ds[auxiliary_var].setncatts(auxiliary_variable_data[auxiliary_var]["attrs"])
537
+
538
+ aux_var_data[:] = auxiliary_variable_data[auxiliary_var]["data"]
539
+ ts_ds.setncatts({key: getattr(primary_ds, key) for key in primary_ds.ncattrs()} | {"timeseries_software_version": "0.8", "timeseries_process": "complete"})
540
+ ts_ds.close()
541
+
542
+ if debug_timing:
543
+ return (time() - debug_start_time, ts_paths)
544
+ else:
545
+ return ts_paths
546
+
547
+
548
+ class ModelOutputDatabase:
549
+ r"""Database for centralizing all history-to-timeseries file operations.
550
+
551
+ Attributes
552
+ ----------
553
+ None
554
+
555
+ Methods
556
+ -------
557
+ getHistoryFileMetaData()
558
+ Returns metadata for particulary history file.
559
+ getTimeSeriesGroups()
560
+ Returns list of paths to detected history files grouped.
561
+ getHistoryFilePaths()
562
+ Returns list of paths to detected history files.
563
+ getTimeStepHours()
564
+ Determines timestep for group history files.
565
+ getTimeStepStr()
566
+ Determines timestep label for group of history files.
567
+ getTimeStepStrFormat()
568
+ Determines date string format code based on timstep label.
569
+ build()
570
+ Builds database by reading each history file and storing metadata.
571
+ run()
572
+ Starts process for generating timeseries files.
573
+ """
574
+
575
+ def __init__(self,
576
+ hf_head_dir: str,
577
+ ts_head_dir: str,
578
+ dir_name_swaps: dict = {},
579
+ file_exclusions: list = [],
580
+ dir_exclusions: list = ["rest", "logs"],
581
+ timeseries_year_length: int = 10,
582
+ overwrite: bool = False,
583
+ include_variables: list = None,
584
+ exclude_variables: list = None,
585
+ year_start: int = None,
586
+ year_end: int = None,
587
+ compression_level: int = None,
588
+ variable_compression_levels: dict = None) -> None:
589
+ r"""Parameters
590
+ ----------
591
+ hf_head_dir : str
592
+ Path to head directory to structure with subdirectories containing
593
+ history files.
594
+ ts_head_dir : str
595
+ Path to head directory where structure reflecting `hf_head_dir`
596
+ will be created and timeseries files will be written to.
597
+ dir_name_swaps : dict
598
+ Dictionary for swapping out keyword directory names in the
599
+ structure under `hf_head_dir` (e.g. ``{"hist" : "proc/tseries"}``
600
+ file_exclusions : list
601
+ File names containing any of the keywords in this list will be
602
+ excluded from the database.
603
+ dir_exclusions : list
604
+ Directory names containing any of the keywords in this list will be
605
+ excluded from the database.
606
+ timeseries_year_length : int
607
+ Number of years each timeseries file should be chunked to using
608
+ `getYearSlices` (Default: 10).
609
+ overwrite : bool
610
+ Whether or not to overwrite timeseries files if they already exist
611
+ at the generated paths (Default: False).
612
+ include_variables : list
613
+ Variables to include in either creating individual timeseries files
614
+ for adding as auxiliary variables.
615
+ exclude_variables : list
616
+ Variables to exclude from either creating individual timeseries
617
+ files for adding as auxiliary variables.
618
+ year_start : int
619
+ Starting year for timeseries generation, must be later than first
620
+ history file timestamp to have an effect.
621
+ year_end : int
622
+ Ending year for timeseries generation, must be later than last
623
+ history file timestamp to have an effect.
624
+ compression_level : int
625
+ Compression level to pass to netCDF4 engine when generating
626
+ timeseries files.
627
+ variable_compression_levels : dict
628
+ Compression levels to apply to specific variables (variable name is
629
+ key and the compression level is the value).
630
+ """
631
+ self.log("Initializing...")
632
+ self.__hf_head_dir = Path(hf_head_dir)
633
+ self.__ts_head_dir = Path(ts_head_dir)
634
+ self.__overwrite = overwrite
635
+ self.__timeseries_year_length = timeseries_year_length
636
+ self.__include_variables = include_variables
637
+ self.__exclude_variables = exclude_variables
638
+ if self.__exclude_variables is None:
639
+ self.__exclude_variables = []
640
+ self.__year_start = year_start
641
+ self.__year_end = year_end
642
+ self.__compression_level = compression_level
643
+ if self.__compression_level is None:
644
+ self.__compression_level = 0
645
+ self.__variable_compression_levels = variable_compression_levels
646
+ self.__total_size = 0
647
+ self.__built = False
648
+
649
+ start_t = time()
650
+ self.log(f"Searching tree for netCDF files: '{hf_head_dir}'")
651
+ self.__history_file_paths = []
652
+ for path in sorted(self.__hf_head_dir.rglob("*.nc")):
653
+ exclude = False
654
+ for exclusion in file_exclusions:
655
+ if exclusion in path.name:
656
+ exclude = True
657
+
658
+ directory_names = [directory.name for directory in sorted(path.parents)]
659
+
660
+ for exclusion in dir_exclusions:
661
+ if exclusion in directory_names:
662
+ exclude = True
663
+
664
+ if not exclude:
665
+ self.__history_file_paths.append(path)
666
+ self.log("\tDone.")
667
+ start_t = time()
668
+ self.log("Grouping history files... ")
669
+ self.__timeseries_group_paths = generateTimeSeriesGroupPaths(self.__history_file_paths, hf_head_dir, ts_head_dir, dir_name_swaps=dir_name_swaps)
670
+ self.log("Done.")
671
+
672
+ def log(self, msg, end="\n"):
673
+ print(msg, end=end)
674
+
675
+ def getHistoryFileMetaData(self,
676
+ history_file_path: Path) -> dict:
677
+ r"""Returns metadata for particulary history file.
678
+
679
+ Metadata is generated by `getHistoryFileMetaData`.
680
+ """
681
+ return self.__history_file_metas[history_file_path]
682
+
683
+ def getTimeSeriesGroups(self):
684
+ r"""Returns list of paths to detected history files grouped.
685
+
686
+ Groups are determined by `generateTimeSeriesGroupPaths`,
687
+ based on which directories the history files are sotred in and
688
+ substrings in the file names.
689
+ """
690
+ return self.__timeseries_group_paths
691
+
692
+ def getHistoryFilePaths(self):
693
+ r"""Returns list of paths to detected history files."""
694
+ return self.__history_file_paths
695
+
696
+ def getTimeStepHours(self,
697
+ hf_paths: list):
698
+ r"""Determines timestep for group history files.
699
+
700
+ Given a group of history files in a timeseries, this function
701
+ determines what the timestep is in hours.
702
+
703
+ Note that this function relies on the time coordinate obtained from
704
+ metadata, not the file name.
705
+
706
+ Parameters
707
+ ----------
708
+ hf_paths : list
709
+ List of paths to history files.
710
+
711
+ Returns
712
+ -------
713
+ float
714
+ Timestep in hours.
715
+ """
716
+ times = []
717
+ for path in hf_paths:
718
+ hf_time = self.__history_file_metas[path]["time"]
719
+ if len(hf_time) == 0:
720
+ return 0
721
+ elif len(hf_time) == 1:
722
+ times.append(hf_time[0])
723
+ else:
724
+ times.append(hf_time[0])
725
+ times.append(hf_time[1])
726
+ break
727
+
728
+ times.sort()
729
+ if len(times) == 1:
730
+ return 0.0
731
+ else:
732
+ return (times[1] - times[0]).total_seconds() / 60 / 60
733
+
734
+ def getTimeStepStr(self,
735
+ hf_paths: list) -> str:
736
+ r"""Determines timestep label for group of history files.
737
+
738
+ Given a group of history files in a timeseries, this function
739
+ determines what group label should be used for the parent directory.
740
+ This is also used to determine how the time range is shown in the
741
+ file names.
742
+
743
+ The timestep is obtained from `ModelOutputDatabase.getTimeStepHours`
744
+
745
+ Parameters
746
+ ----------
747
+ hf_paths : list
748
+ List of paths to history files.
749
+
750
+ Returns
751
+ -------
752
+ str
753
+ Label for timestep
754
+ """
755
+ if "time_period_freq" in self.__history_file_metas[hf_paths[0]]["global_attrs"]:
756
+ return self.__history_file_metas[hf_paths[0]]["global_attrs"]["time_period_freq"]
757
+
758
+ dt_hrs = self.getTimeStepHours(hf_paths)
759
+ if dt_hrs >= 24*364:
760
+ return f"year_{int(np.ceil(dt_hrs / (24*365)))}"
761
+ elif dt_hrs >= 24*28:
762
+ return f"month_{int(np.ceil(dt_hrs / (24*31)))}"
763
+ elif dt_hrs >= 24:
764
+ return f"day_{int(np.ceil(dt_hrs / (24)))}"
765
+ else:
766
+ return f"hour_{int(np.ceil(dt_hrs))}"
767
+
768
+ def getTimeStepStrFormat(self,
769
+ timestep_str: str) -> str:
770
+ r"""Determines date string format code based on timstep label.
771
+
772
+ This function determines how the date ranges for each timeseries
773
+ dataset is shown in the file name.
774
+
775
+ Parameters
776
+ ----------
777
+ timestep_str : str
778
+ Label for timestep group (e.g. ``day_1``).
779
+
780
+ Returns
781
+ -------
782
+ str
783
+ Date string format code for converting CFTime object to string for
784
+ file naming.
785
+ """
786
+ if "hour" in timestep_str:
787
+ return "%Y-%m-%d-%H"
788
+ elif "day" in timestep_str:
789
+ return "%Y-%m-%d"
790
+ elif "month" in timestep_str:
791
+ return "%Y-%m"
792
+ elif "year" in timestep_str:
793
+ return "%Y"
794
+ else:
795
+ return "%Y-%m-%d-%H"
796
+
797
+ def build(self,
798
+ client: dask.distributed.Client = None) -> None:
799
+ r"""Builds database by reading each history file and storing metadata.
800
+
801
+ Should be called before `run()`
802
+
803
+ This only reads metadata (no variable data is loaded) and is therefore
804
+ lightweight. This function recursviely reads the directory structure
805
+ starting at the head directory specified at class initialization.
806
+
807
+ Although reading metadata is lightweight, latency can increase with
808
+ a large number of files. Therefore, Dask is used to convert metadata
809
+ calls into lazy delayed functions and execute on a cluster. Unless a
810
+ client is specified, it will automatically detect the Dask global
811
+ client if it exists. If neither exists, then it proceeds in serial.
812
+
813
+ Parameters
814
+ ----------
815
+ client : dask.distributed.Client
816
+ Client object to use for Dask parallelization.
817
+
818
+ Returns
819
+ -------
820
+ None
821
+ """
822
+ start_bt = time()
823
+ self.log("Starting Build.")
824
+ if client is None:
825
+ client = dask.distributed.client._get_global_client()
826
+
827
+ self.__gen_ts_args_templates = []
828
+ self.__gen_ts_args_hf_paths = []
829
+ self.__gen_ts_args_auxiliary_vars = []
830
+ self.__gen_ts_args_primary_vars = []
831
+ self.__gen_ts_args_time_formats = []
832
+ self.__gen_ts_args_comp_levels = []
833
+ self.__gen_ts_args_overwrites = []
834
+
835
+ start_t = time()
836
+ self.__history_file_metas = {}
837
+ if client is None:
838
+ self.log("Dask client not detected, proceeding serially.")
839
+ self.log("\tGathering metadata...")
840
+ for path in self.__history_file_paths:
841
+ self.__history_file_metas[path] = getHistoryFileMetaData(path)
842
+ self.log("\t Done.")
843
+ else:
844
+ self.log("Dask client detected.")
845
+ self.log("\tGathering metadata...")
846
+ bag = db.from_sequence(self.__history_file_paths, npartitions=20000).map(getHistoryFileMetaData)
847
+ metas = bag.compute()
848
+ for index, path in enumerate(self.__history_file_paths):
849
+ self.__history_file_metas[path] = metas[index]
850
+ self.log("\tDone.")
851
+
852
+ start_t = time()
853
+ self.log("\tComputing timeseries arguments...")
854
+ new_timeseries_group_paths = {}
855
+ for path_template in self.__timeseries_group_paths:
856
+ hs_file_paths = self.__timeseries_group_paths[path_template]
857
+ timestep_str = self.getTimeStepStr(hs_file_paths)
858
+ new_path_template = (path_template.parent / timestep_str) / path_template.name
859
+ new_timeseries_group_paths[new_path_template] = hs_file_paths
860
+ self.__timeseries_group_paths = new_timeseries_group_paths
861
+
862
+ for meta in metas:
863
+ self.__total_size += meta["file_size"]
864
+
865
+ self.__generateTimeSeries_args = []
866
+ for output_template in self.getTimeSeriesGroups():
867
+ hf_paths = self.getTimeSeriesGroups()[output_template]
868
+ years = [self.getHistoryFileMetaData(hf_path)["time"][0].year for hf_path in hf_paths]
869
+
870
+ for start_index, end_index in getYearSlices(years, self.__timeseries_year_length):
871
+ within_range = True
872
+ if self.__year_start is not None and years[start_index] < self.__year_start:
873
+ within_range = False
874
+ for index in range(start_index, end_index):
875
+ if years[start_index] >= self.__year_start:
876
+ start_index = index
877
+ within_range = True
878
+ break
879
+ if self.__year_end is not None and years[end_index-1] > self.__year_end:
880
+ within_range = False
881
+ for index in range(start_index, end_index):
882
+ if years[end_index-1] <= self.__year_end:
883
+ end_index = index
884
+ within_range = True
885
+ break
886
+
887
+ if not within_range or end_index <= start_index:
888
+ continue
889
+
890
+ slice_paths = hf_paths[start_index:end_index]
891
+ metadata = self.getHistoryFileMetaData(slice_paths[0])
892
+ time_str_format = self.getTimeStepStrFormat(self.getTimeStepStr(slice_paths))
893
+
894
+ for primary_variable in metadata["primary_variables"]:
895
+ if self.__include_variables is not None and primary_variable not in self.__include_variables:
896
+ continue
897
+ if primary_variable not in self.__exclude_variables:
898
+ primary_variables = np.array([primary_variable])
899
+ auxiliary_variables = np.array(metadata["auxiliary_variables"])
900
+ if self.__variable_compression_levels is not None and primary_variable in self.__variable_compression_levels:
901
+ compression_level = self.__variable_compression_levels[primary_variable]
902
+ else:
903
+ compression_level = self.__compression_level
904
+ self.__gen_ts_args_templates.append(output_template)
905
+ self.__gen_ts_args_hf_paths.append(slice_paths)
906
+ self.__gen_ts_args_auxiliary_vars.append(auxiliary_variables)
907
+ self.__gen_ts_args_primary_vars.append(primary_variables)
908
+ self.__gen_ts_args_time_formats.append(time_str_format)
909
+ self.__gen_ts_args_comp_levels.append(compression_level)
910
+ self.__gen_ts_args_overwrites.append(self.__overwrite)
911
+ self.log("\tDone.")
912
+ self.log("Build complete.")
913
+ self.__built = True
914
+
915
+ def getGenTSArgs(self):
916
+ return self.__generateTimeSeries_args
917
+
918
+ def run(self,
919
+ client: dask.distributed.Client = None,
920
+ serial: bool = False):
921
+ r"""Starts process for generating timeseries files.
922
+
923
+ This function generates the appropriate parameters for the database and
924
+ parallelizes `generateTimeSeries` using the Dask cluster if it exists
925
+ or runs in serial. The call is blocking and will wait for all
926
+ timeseries files to be written to disk before returning the paths.
927
+
928
+ Parameters
929
+ ----------
930
+ client : dask.distributed.Client
931
+ Client object to use for Dask parallelization.
932
+ serial : bool
933
+ Whether or not to force serial execution and not use Dask
934
+ (Default: False).
935
+
936
+ Returns
937
+ -------
938
+ ts_paths : list
939
+ List of paths pointing to timeseries files generated.
940
+ """
941
+ if not self.__built:
942
+ self.log("Building arguments.")
943
+ self.build(client=client)
944
+ else:
945
+ self.log("Build already complete. Skipping build step.")
946
+
947
+ if client is None:
948
+ client = dask.distributed.client._get_global_client()
949
+
950
+ ts_paths = []
951
+ if client is None or serial:
952
+ self.log("ERROR: No Dask client detected, serial run() not yet implemented.")
953
+ pass
954
+ else:
955
+ self.log("Dask client detected, mapping arguments to generateTimeSeries() in parallel.")
956
+ futures = client.map(generateTimeSeries,
957
+ self.__gen_ts_args_templates,
958
+ self.__gen_ts_args_hf_paths,
959
+ self.__gen_ts_args_auxiliary_vars,
960
+ self.__gen_ts_args_primary_vars,
961
+ self.__gen_ts_args_time_formats,
962
+ self.__gen_ts_args_comp_levels,
963
+ self.__gen_ts_args_overwrites)
964
+ self.log("Map complete, awaiting cluster computation...")
965
+ ts_paths = client.gather(futures, errors="skip")
966
+ self.log(f"\tDone.")
967
+ return ts_paths