pastastore 1.6.1__tar.gz → 1.7.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.
Files changed (31) hide show
  1. {pastastore-1.6.1 → pastastore-1.7.1}/PKG-INFO +1 -1
  2. {pastastore-1.6.1 → pastastore-1.7.1}/pastastore/__init__.py +1 -1
  3. {pastastore-1.6.1 → pastastore-1.7.1}/pastastore/base.py +5 -0
  4. pastastore-1.7.1/pastastore/extensions/__init__.py +14 -0
  5. pastastore-1.7.1/pastastore/extensions/accessor.py +15 -0
  6. pastastore-1.7.1/pastastore/extensions/hpd.py +593 -0
  7. {pastastore-1.6.1 → pastastore-1.7.1}/pastastore/store.py +103 -34
  8. {pastastore-1.6.1 → pastastore-1.7.1}/pastastore/styling.py +39 -6
  9. {pastastore-1.6.1 → pastastore-1.7.1}/pastastore/version.py +1 -1
  10. {pastastore-1.6.1 → pastastore-1.7.1}/pastastore.egg-info/PKG-INFO +1 -1
  11. {pastastore-1.6.1 → pastastore-1.7.1}/pastastore.egg-info/SOURCES.txt +3 -0
  12. {pastastore-1.6.1 → pastastore-1.7.1}/pyproject.toml +1 -4
  13. {pastastore-1.6.1 → pastastore-1.7.1}/tests/test_003_pastastore.py +6 -1
  14. {pastastore-1.6.1 → pastastore-1.7.1}/LICENSE +0 -0
  15. {pastastore-1.6.1 → pastastore-1.7.1}/pastastore/connectors.py +0 -0
  16. {pastastore-1.6.1 → pastastore-1.7.1}/pastastore/datasets.py +0 -0
  17. {pastastore-1.6.1 → pastastore-1.7.1}/pastastore/plotting.py +0 -0
  18. {pastastore-1.6.1 → pastastore-1.7.1}/pastastore/util.py +0 -0
  19. {pastastore-1.6.1 → pastastore-1.7.1}/pastastore/yaml_interface.py +0 -0
  20. {pastastore-1.6.1 → pastastore-1.7.1}/pastastore.egg-info/dependency_links.txt +0 -0
  21. {pastastore-1.6.1 → pastastore-1.7.1}/pastastore.egg-info/requires.txt +0 -0
  22. {pastastore-1.6.1 → pastastore-1.7.1}/pastastore.egg-info/top_level.txt +0 -0
  23. {pastastore-1.6.1 → pastastore-1.7.1}/readme.md +0 -0
  24. {pastastore-1.6.1 → pastastore-1.7.1}/setup.cfg +0 -0
  25. {pastastore-1.6.1 → pastastore-1.7.1}/tests/test_001_import.py +0 -0
  26. {pastastore-1.6.1 → pastastore-1.7.1}/tests/test_002_connectors.py +0 -0
  27. {pastastore-1.6.1 → pastastore-1.7.1}/tests/test_004_yaml.py +0 -0
  28. {pastastore-1.6.1 → pastastore-1.7.1}/tests/test_005_maps_plots.py +0 -0
  29. {pastastore-1.6.1 → pastastore-1.7.1}/tests/test_006_benchmark.py +0 -0
  30. {pastastore-1.6.1 → pastastore-1.7.1}/tests/test_007_hpdextension.py +0 -0
  31. {pastastore-1.6.1 → pastastore-1.7.1}/tests/test_008_stressmodels.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pastastore
3
- Version: 1.6.1
3
+ Version: 1.7.1
4
4
  Summary: Tools for managing Pastas time series models.
5
5
  Author: D.A. Brakenhoff
6
6
  Maintainer-email: "D.A. Brakenhoff" <d.brakenhoff@artesia-water.nl>, "R. Calje" <r.calje@artesia-water.nl>, "M.A. Vonk" <m.vonk@artesia-water.nl>
@@ -1,5 +1,5 @@
1
1
  # ruff: noqa: F401 D104
2
- from pastastore import connectors, styling, util
2
+ from pastastore import connectors, extensions, styling, util
3
3
  from pastastore.connectors import (
4
4
  ArcticDBConnector,
5
5
  DictConnector,
@@ -56,6 +56,11 @@ class BaseConnector(ABC):
56
56
  f"{self.n_models} models"
57
57
  )
58
58
 
59
+ @property
60
+ def empty(self):
61
+ """Check if the database is empty."""
62
+ return not any([self.n_oseries > 0, self.n_stresses > 0, self.n_models > 0])
63
+
59
64
  @abstractmethod
60
65
  def _get_library(self, libname: str):
