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.
@@ -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