ngiab_eval 0.1.1__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.
ngiab_eval/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ from .core import evaluate_folder
2
+ from .logging_config import setup_logging
ngiab_eval/__main__.py ADDED
@@ -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)
ngiab_eval/core.py ADDED
@@ -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")