61
66
  """Get library handle.
@@ -0,0 +1,14 @@
1
+ # ruff: noqa: D104 F401
2
+ from pastastore.extensions.accessor import (
3
+ register_pastastore_accessor as register_pastastore_accessor,
4
+ )
5
+
6
+
7
+ def activate_hydropandas_extension():
8
+ """Register Plotly extension for pastas.Model class for interactive plotting."""
9
+ from pastastore.extensions.hpd import HydroPandasExtension as _
10
+
11
+ print(
12
+ "Registered HydroPandas extension in PastaStore class, "
13
+ "e.g. `pstore.hpd.download_bro_gmw()`."
14
+ )
@@ -0,0 +1,15 @@
1
+ # ruff: noqa: D100
2
+ from pastas.extensions.accessor import _register_accessor
3
+
4
+
5
+ def register_pastastore_accessor(name: str):
6
+ """Register an extension in the PastaStore class.
7
+
8
+ Parameters
9
+ ----------
10
+ name : str
11
+ name of the extension to register
12
+ """
13
+ from pastastore.store import PastaStore
14
+
15
+ return _register_accessor(name, PastaStore)
@@ -0,0 +1,593 @@
1
+ """HydroPandas extension for PastaStore.
2
+
3
+ Features:
4
+
5
+ - Add `hpd.Obs` and `hpd.ObsCollection` to PastaStore.
6
+ - Download and store meteorological data from KNMI or groundwater observations from BRO.
7
+ - Update currently stored (KNMI or BRO) time series from last observation to tmax.
8
+ """
9
+
10
+ import logging
11
+ from typing import List, Optional, Union
12
+
13
+ import hydropandas as hpd
14
+ import numpy as np
15
+ from hydropandas.io.knmi import _check_latest_measurement_date_de_bilt, get_stations
16
+ from pandas import DataFrame, Series, Timedelta, Timestamp
17
+ from pastas.timeseries_utils import timestep_weighted_resample
18
+ from tqdm.auto import tqdm
19
+
20
+ from pastastore.extensions.accessor import register_pastastore_accessor
21
+
22
+ logger = logging.getLogger("hydropandas_extension")
23
+
24
+
25
+ TimeType = Optional[Union[str, Timestamp]]
26
+
27
+
28
+ @register_pastastore_accessor("hpd")
29
+ class HydroPandasExtension:
30
+ """HydroPandas extension for PastaStore.
31
+
32
+ Parameters
33
+ ----------
34
+ store: pastastore.store.PastaStore
35
+ PastaStore object to extend with HydroPandas functionality
36
+ """
37
+
38
+ def __init__(self, store):
39
+ """Initialize HydroPandasExtenstion.
40
+
41
+ Parameters
42
+ ----------
43
+ store : pasta.store.PastaStore
44
+ PastaStore object to extend with HydroPandas functionality
45
+ """
46
+ self._store = store
47
+
48
+ def add_obscollection(
49
+ self,
50
+ libname: str,
51
+ oc: hpd.ObsCollection,
52
+ kind: Optional[str] = None,
53
+ data_column: Optional[str] = None,
54
+ unit_multiplier: float = 1.0,
55
+ update: bool = False,
56
+ normalize_datetime_index: bool = False,
57
+ ):
58
+ """Add an ObsCollection to the PastaStore.
59
+
60
+ Parameters
61
+ ----------
62
+ libname : str
63
+ Name of the library to add the ObsCollection to ["oseries", "stresses"].
64
+ oc : hpd.ObsCollection
65
+ ObsCollection to add to the store.
66
+ kind : str, optional
67
+ kind identifier for observations, by default None. Required for adding
68
+ stresses.
69
+ data_column : str, optional
70
+ name of column containing observation values, by default None.
71
+ unit_multiplier : float, optional
72
+ multiply unit by this value before saving it in the store
73
+ update : bool, optional
74
+ if True, update currently stored time series with new data
75
+ normalize_datetime_index : bool, optional
76
+ if True, normalize the datetime so stress value at midnight represents
77
+ the daily total, by default True.
78
+ """
79
+ for name, row in oc.iterrows():
80
+ obs = row["obs"]
81
+ # metadata = row.drop("obs").to_dict()
82
+ self.add_observation(
83
+ libname,
84
+ obs,
85
+ name=name,
86
+ kind=kind,
87
+ data_column=data_column,
88
+ unit_multiplier=unit_multiplier,
89
+ update=update,
90
+ normalize_datetime_index=normalize_datetime_index,
91
+ )
92
+
93
+ def add_observation(
94
+ self,
95
+ libname: str,
96
+ obs: hpd.Obs,
97
+ name: Optional[str] = None,
98
+ kind: Optional[str] = None,
99
+ data_column: Optional[str] = None,
100
+ unit_multiplier: float = 1.0,
101
+ update: bool = False,
102
+ normalize_datetime_index: bool = False,
103
+ ):
104
+ """Add an hydropandas observation series to the PastaStore.
105
+
106
+ Parameters
107
+ ----------
108
+ libname : str
109
+ Name of the library to add the observation to ["oseries", "stresses"].
110
+ obs : hpd.Obs
111
+ hydroPandas observation series to add to the store.
112
+ name : str, optional
113
+ Name of the observation, by default None. If None, the name of the
114
+ observation is used.
115
+ kind : str, optional
116
+ kind identifier for observations, by default None. Required for adding
117
+ stresses.
118
+ data_column : str, optional
119
+ name of column containing observation values, by default None.
120
+ unit_multiplier : float, optional
121
+ multiply unit by this value before saving it in the store
122
+ update : bool, optional
123
+ if True, update currently stored time series with new data
124
+ normalize_datetime_index : bool, optional
125
+ if True, normalize the datetime so stress value at midnight represents
126
+ the daily total, by default True.
127
+ """
128
+ # if data_column is not None, use data_column
129
+ if data_column is not None:
130
+ if not obs.empty:
131
+ o = obs[[data_column]]
132
+ else:
133
+ o = Series()
134
+ elif isinstance(obs, Series):
135
+ o = obs
136
+ # else raise error
137
+ elif isinstance(obs, DataFrame) and (obs.columns.size > 1):
138
+ raise ValueError("No data_column specified and obs has multiple columns.")
139
+ else:
140
+ raise TypeError("obs must be a Series or DataFrame with a single column.")
141
+
142
+ # break if obs is empty
143
+ if o.empty:
144
+ logger.info("Observation '%s' is empty, not adding to store.", name)
145
+ return
146
+
147
+ if normalize_datetime_index and o.index.size > 1:
148
+ o = self._normalize_datetime_index(o).iloc[1:] # remove first nan
149
+ elif normalize_datetime_index and o.index.size <= 1:
150
+ raise ValueError(
151
+ "Must have minimum of 2 observations for timestep_weighted_resample."
152
+ )
153
+
154
+ # gather metadata from obs object
155
+ metadata = {key: getattr(obs, key) for key in obs._metadata}
156
+
157
+ # convert np dtypes to builtins
158
+ for k, v in metadata.items():
159
+ if isinstance(v, np.integer):
160
+ metadata[k] = int(v)
161
+ elif isinstance(v, np.floating):
162
+ metadata[k] = float(v)
163
+
164
+ metadata.pop("name", None)
165
+ metadata.pop("meta", None)
166
+ unit = metadata.get("unit", None)
167
+ if unit == "m" and unit_multiplier == 1e3:
168
+ metadata["unit"] = "mm"
169
+ elif unit_multiplier != 1.0:
170
+ metadata["unit"] = f"{unit_multiplier:e}*{unit}"
171
+
172
+ source = metadata.get("source", "")
173
+ if len(source) > 0:
174
+ source = f"{source} "
175
+
176
+ if update:
177
+ action_msg = "updated in"
178
+ else:
179
+ action_msg = "added to"
180
+
181
+ if libname == "oseries":
182
+ self._store.upsert_oseries(o.squeeze(axis=1), name, metadata=metadata)
183
+ logger.info(
184
+ "%sobservation '%s' %s oseries library.", source, name, action_msg
185
+ )
186
+ elif libname == "stresses":
187
+ if kind is None:
188
+ raise ValueError("`kind` must be specified for stresses!")
189
+ self._store.upsert_stress(
190
+ (o * unit_multiplier).squeeze(axis=1), name, kind, metadata=metadata
191
+ )
192
+ logger.info(
193
+ "%sstress '%s' (kind='%s') %s stresses library.",
194
+ source,
195
+ name,
196
+ kind,
197
+ action_msg,
198
+ )
199
+ else:
200
+ raise ValueError("libname must be 'oseries' or 'stresses'.")
201
+
202
+ def download_knmi_precipitation(
203
+ self,
204
+ stns: Optional[list[int]] = None,
205
+ meteo_var: str = "RD",
206
+ tmin: TimeType = None,
207
+ tmax: TimeType = None,
208
+ unit_multiplier: float = 1e3,
209
+ fill_missing_obs: bool = True,
210
+ normalize_datetime_index: bool = True,
211
+ **kwargs,
212
+ ):
213
+ """Download precipitation data from KNMI and store in PastaStore.
214
+
215
+ Parameters
216
+ ----------
217
+ stns : list of int/str, optional
218
+ list of station numbers to download data for, by default None
219
+ meteo_var : str, optional
220
+ variable to download, by default "RD", valid options are ["RD", "RH"].
221
+ tmin : TimeType, optional
222
+ start time, by default None
223
+ tmax : TimeType, optional
224
+ end time, by default None
225
+ unit_multiplier : float, optional
226
+ multiply unit by this value before saving it in the store,
227
+ by default 1e3 to convert m to mm
228
+ """
229
+ self.download_knmi_meteo(
230
+ meteo_var=meteo_var,
231
+ kind="prec",
232
+ stns=stns,
233
+ tmin=tmin,
234
+ tmax=tmax,
235
+ unit_multiplier=unit_multiplier,
236
+ fill_missing_obs=fill_missing_obs,
237
+ normalize_datetime_index=normalize_datetime_index,
238
+ **kwargs,
239
+ )
240
+
241
+ def download_knmi_evaporation(
242
+ self,
243
+ stns: Optional[list[int]] = None,
244
+ meteo_var: str = "EV24",
245
+ tmin: TimeType = None,
246
+ tmax: TimeType = None,
247
+ unit_multiplier: float = 1e3,
248
+ fill_missing_obs: bool = True,
249
+ normalize_datetime_index: bool = True,
250
+ **kwargs,
251
+ ):
252
+ """Download evaporation data from KNMI and store in PastaStore.
253
+
254
+ Parameters
255
+ ----------
256
+ stns : list of int/str, optional
257
+ list of station numbers to download data for, by default None
258
+ meteo_var : str, optional
259
+ variable to download, by default "EV24"
260
+ tmin : TimeType, optional
261
+ start time, by default None
262
+ tmax : TimeType, optional
263
+ end time, by default None
264
+ unit_multiplier : float, optional
265
+ multiply unit by this value before saving it in the store,
266
+ by default 1e3 to convert m to mm
267
+ fill_missing_obs : bool, optional
268
+ if True, fill missing observations by getting observations from nearest
269
+ station with data.
270
+ normalize_datetime_index : bool, optional
271
+ if True, normalize the datetime so stress value at midnight represents
272
+ the daily total, by default True.
273
+ """
274
+ self.download_knmi_meteo(
275
+ meteo_var=meteo_var,
276
+ kind="evap",
277
+ stns=stns,
278
+ tmin=tmin,
279
+ tmax=tmax,
280
+ unit_multiplier=unit_multiplier,
281
+ fill_missing_obs=fill_missing_obs,
282
+ normalize_datetime_index=normalize_datetime_index,
283
+ **kwargs,
284
+ )
285
+
286
+ def download_knmi_meteo(
287
+ self,
288
+ meteo_var: str,
289
+ kind: str,
290
+ stns: Optional[list[int]] = None,
291
+ tmin: TimeType = None,
292
+ tmax: TimeType = None,
293
+ unit_multiplier: float = 1.0,
294
+ normalize_datetime_index: bool = True,
295
+ fill_missing_obs: bool = True,
296
+ **kwargs,
297
+ ):
298
+ """Download meteorological data from KNMI and store in PastaStore.
299
+
300
+ Parameters
301
+ ----------
302
+ meteo_var : str, optional
303
+ variable to download, by default "RH", valid options are
304
+ e.g. ["RD", "RH", "EV24", "T", "Q"].
305
+ kind : str
306
+ kind identifier for observations, usually "prec" or "evap".
307
+ stns : list of int/str, optional
308
+ list of station numbers to download data for, by default None
309
+ tmin : TimeType, optional
310
+ start time, by default None
311
+ tmax : TimeType, optional
312
+ end time, by default None
313
+ unit_multiplier : float, optional
314
+ multiply unit by this value before saving it in the store,
315
+ by default 1.0 (no conversion)
316
+ fill_missing_obs : bool, optional
317
+ if True, fill missing observations by getting observations from nearest
318
+ station with data.
319
+ normalize_datetime_index : bool, optional
320
+ if True, normalize the datetime so stress value at midnight represents
321
+ the daily total, by default True.
322
+ """
323
+ # get tmin/tmax if not specified
324
+ tmintmax = self._store.get_tmin_tmax("oseries")
325
+ if tmin is None:
326
+ tmin = tmintmax.loc[:, "tmin"].min() - Timedelta(days=10 * 365)
327
+ if tmax is None:
328
+ tmax = tmintmax.loc[:, "tmax"].max()
329
+
330
+ if stns is None:
331
+ locations = self._store.oseries.loc[:, ["x", "y"]]
332
+ else:
333
+ locations = None
334
+
335
+ # download data
336
+ knmi = hpd.read_knmi(
337
+ locations=locations,
338
+ stns=stns,
339
+ meteo_vars=[meteo_var],
340
+ starts=tmin,
341
+ ends=tmax,
342
+ fill_missing_obs=fill_missing_obs,
343
+ **kwargs,
344
+ )
345
+
346
+ # add to store
347
+ self.add_obscollection(
348
+ libname="stresses",
349
+ oc=knmi,
350
+ kind=kind,
351
+ data_column=meteo_var,
352
+ unit_multiplier=unit_multiplier,
353
+ update=False,
354
+ normalize_datetime_index=normalize_datetime_index,
355
+ )
356
+
357
+ def update_knmi_meteo(
358
+ self,
359
+ names: Optional[List[str]] = None,
360
+ tmin: TimeType = None,
361
+ tmax: TimeType = None,
362
+ fill_missing_obs: bool = True,
363
+ normalize_datetime_index: bool = True,
364
+ raise_on_error: bool = False,
365
+ **kwargs,
366
+ ):
367
+ """Update meteorological data from KNMI in PastaStore.
368
+
369
+ Parameters
370
+ ----------
371
+ names : list of str, optional
372
+ list of names of observations to update, by default None
373
+ tmin : TimeType, optional
374
+ start time, by default None, which uses current last observation timestamp
375
+ as tmin
376
+ tmax : TimeType, optional
377
+ end time, by default None, which defaults to today
378
+ fill_missing_obs : bool, optional
379
+ if True, fill missing observations by getting observations from nearest
380
+ station with data.
381
+ normalize_datetime_index : bool, optional
382
+ if True, normalize the datetime so stress value at midnight represents
383
+ the daily total, by default True.
384
+ raise_on_error : bool, optional
385
+ if True, raise error if an error occurs, by default False
386
+ **kwargs : dict, optional
387
+ Additional keyword arguments to pass to `hpd.read_knmi()`
388
+ """
389
+ if names is None:
390
+ names = self._store.stresses.loc[
391
+ self._store.stresses["source"] == "KNMI"
392
+ ].index.tolist()
393
+
394
+ tmintmax = self._store.get_tmin_tmax("stresses", names=names)
395
+
396
+ if tmax is not None:
397
+ if tmintmax["tmax"].min() >= Timestamp(tmax):
398
+ logger.info(f"All KNMI stresses are up to date till {tmax}.")
399
+ return
400
+
401
+ try:
402
+ maxtmax_rd = _check_latest_measurement_date_de_bilt("RD")
403
+ maxtmax_ev24 = _check_latest_measurement_date_de_bilt("EV24")
404
+ except Exception as e:
405
+ # otherwise use maxtmax 28 days (4 weeks) prior to today
406
+ logger.warning(
407
+ "Could not check latest measurement date in De Bilt: %s" % str(e)
408
+ )
409
+ maxtmax_rd = maxtmax_ev24 = Timestamp.today() - Timedelta(days=28)
410
+ logger.info(
411
+ "Using 28 days (4 weeks) prior to today as maxtmax: %s."
412
+ % str(maxtmax_rd)
413
+ )
414
+
415
+ for name in tqdm(names, desc="Updating KNMI meteo stresses"):
416
+ meteo_var = self._store.stresses.loc[name, "meteo_var"]
417
+ if meteo_var == "RD":
418
+ maxtmax = maxtmax_rd
419
+ elif meteo_var == "EV24":
420
+ maxtmax = maxtmax_ev24
421
+ else:
422
+ maxtmax = maxtmax_rd
423
+
424
+ # 1 days extra to ensure computation of daily totals using
425
+ # timestep_weighted_resample
426
+ if tmin is None:
427
+ itmin = tmintmax.loc[name, "tmax"] - Timedelta(days=1)
428
+ else:
429
+ itmin = tmin - Timedelta(days=1)
430
+
431
+ # ensure 2 observations at least
432
+ if itmin >= (maxtmax - Timedelta(days=1)):
433
+ logger.debug("KNMI %s is already up to date." % name)
434
+ continue
435
+
436
+ if tmax is None:
437
+ itmax = maxtmax
438
+ else:
439
+ itmax = Timestamp(tmax)
440
+
441
+ # fix for duplicate station entry in metadata:
442
+ stress_station = (
443
+ self._store.stresses.at[name, "station"]
444
+ if "station" in self._store.stresses.columns
445
+ else None
446
+ )
447
+ if stress_station is not None and not isinstance(
448
+ stress_station, (int, np.integer)
449
+ ):
450
+ stress_station = stress_station.squeeze().unique().item()
451
+
452
+ unit = self._store.stresses.loc[name, "unit"]
453
+ kind = self._store.stresses.loc[name, "kind"]
454
+ if stress_station is not None:
455
+ stn = stress_station
456
+ else:
457
+ stns = get_stations(meteo_var)
458
+ stn_name = name.split("_")[-1].lower()
459
+ mask = stns["name"].str.lower().str.replace(" ", "-") == stn_name
460
+ if not mask.any():
461
+ logger.warning(
462
+ "Station '%s' not found in list of KNMI %s stations."
463
+ % (stn_name, meteo_var)
464
+ )
465
+ continue
466
+ stn = stns.loc[mask].index[0]
467
+
468
+ if unit == "mm":
469
+ unit_multiplier = 1e3
470
+ else:
471
+ unit_multiplier = 1.0
472
+
473
+ logger.debug("Updating KNMI %s from %s to %s" % (name, itmin, itmax))
474
+ knmi = hpd.read_knmi(
475
+ stns=[stn],
476
+ meteo_vars=[meteo_var],
477
+ starts=itmin,
478
+ ends=itmax,
479
+ fill_missing_obs=fill_missing_obs,
480
+ **kwargs,
481
+ )
482
+ obs = knmi["obs"].iloc[0]
483
+
484
+ try:
485
+ self.add_observation(
486
+ "stresses",
487
+ obs,
488
+ name=name,
489
+ kind=kind,
490
+ data_column=meteo_var,
491
+ unit_multiplier=unit_multiplier,
492
+ update=True,
493
+ normalize_datetime_index=normalize_datetime_index,
494
+ )
495
+ except ValueError as e:
496
+ logger.error("Error updating KNMI %s: %s" % (name, str(e)))
497
+ if raise_on_error:
498
+ raise e
499
+
500
+ @staticmethod
501
+ def _normalize_datetime_index(obs):
502
+ """Normalize observation datetime index (i.e. set observation time to midnight).
503
+
504
+ Parameters
505
+ ----------
506
+ obs : pandas.Series
507
+ observation series to normalize
508
+
509
+ Returns
510
+ -------
511
+ hpd.Obs
512
+ observation series with normalized datetime index
513
+ """
514
+ if isinstance(obs, hpd.Obs):
515
+ metadata = {k: getattr(obs, k) for k in obs._metadata}
516
+ else:
517
+ metadata = {}
518
+ return obs.__class__(
519
+ timestep_weighted_resample(
520
+ obs,
521
+ obs.index.normalize(),
522
+ ).rename(obs.name),
523
+ **metadata,
524
+ )
525
+
526
+ def download_bro_gmw(
527
+ self,
528
+ extent: Optional[List[float]] = None,
529
+ tmin: TimeType = None,
530
+ tmax: TimeType = None,
531
+ update: bool = False,
532
+ **kwargs,
533
+ ):
534
+ """Download groundwater monitoring well observations from BRO.
535
+
536
+ Parameters
537
+ ----------
538
+ extent: tuple, optional
539
+ Extent of the area to download observations from.
540
+ tmin: pandas.Timestamp, optional
541
+ Start date of the observations to download.
542
+ tmax: pandas.Timestamp, optional
543
+ End date of the observations to download.
544
+ **kwargs: dict, optional
545
+ Additional keyword arguments to pass to `hpd.read_bro()`
546
+ """
547
+ bro = hpd.read_bro(
548
+ extent=extent,
549
+ tmin=tmin,
550
+ tmax=tmax,
551
+ **kwargs,
552
+ )
553
+ self.add_obscollection("oseries", bro, data_column="values", update=update)
554
+
555
+ def update_bro_gmw(
556
+ self,
557
+ names: Optional[List[str]] = None,
558
+ tmin: TimeType = None,
559
+ tmax: TimeType = None,
560
+ **kwargs,
561
+ ):
562
+ """Update groundwater monitoring well observations from BRO.
563
+
564
+ Parameters
565
+ ----------
566
+ names : list of str, optional
567
+ list of names of observations to update, by default None which updates all
568
+ stored oseries.
569
+ tmin : TimeType, optional
570
+ start time, by default None, which uses current last observation timestamp
571
+ as tmin
572
+ tmax : TimeType, optional
573
+ end time, by default None, which defaults to today
574
+ **kwargs : dict, optional
575
+ Additional keyword arguments to pass to `hpd.GroundwaterObs.from_bro()`
576
+ """
577
+ if names is None:
578
+ names = self._store.oseries.index.to_list()
579
+
580
+ tmintmax = self._store.get_tmin_tmax("oseries")
581
+
582
+ for obsnam in tqdm(names, desc="Updating BRO oseries"):
583
+ bro_id, tube_number = obsnam.split("_")
584
+
585
+ if tmin is None:
586
+ _, tmin = tmintmax.loc[obsnam] # tmin is stored tmax
587
+
588
+ obs = hpd.GroundwaterObs.from_bro(
589
+ bro_id, int(tube_number), tmin=tmin, tmax=tmax, **kwargs
590
+ )
591
+ self.add_observation(
592
+ "oseries", obs, name=obsnam, data_column="values", update=True
593
+ )
@@ -4,6 +4,8 @@ import json
4
4
  import logging
5
5
  import os
6
6
  import warnings
7
+ from concurrent.futures import ProcessPoolExecutor
8
+ from functools import partial
7
9
  from typing import Dict, List, Literal, Optional, Tuple, Union
8
10
 
9
11
  import numpy as np
@@ -12,6 +14,7 @@ import pastas as ps
12
14
  from packaging.version import parse as parse_version
13
15
  from pastas.io.pas import pastas_hook
14
16
  from tqdm.auto import tqdm
17
+ from tqdm.contrib.concurrent import process_map
15
18
 
16
19
  from pastastore.base import BaseConnector
17
20
  from pastastore.connectors import DictConnector
@@ -78,6 +81,11 @@ class PastaStore:
78
81
  self.plots = Plots(self)
79
82
  self.yaml = PastastoreYAML(self)
80
83
 
84
+ @property
85
+ def empty(self) -> bool:
86
+ """Check if the PastaStore is empty."""
87
+ return self.conn.empty
88
+
81
89
  def _register_connector_methods(self):
82
90
  """Register connector methods (internal method)."""
83
91
  methods = [
@@ -1175,18 +1183,19 @@ class PastaStore:
1175
1183
 
1176
1184
  def solve_models(
1177
1185
  self,
1178
- mls: Optional[Union[ps.Model, list, str]] = None,
1186
+ modelnames: Union[List[str], str, None] = None,
1179
1187
  report: bool = False,
1180
1188
  ignore_solve_errors: bool = False,
1181
- store_result: bool = True,
1182
1189
  progressbar: bool = True,
1190
+ parallel: bool = False,
1191
+ max_workers: Optional[int] = None,
1183
1192
  **kwargs,
1184
1193
  ) -> None:
1185
1194
  """Solves the models in the store.
