easysnowdata 0.0.24__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.
- easysnowdata/__init__.py +23 -0
- easysnowdata/automatic_weather_stations.py +384 -0
- easysnowdata/hydroclimatology.py +1234 -0
- easysnowdata/remote_sensing.py +3430 -0
- easysnowdata/topography.py +175 -0
- easysnowdata/utils.py +448 -0
- easysnowdata-0.0.24.dist-info/METADATA +180 -0
- easysnowdata-0.0.24.dist-info/RECORD +11 -0
- easysnowdata-0.0.24.dist-info/WHEEL +5 -0
- easysnowdata-0.0.24.dist-info/licenses/LICENSE +21 -0
- easysnowdata-0.0.24.dist-info/top_level.txt +1 -0
easysnowdata/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""easysnowdata — easily retrieve data relevant to snow science."""
|
|
2
|
+
|
|
3
|
+
__author__ = "Eric Gagliano"
|
|
4
|
+
__email__ = "egagli@uw.edu"
|
|
5
|
+
__version__ = "0.0.24"
|
|
6
|
+
__all__ = [
|
|
7
|
+
"utils",
|
|
8
|
+
"remote_sensing",
|
|
9
|
+
"automatic_weather_stations",
|
|
10
|
+
"topography",
|
|
11
|
+
"hydroclimatology",
|
|
12
|
+
"authenticate_all",
|
|
13
|
+
"CredentialError",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
import easysnowdata.utils
|
|
17
|
+
import easysnowdata.remote_sensing
|
|
18
|
+
import easysnowdata.automatic_weather_stations
|
|
19
|
+
import easysnowdata.topography
|
|
20
|
+
import easysnowdata.hydroclimatology
|
|
21
|
+
|
|
22
|
+
from easysnowdata.utils import CredentialError
|
|
23
|
+
from easysnowdata.remote_sensing import authenticate_all
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Access SNOTEL and CCSS automatic weather station data.
|
|
2
|
+
|
|
3
|
+
Data are hosted on the companion repository
|
|
4
|
+
`egagli/snotel_ccss_stations <https://github.com/egagli/snotel_ccss_stations>`_
|
|
5
|
+
and retrieved as individual CSV files or as a single compressed archive.
|
|
6
|
+
|
|
7
|
+
References
|
|
8
|
+
----------
|
|
9
|
+
- SNOTEL: https://www.nrcs.usda.gov/wps/portal/wcc/home/quicklinks/imap
|
|
10
|
+
- CCSS: https://cdec.water.ca.gov/snow/current/snow/
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime
|
|
16
|
+
import glob
|
|
17
|
+
import logging
|
|
18
|
+
import pathlib
|
|
19
|
+
import subprocess
|
|
20
|
+
|
|
21
|
+
import geopandas as gpd
|
|
22
|
+
import numpy as np
|
|
23
|
+
import pandas as pd
|
|
24
|
+
import tqdm
|
|
25
|
+
import xarray as xr
|
|
26
|
+
|
|
27
|
+
from easysnowdata.utils import (
|
|
28
|
+
convert_bbox_to_geodataframe,
|
|
29
|
+
datetime_to_DOWY,
|
|
30
|
+
datetime_to_WY,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = ["StationCollection"]
|
|
34
|
+
|
|
35
|
+
_logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
_STATION_GEOJSON_URL = (
|
|
38
|
+
"https://github.com/egagli/snotel_ccss_stations/raw/main/all_stations.geojson"
|
|
39
|
+
)
|
|
40
|
+
_STATION_DATA_BASE_URL = (
|
|
41
|
+
"https://raw.githubusercontent.com/egagli/snotel_ccss_stations/main/data/"
|
|
42
|
+
)
|
|
43
|
+
_ARCHIVE_URL = "https://github.com/egagli/snotel_ccss_stations/raw/main/data/all_station_data.tar.lzma"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StationCollection:
|
|
47
|
+
"""A collection of SNOTEL and CCSS automatic weather stations.
|
|
48
|
+
|
|
49
|
+
Retrieves station metadata and time-series data from the
|
|
50
|
+
`egagli/snotel_ccss_stations <https://github.com/egagli/snotel_ccss_stations>`_
|
|
51
|
+
GitHub repository. Outputs are pandas DataFrames (single station) or
|
|
52
|
+
xarray Datasets (multiple stations).
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
data_available : bool, optional
|
|
57
|
+
If ``True`` (default), only include stations that have CSV data files.
|
|
58
|
+
sortby_dist_to_geom : GeoDataFrame or tuple or shapely geometry, optional
|
|
59
|
+
If provided, stations are sorted by distance to this geometry and a
|
|
60
|
+
``dist_km`` column is added to ``all_stations``.
|
|
61
|
+
|
|
62
|
+
Attributes
|
|
63
|
+
----------
|
|
64
|
+
all_stations : geopandas.GeoDataFrame
|
|
65
|
+
All stations matching the filter criteria, indexed by station code.
|
|
66
|
+
stations : geopandas.GeoDataFrame or None
|
|
67
|
+
The subset selected by the most recent :meth:`choose_stations` call.
|
|
68
|
+
data : pandas.DataFrame or xarray.Dataset or None
|
|
69
|
+
Data returned by the most recent :meth:`get_data` call.
|
|
70
|
+
entire_data_archive : xarray.Dataset or None
|
|
71
|
+
Full dataset returned by :meth:`get_entire_data_archive`.
|
|
72
|
+
|
|
73
|
+
Examples
|
|
74
|
+
--------
|
|
75
|
+
Single-station retrieval (returns a DataFrame):
|
|
76
|
+
|
|
77
|
+
>>> sc = StationCollection()
|
|
78
|
+
>>> sc.get_data(stations="679_WA_SNTL", variables=["WTEQ", "SNWD"],
|
|
79
|
+
... start_date="2020-10-01", end_date="2021-09-30")
|
|
80
|
+
>>> sc.data.head()
|
|
81
|
+
|
|
82
|
+
Multi-station retrieval (returns an xarray Dataset):
|
|
83
|
+
|
|
84
|
+
>>> sc = StationCollection()
|
|
85
|
+
>>> sc.get_data(stations=["679_WA_SNTL", "680_WA_SNTL"],
|
|
86
|
+
... variables=["WTEQ"],
|
|
87
|
+
... start_date="2022-01-01", end_date="2022-03-31")
|
|
88
|
+
>>> sc.data
|
|
89
|
+
|
|
90
|
+
Notes
|
|
91
|
+
-----
|
|
92
|
+
Available variables: ``WTEQ`` (SWE), ``SNWD`` (snow depth),
|
|
93
|
+
``PRCPSA`` (accumulated precipitation), ``TAVG``, ``TMIN``, ``TMAX``.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
data_available: bool = True,
|
|
99
|
+
sortby_dist_to_geom: gpd.GeoDataFrame | tuple | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
self.data_available = data_available
|
|
102
|
+
self.sortby_dist_to_geom = sortby_dist_to_geom
|
|
103
|
+
|
|
104
|
+
self.all_stations: gpd.GeoDataFrame | None = None
|
|
105
|
+
self.stations: gpd.GeoDataFrame | None = None
|
|
106
|
+
self.data: pd.DataFrame | xr.Dataset | None = None
|
|
107
|
+
self.entire_data_archive: xr.Dataset | None = None
|
|
108
|
+
|
|
109
|
+
# Per-variable DataFrames populated by get_multiple_station_data
|
|
110
|
+
self.TAVG: pd.DataFrame | None = None
|
|
111
|
+
self.TMIN: pd.DataFrame | None = None
|
|
112
|
+
self.TMAX: pd.DataFrame | None = None
|
|
113
|
+
self.SNWD: pd.DataFrame | None = None
|
|
114
|
+
self.WTEQ: pd.DataFrame | None = None
|
|
115
|
+
self.PRCPSA: pd.DataFrame | None = None
|
|
116
|
+
|
|
117
|
+
self.get_all_stations()
|
|
118
|
+
|
|
119
|
+
def get_all_stations(self) -> None:
|
|
120
|
+
"""Fetch all station metadata from GitHub and populate ``all_stations``.
|
|
121
|
+
|
|
122
|
+
Optionally filters to stations with data files and sorts by distance
|
|
123
|
+
to ``sortby_dist_to_geom`` if provided.
|
|
124
|
+
|
|
125
|
+
Returns
|
|
126
|
+
-------
|
|
127
|
+
None
|
|
128
|
+
Sets ``self.all_stations``.
|
|
129
|
+
"""
|
|
130
|
+
all_stations_gdf = gpd.read_file(_STATION_GEOJSON_URL).set_index("code")
|
|
131
|
+
|
|
132
|
+
if self.data_available:
|
|
133
|
+
all_stations_gdf = all_stations_gdf[all_stations_gdf["csvData"]]
|
|
134
|
+
|
|
135
|
+
if self.sortby_dist_to_geom is not None:
|
|
136
|
+
_logger.info("Sorting stations by distance to provided geometry.")
|
|
137
|
+
geom_gdf = convert_bbox_to_geodataframe(self.sortby_dist_to_geom)
|
|
138
|
+
proj = "EPSG:32611"
|
|
139
|
+
all_stations_gdf["dist_km"] = (
|
|
140
|
+
all_stations_gdf.to_crs(proj).distance(
|
|
141
|
+
geom_gdf.to_crs(proj).geometry.iloc[0]
|
|
142
|
+
)
|
|
143
|
+
/ 1000
|
|
144
|
+
)
|
|
145
|
+
all_stations_gdf = all_stations_gdf.sort_values("dist_km")
|
|
146
|
+
|
|
147
|
+
self.all_stations = all_stations_gdf
|
|
148
|
+
_logger.info("Loaded %d stations into all_stations.", len(self.all_stations))
|
|
149
|
+
|
|
150
|
+
def choose_stations(self, stations_input: gpd.GeoDataFrame | str | list) -> None:
|
|
151
|
+
"""Select a subset of stations by code string, list of codes, or GeoDataFrame.
|
|
152
|
+
|
|
153
|
+
Parameters
|
|
154
|
+
----------
|
|
155
|
+
stations_input : str, list of str, or geopandas.GeoDataFrame
|
|
156
|
+
Station code(s) to select (e.g. ``"679_WA_SNTL"`` or
|
|
157
|
+
``["679_WA_SNTL", "680_WA_SNTL"]``), or a GeoDataFrame already
|
|
158
|
+
filtered from ``all_stations``.
|
|
159
|
+
|
|
160
|
+
Returns
|
|
161
|
+
-------
|
|
162
|
+
None
|
|
163
|
+
Sets ``self.stations``.
|
|
164
|
+
"""
|
|
165
|
+
if isinstance(stations_input, str):
|
|
166
|
+
self.stations = self.all_stations.loc[[stations_input]]
|
|
167
|
+
elif isinstance(stations_input, list):
|
|
168
|
+
self.stations = self.all_stations.loc[stations_input]
|
|
169
|
+
else:
|
|
170
|
+
self.stations = stations_input
|
|
171
|
+
|
|
172
|
+
def get_data(
|
|
173
|
+
self,
|
|
174
|
+
stations: gpd.GeoDataFrame | str | list = "679_WA_SNTL",
|
|
175
|
+
variables: str | list | None = None,
|
|
176
|
+
start_date: str = "1900-01-01",
|
|
177
|
+
end_date: str | None = None,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Fetch data for the given stations and variables.
|
|
180
|
+
|
|
181
|
+
Dispatches to :meth:`get_single_station_data` or
|
|
182
|
+
:meth:`get_multiple_station_data` based on the number of stations
|
|
183
|
+
selected.
|
|
184
|
+
|
|
185
|
+
Parameters
|
|
186
|
+
----------
|
|
187
|
+
stations : str, list of str, or GeoDataFrame, optional
|
|
188
|
+
Station code(s) to fetch. Default is ``"679_WA_SNTL"``
|
|
189
|
+
(Paradise, WA SNOTEL).
|
|
190
|
+
variables : str or list of str, optional
|
|
191
|
+
Variable(s) to fetch. Defaults to all variables for a single
|
|
192
|
+
station, or ``WTEQ`` for multiple stations.
|
|
193
|
+
start_date : str, optional
|
|
194
|
+
ISO date string ``"YYYY-MM-DD"``. Default is ``"1900-01-01"``.
|
|
195
|
+
end_date : str, optional
|
|
196
|
+
ISO date string. Default is today's date.
|
|
197
|
+
|
|
198
|
+
Returns
|
|
199
|
+
-------
|
|
200
|
+
None
|
|
201
|
+
Sets ``self.data``.
|
|
202
|
+
"""
|
|
203
|
+
if end_date is None:
|
|
204
|
+
end_date = datetime.datetime.now().strftime("%Y-%m-%d")
|
|
205
|
+
|
|
206
|
+
self.choose_stations(stations)
|
|
207
|
+
|
|
208
|
+
if len(self.stations) == 1:
|
|
209
|
+
self.get_single_station_data(
|
|
210
|
+
variables=variables, start_date=start_date, end_date=end_date
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
if variables is None:
|
|
214
|
+
_logger.info(
|
|
215
|
+
"Multiple stations chosen with variables=None — defaulting to WTEQ."
|
|
216
|
+
)
|
|
217
|
+
self.get_multiple_station_data(
|
|
218
|
+
variables=variables or "WTEQ",
|
|
219
|
+
start_date=start_date,
|
|
220
|
+
end_date=end_date,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def get_single_station_data(
|
|
224
|
+
self,
|
|
225
|
+
variables: list[str] | None = None,
|
|
226
|
+
start_date: str = "1900-01-01",
|
|
227
|
+
end_date: str | None = None,
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Fetch all (or selected) variables for the currently selected single station.
|
|
230
|
+
|
|
231
|
+
Parameters
|
|
232
|
+
----------
|
|
233
|
+
variables : list of str, optional
|
|
234
|
+
Variable columns to keep. Defaults to all available variables.
|
|
235
|
+
start_date : str, optional
|
|
236
|
+
ISO date string. Default ``"1900-01-01"``.
|
|
237
|
+
end_date : str, optional
|
|
238
|
+
ISO date string. Defaults to today.
|
|
239
|
+
|
|
240
|
+
Returns
|
|
241
|
+
-------
|
|
242
|
+
None
|
|
243
|
+
Sets ``self.data`` to a :class:`pandas.DataFrame`.
|
|
244
|
+
"""
|
|
245
|
+
if end_date is None:
|
|
246
|
+
end_date = datetime.datetime.now().strftime("%Y-%m-%d")
|
|
247
|
+
if variables is None:
|
|
248
|
+
variables = ["WTEQ", "SNWD", "PRCPSA", "TAVG", "TMIN", "TMAX"]
|
|
249
|
+
|
|
250
|
+
station_code = self.stations.index[0]
|
|
251
|
+
url = f"{_STATION_DATA_BASE_URL}{station_code}.csv"
|
|
252
|
+
df = pd.read_csv(url, index_col="datetime", parse_dates=True)
|
|
253
|
+
|
|
254
|
+
drop_cols = [c for c in df.columns if c not in variables]
|
|
255
|
+
self.data = df.drop(columns=drop_cols).loc[start_date:end_date]
|
|
256
|
+
_logger.info("Loaded data for station %s.", station_code)
|
|
257
|
+
|
|
258
|
+
def get_multiple_station_data(
|
|
259
|
+
self,
|
|
260
|
+
variables: str | list[str] = "WTEQ",
|
|
261
|
+
start_date: str = "1900-01-01",
|
|
262
|
+
end_date: str | None = None,
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Fetch one or more variables for all currently selected stations.
|
|
265
|
+
|
|
266
|
+
Parameters
|
|
267
|
+
----------
|
|
268
|
+
variables : str or list of str, optional
|
|
269
|
+
Variable(s) to retrieve. Default is ``"WTEQ"``.
|
|
270
|
+
start_date : str, optional
|
|
271
|
+
ISO date string. Default ``"1900-01-01"``.
|
|
272
|
+
end_date : str, optional
|
|
273
|
+
ISO date string. Defaults to today.
|
|
274
|
+
|
|
275
|
+
Returns
|
|
276
|
+
-------
|
|
277
|
+
None
|
|
278
|
+
Sets ``self.data`` to an :class:`xarray.Dataset` with water-year
|
|
279
|
+
coordinates ``WY`` and ``DOWY``.
|
|
280
|
+
"""
|
|
281
|
+
if end_date is None:
|
|
282
|
+
end_date = datetime.datetime.now().strftime("%Y-%m-%d")
|
|
283
|
+
if isinstance(variables, str):
|
|
284
|
+
variables = [variables]
|
|
285
|
+
|
|
286
|
+
dataarrays = []
|
|
287
|
+
for variable in variables:
|
|
288
|
+
station_dict: dict[str, pd.Series] = {}
|
|
289
|
+
for station in tqdm.tqdm(self.stations.index, desc=variable):
|
|
290
|
+
try:
|
|
291
|
+
url = f"{_STATION_DATA_BASE_URL}{station}.csv"
|
|
292
|
+
tmp = pd.read_csv(url, index_col="datetime", parse_dates=True)[
|
|
293
|
+
variable
|
|
294
|
+
]
|
|
295
|
+
station_dict[station] = tmp
|
|
296
|
+
except Exception as exc:
|
|
297
|
+
_logger.warning(
|
|
298
|
+
"Failed to retrieve %s for %s: %s", variable, station, exc
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
station_df = pd.DataFrame.from_dict(station_dict).loc[start_date:end_date]
|
|
302
|
+
setattr(self, variable, station_df)
|
|
303
|
+
|
|
304
|
+
da = (
|
|
305
|
+
station_df.to_xarray()
|
|
306
|
+
.to_dataarray(dim="station")
|
|
307
|
+
.rename(variable)
|
|
308
|
+
.rename({"datetime": "time"})
|
|
309
|
+
)
|
|
310
|
+
dataarrays.append(da)
|
|
311
|
+
|
|
312
|
+
ds = xr.merge(dataarrays)
|
|
313
|
+
|
|
314
|
+
for col in self.stations.columns:
|
|
315
|
+
ds = ds.assign_coords({col: ("station", self.stations[col])})
|
|
316
|
+
|
|
317
|
+
ds["time"] = pd.to_datetime(ds.time)
|
|
318
|
+
ds.coords["WY"] = ("time", pd.to_datetime(ds.time).map(datetime_to_WY))
|
|
319
|
+
ds.coords["DOWY"] = ("time", pd.to_datetime(ds.time).map(datetime_to_DOWY))
|
|
320
|
+
|
|
321
|
+
self.data = ds
|
|
322
|
+
_logger.info("Loaded %s for %d stations.", variables, len(self.stations))
|
|
323
|
+
|
|
324
|
+
def get_entire_data_archive(
|
|
325
|
+
self, refresh: bool = True, temp_dir: str = "/tmp/"
|
|
326
|
+
) -> xr.Dataset:
|
|
327
|
+
"""Download, decompress, and assemble the full station data archive.
|
|
328
|
+
|
|
329
|
+
Parameters
|
|
330
|
+
----------
|
|
331
|
+
refresh : bool, optional
|
|
332
|
+
Re-download the archive even if it already exists locally.
|
|
333
|
+
Default is ``True``.
|
|
334
|
+
temp_dir : str, optional
|
|
335
|
+
Local directory for the downloaded archive. Default is ``"/tmp/"``.
|
|
336
|
+
|
|
337
|
+
Returns
|
|
338
|
+
-------
|
|
339
|
+
xarray.Dataset
|
|
340
|
+
All variables for all stations with ``WY`` and ``DOWY`` coordinates.
|
|
341
|
+
Also stored as ``self.entire_data_archive``.
|
|
342
|
+
|
|
343
|
+
Notes
|
|
344
|
+
-----
|
|
345
|
+
The compressed archive is ~several hundred MB; allow a few minutes for
|
|
346
|
+
download and decompression on first run.
|
|
347
|
+
"""
|
|
348
|
+
compressed_path = pathlib.Path(temp_dir, "all_station_data.tar.lzma")
|
|
349
|
+
decompressed_dir = pathlib.Path(temp_dir, "data")
|
|
350
|
+
|
|
351
|
+
if not compressed_path.exists() or refresh:
|
|
352
|
+
_logger.info("Downloading archive to %s …", compressed_path)
|
|
353
|
+
subprocess.run(["wget", "-q", "-P", temp_dir, _ARCHIVE_URL], check=True)
|
|
354
|
+
|
|
355
|
+
if not decompressed_dir.exists() or refresh:
|
|
356
|
+
_logger.info("Decompressing archive …")
|
|
357
|
+
subprocess.run(
|
|
358
|
+
["tar", "--lzma", "-xf", str(compressed_path), "-C", temp_dir],
|
|
359
|
+
check=True,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
_logger.info("Building xarray.Dataset from decompressed CSVs …")
|
|
363
|
+
datasets = []
|
|
364
|
+
for csv_file in glob.glob(str(decompressed_dir / "*.csv")):
|
|
365
|
+
station_name = pathlib.Path(csv_file).stem
|
|
366
|
+
df = (
|
|
367
|
+
pd.read_csv(csv_file, parse_dates=True)
|
|
368
|
+
.rename(columns={"datetime": "time"})
|
|
369
|
+
.set_index("time")
|
|
370
|
+
.sort_index()
|
|
371
|
+
)
|
|
372
|
+
station_ds = df.to_xarray().assign_coords(station=station_name)
|
|
373
|
+
for col in self.all_stations.columns:
|
|
374
|
+
station_ds.coords[col] = self.all_stations.loc[station_name, col]
|
|
375
|
+
datasets.append(station_ds)
|
|
376
|
+
|
|
377
|
+
ds = xr.concat(datasets, dim="station", coords="all")
|
|
378
|
+
ds["time"] = pd.to_datetime(ds.time)
|
|
379
|
+
ds.coords["WY"] = ("time", pd.to_datetime(ds.time).map(datetime_to_WY))
|
|
380
|
+
ds.coords["DOWY"] = ("time", pd.to_datetime(ds.time).map(datetime_to_DOWY))
|
|
381
|
+
|
|
382
|
+
self.entire_data_archive = ds
|
|
383
|
+
_logger.info("Full archive loaded (%d stations).", len(datasets))
|
|
384
|
+
return ds
|