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 +2 -0
- ngiab_eval/__main__.py +52 -0
- ngiab_eval/core.py +306 -0
- ngiab_eval/gage_to_feature_id.py +8662 -0
- ngiab_eval/logging_config.py +32 -0
- ngiab_eval/output_formatter.py +72 -0
- ngiab_eval-0.1.1.dist-info/METADATA +27 -0
- ngiab_eval-0.1.1.dist-info/RECORD +10 -0
- ngiab_eval-0.1.1.dist-info/WHEEL +5 -0
- ngiab_eval-0.1.1.dist-info/top_level.txt +1 -0
ngiab_eval/__init__.py
ADDED
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")
|