1186
1195
 
1187
1196
  Parameters
1188
1197
  ----------
1189
- mls : list of str, optional
1198
+ modelnames : list of str, optional
1190
1199
  list of model names, if None all models in the pastastore
1191
1200
  are solved.
1192
1201
  report : boolean, optional
@@ -1196,43 +1205,103 @@ class PastaStore:
1196
1205
  if True, errors emerging from the solve method are ignored,
1197
1206
  default is False which will raise an exception when a model
1198
1207
  cannot be optimized
1199
- store_result : bool, optional
1200
- if True save optimized models, default is True
1201
1208
  progressbar : bool, optional
1202
- show progressbar, default is True
1203
- **kwargs :
1209
+ show progressbar, default is True.
1210
+ parallel: bool, optional
1211
+ if True, solve models in parallel using ProcessPoolExecutor
1212
+ max_workers: int, optional
1213
+ maximum number of workers to use in parallel solving, default is
1214
+ None which will use the number of cores available on the machine
1215
+ **kwargs : dictionary
1204
1216
  arguments are passed to the solve method.
1217
+
1218
+ Notes
1219
+ -----
1220
+ Users should be aware that parallel solving is platform dependent
1221
+ and may not always work. The current implementation works well for Linux users.
1222
+ For Windows users, parallel solving does not work when called directly from
1223
+ Jupyter Notebooks or IPython. To use parallel solving on Windows, the following
1224
+ code should be used in a Python file::
1225
+
1226
+ from multiprocessing import freeze_support
1227
+
1228
+ if __name__ == "__main__":
1229
+ freeze_support()
1230
+ pstore.solve_models(parallel=True)
1205
1231
  """
1206
- if mls is None:
1207
- mls = self.conn.model_names
1208
- elif isinstance(mls, ps.Model):
1209
- mls = [mls.name]
1232
+ if "mls" in kwargs:
1233
+ modelnames = kwargs.pop("mls")
1234
+ logger.warning("Argument `mls` is deprecated, use `modelnames` instead.")
1210
1235
 
1211
- desc = "Solving models"
1212
- for ml_name in tqdm(mls, desc=desc) if progressbar else mls:
1213
- ml = self.conn.get_models(ml_name)
1236
+ modelnames = self.conn._parse_names(modelnames, libname="models")
1214
1237
 
1215
- m_kwargs = {}
1216
- for key, value in kwargs.items():
1217
- if isinstance(value, pd.Series):
1218
- m_kwargs[key] = value.loc[ml_name]
1219
- else:
1220
- m_kwargs[key] = value
1221
- # Convert timestamps
1222
- for tstamp in ["tmin", "tmax"]:
1223
- if tstamp in m_kwargs:
1224
- m_kwargs[tstamp] = pd.Timestamp(m_kwargs[tstamp])
1238
+ solve_model = partial(
1239
+ self._solve_model,
1240
+ report=report,
1241
+ ignore_solve_errors=ignore_solve_errors,
1242
+ **kwargs,
1243
+ )
1244
+ if self.conn.conn_type != "pas":
1245
+ parallel = False
1246
+ logger.error(
1247
+ "Parallel solving only supported for PasConnector databases."
1248
+ "Setting parallel to `False`"
1249
+ )
1225
1250
 
1226
- try:
1227
- ml.solve(report=report, **m_kwargs)
1228
- if store_result:
1229
- self.conn.add_model(ml, overwrite=True)
1230
- except Exception as e:
1231
- if ignore_solve_errors:
1232
- warning = "solve error ignored for -> {}".format(ml.name)
1233
- ps.logger.warning(warning)
1234
- else:
1235
- raise e
1251
+ if parallel and progressbar:
1252
+ process_map(solve_model, modelnames, max_workers=max_workers)
1253
+ elif parallel and not progressbar:
1254
+ with ProcessPoolExecutor(max_workers=max_workers) as executor:
1255
+ executor.map(solve_model, modelnames)
1256
+ else:
1257
+ for ml_name in (
1258
+ tqdm(modelnames, desc="Solving models") if progressbar else modelnames
1259
+ ):
1260
+ solve_model(ml_name=ml_name)
1261
+
1262
+ def _solve_model(
1263
+ self,
1264
+ ml_name: str,
1265
+ report: bool = False,
1266
+ ignore_solve_errors: bool = False,
1267
+ **kwargs,
1268
+ ) -> None:
1269
+ """Solve a model in the store (internal method).
1270
+
1271
+ ml_name : list of str, optional
1272
+ name of a model in the pastastore
1273
+ report : boolean, optional
1274
+ determines if a report is printed when the model is solved,
1275
+ default is False
1276
+ ignore_solve_errors : boolean, optional
1277
+ if True, errors emerging from the solve method are ignored,
1278
+ default is False which will raise an exception when a model
1279
+ cannot be optimized
1280
+ **kwargs : dictionary
1281
+ arguments are passed to the solve method.
1282
+ """
1283
+ ml = self.conn.get_models(ml_name)
1284
+ m_kwargs = {}
1285
+ for key, value in kwargs.items():
1286
+ if isinstance(value, pd.Series):
1287
+ m_kwargs[key] = value.loc[ml.name]
1288
+ else:
1289
+ m_kwargs[key] = value
1290
+ # Convert timestamps
1291
+ for tstamp in ["tmin", "tmax"]:
1292
+ if tstamp in m_kwargs:
1293
+ m_kwargs[tstamp] = pd.Timestamp(m_kwargs[tstamp])
1294
+
1295
+ try:
1296
+ ml.solve(report=report, **m_kwargs)
1297
+ except Exception as e:
1298
+ if ignore_solve_errors:
1299
+ warning = "Solve error ignored for '%s': %s " % (ml.name, e)
1300
+ logger.warning(warning)
1301
+ else:
1302
+ raise e
1303
+
1304
+ self.conn.add_model(ml, overwrite=True)
1236
1305
 
1237
1306
  def model_results(
1238
1307
  self,
@@ -1,8 +1,8 @@
1
1
  """Module containing dataframe styling functions."""
2
2
 
3
- import matplotlib as mpl
4
3
  import matplotlib.pyplot as plt
5
4
  import numpy as np
5
+ from matplotlib.colors import rgb2hex
6
6
 
7
7
 
8
8
  def float_styler(val, norm, cmap=None):
@@ -26,12 +26,12 @@ def float_styler(val, norm, cmap=None):
26
26
  -----
27
27
  Given some dataframe
28
28
 
29
- >>> df.map(float_styler, subset=["some column"], norm=norm, cmap=cmap)
29
+ >>> df.style.map(float_styler, subset=["some column"], norm=norm, cmap=cmap)
30
30
  """
