ngiab_eval 0.1.1__tar.gz

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,6 @@
1
+ .git
2
+ env
3
+ __pycache__
4
+ *.sqlite
5
+ *.egg-info
6
+ *_results.json
@@ -0,0 +1,63 @@
1
+ name: Build and Push Docker Image
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ pypi-publish:
9
+ name: Publish release to PyPI
10
+ runs-on: ubuntu-latest
11
+ environment:
12
+ name: pypi
13
+ url: https://pypi.org/p/ngiab_eval
14
+ permissions:
15
+ id-token: write
16
+ steps:
17
+ - uses: actions/checkout@v4.1.7
18
+ - name: Set up Python
19
+ uses: actions/setup-python@v5.2.0
20
+ with:
21
+ python-version: "3.x"
22
+ - name: Install dependencies
23
+ run: |
24
+ python -m pip install --upgrade pip
25
+ pip install setuptools wheel build
26
+ - name: Update package version
27
+ run: |
28
+ sed -i 's/^#version.*/version = "${{ github.event.release.tag_name }}"/g' pyproject.toml
29
+ sed -i '/^dynamic.*/d' pyproject.toml
30
+ - name: Build package
31
+ run: |
32
+ python -m build
33
+ - name: Publish package distributions to PyPI
34
+ uses: pypa/gh-action-pypi-publish@release/v1.9
35
+ docker-build-and-push:
36
+ # no point building the docker image if the pypi publish fails
37
+ needs: pypi-publish
38
+ runs-on: ubuntu-latest
39
+ steps:
40
+ - name: Checkout code
41
+ uses: actions/checkout@v4.1.7
42
+
43
+ - name: Set up QEMU
44
+ uses: docker/setup-qemu-action@v3.2.0
45
+
46
+ - name: Set up Docker Buildx
47
+ uses: docker/setup-buildx-action@v3.6.1
48
+
49
+ - name: Login to DockerHub
50
+ uses: docker/login-action@v3.3.0
51
+ with:
52
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
53
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
54
+
55
+ - name: Build and push
56
+ uses: docker/build-push-action@v6.7.0
57
+ with:
58
+ context: .
59
+ platforms: linux/amd64
60
+ push: true
61
+ tags: |
62
+ ${{ secrets.DOCKERHUB_USERNAME }}/ngiab_eval:latest
63
+ ${{ secrets.DOCKERHUB_USERNAME }}/ngiab_eval:${{ github.event.release.tag_name }}
@@ -0,0 +1,5 @@
1
+ env
2
+ *.sqlite
3
+ *_results.json
4
+ __pycache__
5
+ *.egg-info
@@ -0,0 +1,13 @@
1
+ FROM rockylinux:9.1 AS base
2
+
3
+ # Install dependencies
4
+ RUN dnf install python3 python3-pip -y
5
+
6
+ # Copy the application
7
+ COPY . /ngiab_eval
8
+
9
+ # Install the application
10
+ RUN pip3 install /ngiab_eval[plot]
11
+
12
+ # Run the application
13
+ ENTRYPOINT ["python3","-m","ngiab_eval","-i","/ngen/ngen/data"]
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: ngiab_eval
3
+ Version: 0.1.1
4
+ Summary: Basic KGE and NSE evaluation for the NGIAB project
5
+ Author-email: Josh Cunningham <jcunningham8@ua.edu>
6
+ Project-URL: Homepage, https://github.com/JoshCu/ngiab_eval
7
+ Project-URL: Issues, https://github.com/JoshCu/ngiab_eval/issues
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: xarray==2024.2.0
14
+ Requires-Dist: dask==2024.4.1
15
+ Requires-Dist: dask[distributed]==2024.4.1
16
+ Requires-Dist: s3fs==2024.3.1
17
+ Requires-Dist: zarr==2.17.1
18
+ Requires-Dist: hydrotools
19
+ Requires-Dist: hydroeval
20
+ Requires-Dist: netcdf4
21
+ Requires-Dist: colorama
22
+ Provides-Extra: plot
23
+ Requires-Dist: seaborn; extra == "plot"
24
+ Requires-Dist: matplotlib; extra == "plot"
25
+
26
+ # Please don't use this
27
+ If you've found this, it's a throw-away prototype to help with planning. Don't expect anything other than random numbers to come out of it. I haven't spent any time making this efficient or properly validating correctness, please don't use this as is or even as a reference. The gage_to_feature_id.py map is "correct" as far as the nwm output is stored in s3 zarrs, but I have not verified that those features are correct for those gages.
@@ -0,0 +1,2 @@
1
+ # Please don't use this
2
+ If you've found this, it's a throw-away prototype to help with planning. Don't expect anything other than random numbers to come out of it. I haven't spent any time making this efficient or properly validating correctness, please don't use this as is or even as a reference. The gage_to_feature_id.py map is "correct" as far as the nwm output is stored in s3 zarrs, but I have not verified that those features are correct for those gages.
@@ -0,0 +1,2 @@
1
+ from .core import evaluate_folder
2
+ from .logging_config import setup_logging
@@ -0,0 +1,52 @@
1
+ import logging
2
+ from pathlib import Path
3
+ import argparse
4
+ import warnings
5
+ from ngiab_eval import evaluate_folder
6
+ from ngiab_eval import setup_logging
7
+
8
+ # we check this ourselves and log a warning so we can silence this
9
+ warnings.filterwarnings("ignore", message="No data was returned by the request.")
10
+
11
+
12
+ logger = logging.getLogger(__name__)
13
+ logger.setLevel(logging.INFO)
14
+
15
+
16
+ def parse_arguments() -> argparse.Namespace:
17
+ """Parse command line arguments."""
18
+ parser = argparse.ArgumentParser(
19
+ description="Subsetting hydrofabrics, forcing generation, and realization creation"
20
+ )
21
+ parser.add_argument(
22
+ "-i",
23
+ "--input_file",
24
+ type=str,
25
+ help="Path to a csv or txt file containing a newline separated list of catchment IDs, when used with -l, the file should contain lat/lon pairs",
26
+ )
27
+ parser.add_argument(
28
+ "-d",
29
+ "--debug",
30
+ action="store_true",
31
+ help="enable debug logging",
32
+ )
33
+ parser.add_argument(
34
+ "-p",
35
+ "--plot",
36
+ action="store_true",
37
+ help="Plot streamflow data",
38
+ )
39
+ return parser.parse_args()
40
+
41
+ if __name__ == "__main__":
42
+ args = parse_arguments()
43
+ setup_logging(args.debug)
44
+ logger.info("Starting evaluation")
45
+ if args.debug:
46
+ logger.setLevel(logging.DEBUG)
47
+ if not args.input_file:
48
+ logger.error("No input file provided")
49
+ exit(1)
50
+
51
+ folder_to_eval = Path(args.input_file)
52
+ evaluate_folder(folder_to_eval, args.plot, args.debug)
@@ -0,0 +1,306 @@
1
+ import sqlite3
2
+ import os
3
+ import s3fs
4
+ import xarray as xr
5
+ import logging
6
+ from dask.distributed import Client, LocalCluster, progress
7
+ from distributed.utils import silence_logging_cmgr
8
+ from pathlib import Path
9
+ import pandas as pd
10
+ import glob
11
+ import time
12
+ import json
13
+ import numpy as np
14
+ from hydrotools.nwis_client import IVDataService
15
+ import hydroeval as he
16
+ from colorama import Fore, Style, init
17
+ from ngiab_eval.output_formatter import write_output, write_streamflow_to_sqlite
18
+ from ngiab_eval.gage_to_feature_id import feature_ids
19
+ import warnings
20
+ import multiprocessing
21
+ from functools import partial
22
+
23
+ # we check this ourselves and log a warning so we can silence this
24
+ warnings.filterwarnings("ignore", message="No data was returned by the request.")
25
+
26
+ # Initialize colorama
27
+ init(autoreset=True)
28
+
29
+ logger = logging.getLogger(__name__)
30
+ logger.setLevel(logging.INFO)
31
+
32
+
33
+ def download_nwm_output(gage, start_time, end_time) -> xr.Dataset:
34
+ """Load zarr datasets from S3 within the specified time range."""
35
+ # if a LocalCluster is not already running, start one
36
+
37
+ logger.debug("Creating s3fs object")
38
+ store = s3fs.S3Map(
39
+ f"s3://noaa-nwm-retrospective-3-0-pds/CONUS/zarr/chrtout.zarr",
40
+ s3=s3fs.S3FileSystem(anon=True),
41
+ )
42
+
43
+ logger.debug("Opening zarr store")
44
+ dataset = xr.open_zarr(store, consolidated=True)
45
+
46
+ # select the feature_id
47
+ logger.debug("Selecting feature_id")
48
+ dataset = dataset.sel(time=slice(start_time, end_time), feature_id=feature_ids[gage])
49
+
50
+ # drop everything except coordinates feature_id, gage_id, time and variables streamflow
51
+ dataset = dataset[["streamflow"]]
52
+ logger.debug("Computing dataset")
53
+ logger.debug("Dataset: %s", dataset)
54
+
55
+ return dataset
56
+
57
+
58
+ def check_local_cache(gage, start_time, end_time, cache_folder: Path = Path(".")) -> xr.Dataset:
59
+ # check if the data is already in the cache
60
+ # if it is, return it
61
+ # if it is not, download it and return it
62
+ cached_file = cache_folder / f"{gage}_{start_time}_{end_time}.nc"
63
+ temp_file = cache_folder / f"{gage}_{start_time}_{end_time}_downloading.nc"
64
+
65
+ if temp_file.exists():
66
+ temp_file.unlink()
67
+
68
+ if not cache_folder.exists():
69
+ cache_folder.mkdir(exist_ok=True, parents=True)
70
+
71
+ if cached_file.exists():
72
+ dataset = xr.open_dataset(cached_file)
73
+ else:
74
+ dataset = download_nwm_output(gage, start_time, end_time)
75
+ client = Client.current()
76
+ logger.debug("client fetched")
77
+ future = client.compute(dataset.to_netcdf(temp_file, compute=False))
78
+ logger.debug("future created")
79
+ # Display progress bar
80
+ progress(future)
81
+ future.result()
82
+ temp_file.rename(cached_file)
83
+ dataset = xr.open_dataset(cached_file)
84
+
85
+ df = zip(dataset.time.values, dataset.streamflow.values)
86
+ time_series = pd.DataFrame(df, columns=["time", "streamflow"])
87
+ return time_series
88
+
89
+
90
+ def get_gages_from_hydrofabric(folder_to_eval):
91
+ # search inside the folder for _subset.gpkg recursively
92
+ gpkg_file = None
93
+ for root, dirs, files in os.walk(folder_to_eval):
94
+ for file in files:
95
+ if file.endswith("_subset.gpkg"):
96
+ gpkg_file = os.path.join(root, file)
97
+ break
98
+
99
+ if gpkg_file is None:
100
+ raise FileNotFoundError("No subset.gpkg file found in folder")
101
+
102
+ with sqlite3.connect(gpkg_file) as conn:
103
+ results = conn.execute(
104
+ "SELECT id, gage FROM 'flowpath-attributes' WHERE gage IS NOT NULL"
105
+ ).fetchall()
106
+ return results
107
+
108
+
109
+ def get_simulation_output(wb_id, folder_to_eval):
110
+ nc_file = folder_to_eval / "outputs" / "troute" / "*.nc"
111
+ # find the nc file
112
+ nc_files = glob.glob(str(nc_file))
113
+ if len(nc_files) == 0:
114
+ raise FileNotFoundError("No netcdf file found in the outputs/troute folder")
115
+ if len(nc_files) > 1:
116
+ logger.warning("Multiple netcdf files found in the outputs/troute folder")
117
+ logger.warning("Using the most recent file")
118
+ nc_files.sort(key=os.path.getmtime)
119
+ file_to_open = nc_files[-1]
120
+ if len(nc_files) == 1:
121
+ file_to_open = nc_files[0]
122
+ all_output = xr.open_dataset(file_to_open)
123
+ print(all_output)
124
+ id_stem = wb_id.split("-")[1]
125
+ gage_output = all_output.sel(feature_id=int(id_stem))
126
+ gage_output = gage_output.drop_vars(["type", "velocity", "depth", "nudge", "feature_id"])
127
+ gage_output = gage_output.to_dataframe()
128
+ print(gage_output)
129
+ return gage_output.reset_index()
130
+
131
+
132
+ def get_simulation_start_end_time(folder_to_eval):
133
+ realization = folder_to_eval / "config" / "realization.json"
134
+ with open(realization) as f:
135
+ realization = json.load(f)
136
+ start = realization["time"]["start_time"]
137
+ end = realization["time"]["end_time"]
138
+ return start, end
139
+
140
+
141
+ class ColoredFormatter(logging.Formatter):
142
+ def format(self, record):
143
+ message = super().format(record)
144
+ message = message.replace("<module>", "main")
145
+ time = message.split(" - ")[0] + " - "
146
+ rest_of_message = " - ".join(message.split(" - ")[1:])
147
+ if record.levelno == logging.DEBUG:
148
+ return f"{time}{Fore.BLUE}{rest_of_message}{Style.RESET_ALL}"
149
+ if record.levelno == logging.WARNING:
150
+ return f"{time}{Fore.YELLOW}{rest_of_message}{Style.RESET_ALL}"
151
+ if record.levelno == logging.INFO:
152
+ return f"{time}{Fore.GREEN}{rest_of_message}{Style.RESET_ALL}"
153
+ return message
154
+
155
+
156
+ def plot_streamflow(output_folder, df, gage):
157
+ try:
158
+ import seaborn as sns
159
+ import matplotlib
160
+
161
+ # use Agg backend for headless plotting
162
+ matplotlib.use("Agg")
163
+ import matplotlib.pyplot as plt
164
+ except ImportError:
165
+ raise ImportError(
166
+ "Seaborn and matplotlib are required for plotting, please pip install ngiab_eval[plot]"
167
+ )
168
+ plot_folder = Path(output_folder) / "eval" / "plots"
169
+ plot_folder.mkdir(exist_ok=True, parents=True)
170
+ output_image = plot_folder / f"gage-{gage}_streamflow.png"
171
+
172
+ sns.set_style("whitegrid")
173
+ fig, ax = plt.subplots(figsize=(12, 6))
174
+
175
+ for source in ["NWM", "USGS", "NGEN"]:
176
+ sns.lineplot(x="time", y=source, data=df, label=source, ax=ax)
177
+
178
+ ax.set(title=f"Streamflow for {gage}", xlabel="Time", ylabel="Streamflow (m³ s⁻¹)")
179
+ ax.legend(title="Source")
180
+ plt.xticks(rotation=45, ha="right")
181
+ plt.tight_layout()
182
+ plt.savefig(output_image)
183
+ plt.close(fig)
184
+
185
+
186
+ def get_usgs_data(gage, start_time, end_time, cache_path):
187
+ service = IVDataService(cache_filename=cache_path)
188
+ logger.info(f"Downloading USGS data for {gage}")
189
+ usgs_data = service.get(sites=gage, startDT=start_time, endDT=end_time)
190
+ return usgs_data
191
+
192
+ def evaluate_gage(
193
+ gage_wb_pair,
194
+ cache_path,
195
+ start_time,
196
+ end_time,
197
+ folder_to_eval,
198
+ eval_output_folder,
199
+ plot=False,
200
+ debug=False,
201
+ ):
202
+ gage = gage_wb_pair[0]
203
+ wb_id = gage_wb_pair[1]
204
+ usgs_data = get_usgs_data(gage, start_time, end_time, cache_path)
205
+ if usgs_data.empty:
206
+ logger.warning(f"No data found for {gage} between {start_time} and {end_time}")
207
+ time.sleep(2)
208
+ return
209
+ logger.info(f"Downloading NWM data for {gage}")
210
+ nwm_data = check_local_cache(
211
+ gage, start_time, end_time, cache_folder=eval_output_folder / "nwm_cache"
212
+ )
213
+ logger.debug(f"Downloaded NWM data for {gage}")
214
+ logger.info(f"Getting simulation output for {gage}")
215
+ simulation_output = get_simulation_output(wb_id, folder_to_eval)
216
+ logger.debug(f"Got simulation output for {gage}")
217
+ logger.debug(f"Merging simulation and gage data for {gage}")
218
+ new_df = pd.merge(
219
+ simulation_output,
220
+ usgs_data,
221
+ left_on="time",
222
+ right_on="value_time",
223
+ how="inner",
224
+ )
225
+ logger.debug(f"Merged in nwm data for {gage}")
226
+ new_df = pd.merge(new_df, nwm_data, left_on="time", right_on="time", how="inner")
227
+ logger.debug(f"Merging complete for {gage}")
228
+ new_df = new_df.dropna()
229
+ # drop everything except the columns we want
230
+ new_df = new_df[["time", "flow", "value", "streamflow"]]
231
+ new_df.columns = ["time", "NGEN", "USGS", "NWM"]
232
+ print(new_df)
233
+ # convert USGS to cms
234
+ new_df["USGS"] = new_df["USGS"] * 0.0283168
235
+ logger.info(f"Calculating NSE and KGE for {gage}")
236
+ nwm_nse = he.evaluator(he.nse, new_df["NWM"], new_df["USGS"])
237
+ ngen_nse = he.evaluator(he.nse, new_df["NGEN"], new_df["USGS"])
238
+ nwm_kge = he.evaluator(he.kge, new_df["NWM"], new_df["USGS"])
239
+ ngen_kge = he.evaluator(he.kge, new_df["NGEN"], new_df["USGS"])
240
+ nwm_pbias = he.evaluator(he.pbias, new_df["NWM"], new_df["USGS"])
241
+ ngen_pbias = he.evaluator(he.pbias, new_df["NGEN"], new_df["USGS"])
242
+
243
+ write_output(
244
+ eval_output_folder, gage, nwm_nse, nwm_kge, nwm_pbias, ngen_nse, ngen_kge, ngen_pbias
245
+ )
246
+
247
+
248
+ debug_output = eval_output_folder / "debug"
249
+ debug_output.mkdir(exist_ok=True)
250
+ new_df.to_csv(debug_output / f"streamflow_at_{gage}.csv")
251
+ write_streamflow_to_sqlite(new_df, gage, eval_output_folder)
252
+
253
+ if plot:
254
+ logger.info(f"plotting streamflow for {gage}")
255
+ plot_streamflow(folder_to_eval, new_df, gage)
256
+
257
+ logger.info(f"Finished processing {gage}")
258
+
259
+
260
+ def evaluate_folder(folder_to_eval: Path, plot: bool = False, debug: bool = False) -> None:
261
+ if not folder_to_eval.exists():
262
+ raise FileNotFoundError(f"Folder {folder_to_eval} does not exist")
263
+
264
+ if debug:
265
+ global logger
266
+ logger.setLevel(logging.DEBUG)
267
+
268
+ eval_output_folder = folder_to_eval / "eval"
269
+ eval_output_folder.mkdir(exist_ok=True)
270
+
271
+ logger.info("Getting gages from hydrofabric")
272
+ wb_gage_pairs = get_gages_from_hydrofabric(folder_to_eval)
273
+ all_gages = {}
274
+ for wb_id, g in wb_gage_pairs:
275
+ gages = g.split(",")
276
+ for gage in gages:
277
+ if gage in feature_ids:
278
+ all_gages[gage] = wb_id
279
+
280
+ logger.info(f"Found {len(all_gages)} gages in the hydrofabric")
281
+ logger.debug(f"getting simulation start and end time")
282
+ start_time, end_time = get_simulation_start_end_time(folder_to_eval)
283
+ logger.info(f"Simulation start time: {start_time}, end time: {end_time}")
284
+ cache_path = eval_output_folder / "nwisiv_cache.sqlite"
285
+
286
+ evaluate_gage_partial = partial(
287
+ evaluate_gage,
288
+ cache_path=cache_path,
289
+ start_time=start_time,
290
+ end_time=end_time,
291
+ folder_to_eval=folder_to_eval,
292
+ eval_output_folder=eval_output_folder,
293
+ plot=plot,
294
+ debug=debug,
295
+ )
296
+
297
+ try:
298
+ client = Client.current()
299
+ except ValueError:
300
+ cluster = LocalCluster()
301
+ client = Client(cluster)
302
+
303
+ for gage in all_gages.items():
304
+ evaluate_gage_partial(gage)
305
+
306
+ logger.info("Finished evaluation")