31
31
  if cmap is None:
32
32
  cmap = plt.get_cmap("RdYlBu")
33
33
  bg = cmap(norm(val))
34
- color = mpl.colors.rgb2hex(bg)
34
+ color = rgb2hex(bg)
35
35
  c = "White" if np.mean(bg[:3]) < 0.4 else "Black"
36
36
  return f"background-color: {color}; color: {c}"
37
37
 
@@ -53,15 +53,48 @@ def boolean_styler(b):
53
53
  -----
54
54
  Given some dataframe
55
55
 
56
- >>> df.map(boolean_styler, subset=["some column"])
56
+ >>> df.style.map(boolean_styler, subset=["some column"])
57
57
  """
58
58
  if b:
59
59
  return (
60
- f"background-color: {mpl.colors.rgb2hex((231/255, 255/255, 239/255))}; "
60
+ f"background-color: {rgb2hex((231/255, 255/255, 239/255))}; "
61
61
  "color: darkgreen"
62
62
  )
63
63
  else:
64
64
  return (
65
- f"background-color: {mpl.colors.rgb2hex((255/255, 238/255, 238/255))}; "
65
+ f"background-color: {rgb2hex((255/255, 238/255, 238/255))}; "
66
66
  "color: darkred"
67
67
  )
68
+
69
+
70
+ def boolean_row_styler(row, column):
71
+ """Styler function to color rows based on the value in column.
72
+
73
+ Parameters
74
+ ----------
75
+ row : pd.Series
76
+ row in dataframe
77
+ column : str
78
+ column name to get boolean value for styling
79
+
80
+ Returns
81
+ -------
82
+ str
83
+ css for styling dataframe row
84
+
85
+ Usage
86
+ -----
87
+ Given some dataframe
88
+
89
+ >>> df.style.apply(boolean_row_styler, column="boolean_column", axis=1)
90
+ """
91
+ if row[column]:
92
+ return (
93
+ f"background-color: {rgb2hex((231/255, 255/255, 239/255))}; "
94
+ "color: darkgreen",
95
+ ) * row.size
96
+ else:
97
+ return (
98
+ f"background-color: {rgb2hex((255/255, 238/255, 238/255))}; "
99
+ "color: darkred",
100
+ ) * row.size
@@ -9,7 +9,7 @@ PASTAS_VERSION = parse_version(ps.__version__)
9
9
  PASTAS_LEQ_022 = PASTAS_VERSION <= parse_version("0.22.0")
10
10
  PASTAS_GEQ_150 = PASTAS_VERSION >= parse_version("1.5.0")
11
11
 
12
- __version__ = "1.6.1"
12
+ __version__ = "1.7.1"
13
13
 
14
14
 
15
15
  def show_versions(optional=False) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pastastore
3
- Version: 1.6.1
3
+ Version: 1.7.1
4
4
  Summary: Tools for managing Pastas time series models.
5
5
  Author: D.A. Brakenhoff
6
6
  Maintainer-email: "D.A. Brakenhoff" <d.brakenhoff@artesia-water.nl>, "R. Calje" <r.calje@artesia-water.nl>, "M.A. Vonk" <m.vonk@artesia-water.nl>
@@ -16,6 +16,9 @@ pastastore.egg-info/SOURCES.txt
16
16
  pastastore.egg-info/dependency_links.txt
17
17
  pastastore.egg-info/requires.txt
18
18
  pastastore.egg-info/top_level.txt
19
+ pastastore/extensions/__init__.py
20
+ pastastore/extensions/accessor.py
21
+ pastastore/extensions/hpd.py
19
22
  tests/test_001_import.py
20
23
  tests/test_002_connectors.py
21
24
  tests/test_003_pastastore.py
@@ -61,7 +61,7 @@ test = [
61
61
  "codacy-coverage",
62
62
  ]
63
63
  test_py312 = [
64
- "pastastore[lint,optional]",
64
+ "pastastore[lint,optional]", # no arcticdb
65
65
  "hydropandas[full]",
66
66
  "coverage",
67
67
  "codecov",
@@ -80,9 +80,6 @@ docs = [
80
80
  "nbsphinx_link",
81
81
  ]
82
82
 
83
- [tool.setuptools]
84
- packages = ["pastastore"]
85
-
86
83
  [tool.setuptools.dynamic]
87
84
  version = { attr = "pastastore.version.__version__" }
88
85
 
@@ -195,13 +195,18 @@ def test_iter_models(request, pstore):
195
195
  def test_solve_models_and_get_stats(request, pstore):
196
196
  depends(request, [f"test_create_models[{pstore.type}]"])
197
197
  _ = pstore.solve_models(
198
- ignore_solve_errors=False, progressbar=False, store_result=True
198
+ ignore_solve_errors=False, progressbar=False, parallel=False
199
199
  )
200
200
  stats = pstore.get_statistics(["evp", "aic"], progressbar=False)
201
201
  assert stats.index.size == 2
202
202
 
203
203
 
204
204
  @pytest.mark.dependency
205
+ def test_solve_models_parallel(request, pstore):
206
+ depends(request, [f"test_create_models[{pstore.type}]"])
207
+ _ = pstore.solve_models(ignore_solve_errors=False, progressbar=False, parallel=True)
208
+
209
+
205
210
  def test_apply(request, pstore):
206
211
  depends(request, [f"test_solve_models_and_get_stats[{pstore.type}]"])
207
212
 
File without changes
File without changes
File without changes