pastastore 1.4.0__py3-none-any.whl → 1.6.0__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.
pastastore/store.py CHANGED
@@ -1,7 +1,10 @@
1
+ """Module containing the PastaStore object for managing time series and models."""
2
+
1
3
  import json
4
+ import logging
2
5
  import os
3
6
  import warnings
4
- from typing import List, Optional, Tuple, Union
7
+ from typing import Dict, List, Literal, Optional, Tuple, Union
5
8
 
6
9
  import numpy as np
7
10
  import pandas as pd
@@ -14,11 +17,14 @@ from pastastore.base import BaseConnector
14
17
  from pastastore.connectors import DictConnector
15
18
  from pastastore.plotting import Maps, Plots
16
19
  from pastastore.util import _custom_warning
20
+ from pastastore.version import PASTAS_GEQ_150, PASTAS_LEQ_022
17
21
  from pastastore.yaml_interface import PastastoreYAML
18
22
 
19
23
  FrameorSeriesUnion = Union[pd.DataFrame, pd.Series]
20
24
  warnings.showwarning = _custom_warning
21
25
 
26
+ logger = logging.getLogger(__name__)
27
+
22
28
 
23
29
  class PastaStore:
24
30
  """PastaStore object for managing pastas time series and models.
@@ -27,9 +33,8 @@ class PastaStore:
27
33
  the database. Different Connectors are available, e.g.:
28
34
 
29
35
  - PasConnector for storing all data as .pas (JSON) files on disk (recommended)
30
- - DictConenctor for storing all data in dictionaries (in-memory)
31
- - ArcticConnector for saving data to MongoDB using the Arctic module
32
- - PystoreConnector for saving data to disk using the Pystore module
36
+ - ArcticDBConnector for saving data on disk using arcticdb package
37
+ - DictConnector for storing all data in dictionaries (in-memory)
33
38
 
34
39
  Parameters
35
40
  ----------
@@ -40,6 +45,8 @@ class PastaStore:
40
45
  name of the PastaStore, by default takes the name of the Connector object
41
46
  """
42
47
 
48
+ _accessors = set()
49
+
43
50
  def __init__(
44
51
  self,
45
52
  connector: Optional[BaseConnector] = None,
@@ -72,7 +79,7 @@ class PastaStore:
72
79
  self.yaml = PastastoreYAML(self)
73
80
 
74
81
  def _register_connector_methods(self):
75
- """Internal method for registering connector methods."""
82
+ """Register connector methods (internal method)."""
76
83
  methods = [
77
84
  func
78
85
  for func in dir(self.conn)
@@ -83,26 +90,70 @@ class PastaStore:
83
90
 
84
91
  @property
85
92
  def oseries(self):
93
+ """
94
+ Returns the oseries metadata as dataframe.
95
+
96
+ Returns
97
+ -------
98
+ oseries
99
+ oseries metadata as dataframe
100
+ """
86
101
  return self.conn.oseries
87
102
 
88
103
  @property
89
104
  def stresses(self):
105
+ """
106
+ Returns the stresses metadata as dataframe.
107
+
108
+ Returns
109
+ -------
110
+ stresses
111
+ stresses metadata as dataframe
112
+ """
90
113
  return self.conn.stresses
91
114
 
92
115
  @property
93
116
  def models(self):
117
+ """Return list of model names.
118
+
119
+ Returns
120
+ -------
121
+ list
122
+ list of model names
123
+ """
94
124
  return self.conn.models
95
125
 
96
126
  @property
97
127
  def oseries_names(self):
128
+ """Return list of oseries names.
129
+
130
+ Returns
131
+ -------
132
+ list
133
+ list of oseries names
134
+ """
98
135
  return self.conn.oseries_names
99
136
 
100
137
  @property
101
138
  def stresses_names(self):
139
+ """Return list of streses names.
140
+
141
+ Returns
142
+ -------
143
+ list
144
+ list of stresses names
145
+ """
102
146
  return self.conn.stresses_names
103
147
 
104
148
  @property
105
149
  def model_names(self):
150
+ """Return list of model names.
151
+
152
+ Returns
153
+ -------
154
+ list
155
+ list of model names
156
+ """
106
157
  return self.conn.model_names
107
158
 
108
159
  @property
@@ -111,22 +162,57 @@ class PastaStore:
111
162
 
112
163
  @property
113
164
  def n_oseries(self):
165
+ """Return number of oseries.
166
+
167
+ Returns
168
+ -------
169
+ int
170
+ number of oseries
171
+ """
114
172
  return self.conn.n_oseries
115
173
 
116
174
  @property
117
175
  def n_stresses(self):
176
+ """Return number of stresses.
177
+
178
+ Returns
179
+ -------
180
+ int
181
+ number of stresses
182
+ """
118
183
  return self.conn.n_stresses
119
184
 
120
185
  @property
121
186
  def n_models(self):
187
+ """Return number of models.
188
+
189
+ Returns
190
+ -------
191
+ int
192
+ number of models
193
+ """
122
194
  return self.conn.n_models
123
195
 
124
196
  @property
125
197
  def oseries_models(self):
198
+ """Return dictionary of models per oseries.
199
+
200
+ Returns
201
+ -------
202
+ dict
203
+ dictionary containing list of models (values) for each oseries (keys).
204
+ """
126
205
  return self.conn.oseries_models
127
206
 
128
207
  @property
129
208
  def oseries_with_models(self):
209
+ """Return list of oseries for which models are contained in the database.
210
+
211
+ Returns
212
+ -------
213
+ list
214
+ list of oseries names for which models are contained in the database.
215
+ """
130
216
  return self.conn.oseries_with_models
131
217
 
132
218
  def __repr__(self):
@@ -136,7 +222,7 @@ class PastaStore:
136
222
  def get_oseries_distances(
137
223
  self, names: Optional[Union[list, str]] = None
138
224
  ) -> FrameorSeriesUnion:
139
- """Method to obtain the distances in meters between the oseries.
225
+ """Get the distances in meters between the oseries.
140
226
 
141
227
  Parameters
142
228
  ----------
@@ -175,7 +261,7 @@ class PastaStore:
175
261
  n: int = 1,
176
262
  maxdist: Optional[float] = None,
177
263
  ) -> FrameorSeriesUnion:
178
- """Method to obtain the nearest (n) oseries.
264
+ """Get the nearest (n) oseries.
179
265
 
180
266
  Parameters
181
267
  ----------
@@ -191,7 +277,6 @@ class PastaStore:
191
277
  oseries:
192
278
  list with the names of the oseries.
193
279
  """
194
-
195
280
  distances = self.get_oseries_distances(names)
196
281
  if maxdist is not None:
197
282
  distances = distances.where(distances <= maxdist, np.nan)
@@ -214,8 +299,7 @@ class PastaStore:
214
299
  stresses: Optional[Union[list, str]] = None,
215
300
  kind: Optional[Union[str, List[str]]] = None,
216
301
  ) -> FrameorSeriesUnion:
217
- """Method to obtain the distances in meters between the oseries and
218
- stresses.
302
+ """Get the distances in meters between the oseries and stresses.
219
303
 
220
304
  Parameters
221
305
  ----------
@@ -274,7 +358,7 @@ class PastaStore:
274
358
  n: int = 1,
275
359
  maxdist: Optional[float] = None,
276
360
  ) -> FrameorSeriesUnion:
277
- """Method to obtain the nearest (n) stresses of a specific kind.
361
+ """Get the nearest (n) stresses of a specific kind.
278
362
 
279
363
  Parameters
280
364
  ----------
@@ -295,7 +379,6 @@ class PastaStore:
295
379
  stresses:
296
380
  list with the names of the stresses.
297
381
  """
298
-
299
382
  distances = self.get_distances(oseries, stresses, kind)
300
383
  if maxdist is not None:
301
384
  distances = distances.where(distances <= maxdist, np.nan)
@@ -317,8 +400,9 @@ class PastaStore:
317
400
  progressbar=False,
318
401
  ignore_errors=False,
319
402
  ):
320
- """Get groundwater signatures. NaN-values are returned when the
321
- signature could not be computed.
403
+ """Get groundwater signatures.
404
+
405
+ NaN-values are returned when the signature cannot be computed.
322
406
 
323
407
  Parameters
324
408
  ----------
@@ -384,7 +468,12 @@ class PastaStore:
384
468
 
385
469
  return signatures_df
386
470
 
387
- def get_tmin_tmax(self, libname, names=None, progressbar=False):
471
+ def get_tmin_tmax(
472
+ self,
473
+ libname: Literal["oseries", "stresses", "models"],
474
+ names: Union[str, List[str], None] = None,
475
+ progressbar: bool = False,
476
+ ):
388
477
  """Get tmin and tmax for time series.
389
478
 
390
479
  Parameters
@@ -403,22 +492,48 @@ class PastaStore:
403
492
  tmintmax : pd.dataframe
404
493
  Dataframe containing tmin and tmax per time series
405
494
  """
406
-
407
495
  names = self.conn._parse_names(names, libname=libname)
408
496
  tmintmax = pd.DataFrame(
409
497
  index=names, columns=["tmin", "tmax"], dtype="datetime64[ns]"
410
498
  )
411
499
  desc = f"Get tmin/tmax {libname}"
412
500
  for n in tqdm(names, desc=desc) if progressbar else names:
413
- if libname == "oseries":
414
- s = self.conn.get_oseries(n)
501
+ if libname == "models":
502
+ mld = self.conn.get_models(
503
+ n,
504
+ return_dict=True,
505
+ )
506
+ tmintmax.loc[n, "tmin"] = mld["settings"]["tmin"]
507
+ tmintmax.loc[n, "tmax"] = mld["settings"]["tmax"]
415
508
  else:
416
- s = self.conn.get_stresses(n)
417
- tmintmax.loc[n, "tmin"] = s.first_valid_index()
418
- tmintmax.loc[n, "tmax"] = s.last_valid_index()
509
+ s = (
510
+ self.conn.get_oseries(n)
511
+ if libname == "oseries"
512
+ else self.conn.get_stresses(n)
513
+ )
514
+ tmintmax.loc[n, "tmin"] = s.first_valid_index()
515
+ tmintmax.loc[n, "tmax"] = s.last_valid_index()
516
+
419
517
  return tmintmax
420
518
 
421
519
  def get_extent(self, libname, names=None, buffer=0.0):
520
+ """Get extent [xmin, xmax, ymin, ymax] from library.
521
+
522
+ Parameters
523
+ ----------
524
+ libname : str
525
+ name of the library containing the time series
526
+ ('oseries', 'stresses', 'models')
527
+ names : str, list of str, or None, optional
528
+ list of names to include for computing the extent
529
+ buffer : float, optional
530
+ add this distance to the extent, by default 0.0
531
+
532
+ Returns
533
+ -------
534
+ extent : list
535
+ extent [xmin, xmax, ymin, ymax]
536
+ """
422
537
  names = self.conn._parse_names(names, libname=libname)
423
538
  if libname in ["oseries", "stresses"]:
424
539
  df = getattr(self, libname)
@@ -443,8 +558,10 @@ class PastaStore:
443
558
  progressbar: Optional[bool] = False,
444
559
  ignore_errors: Optional[bool] = False,
445
560
  ) -> FrameorSeriesUnion:
446
- """Get model parameters. NaN-values are returned when the parameters
447
- are not present in the model or the model is not optimized.
561
+ """Get model parameters.
562
+
563
+ NaN-values are returned when the parameters are not present in the model or the
564
+ model is not optimized.
448
565
 
449
566
  Parameters
450
567
  ----------
@@ -526,7 +643,6 @@ class PastaStore:
526
643
  -------
527
644
  s : pandas.DataFrame
528
645
  """
529
-
530
646
  modelnames = self.conn._parse_names(modelnames, libname="models")
531
647
 
532
648
  # if statistics is str
@@ -556,8 +672,9 @@ class PastaStore:
556
672
  def create_model(
557
673
  self,
558
674
  name: str,
559
- modelname: str = None,
675
+ modelname: Optional[str] = None,
560
676
  add_recharge: bool = True,
677
+ add_ar_noisemodel: bool = False,
561
678
  recharge_name: str = "recharge",
562
679
  ) -> ps.Model:
563
680
  """Create a pastas Model.
@@ -572,6 +689,8 @@ class PastaStore:
572
689
  add recharge to the model by looking for the closest
573
690
  precipitation and evaporation time series in the stresses
574
691
  library, by default True
692
+ add_ar_noisemodel : bool, optional
693
+ add AR(1) noise model to the model, by default False
575
694
  recharge_name : str
576
695
  name of the RechargeModel
577
696
 
@@ -591,13 +710,15 @@ class PastaStore:
591
710
  meta = self.conn.get_metadata("oseries", name, as_frame=False)
592
711
  ts = self.conn.get_oseries(name)
593
712
 
594
- # convert to Timeseries and create model
713
+ # convert to time series and create model
595
714
  if not ts.dropna().empty:
596
715
  if modelname is None:
597
716
  modelname = name
598
717
  ml = ps.Model(ts, name=modelname, metadata=meta)
599
718
  if add_recharge:
600
719
  self.add_recharge(ml, recharge_name=recharge_name)
720
+ if add_ar_noisemodel and PASTAS_GEQ_150:
721
+ ml.add_noisemodel(ps.ArNoiseModel())
601
722
  return ml
602
723
  else:
603
724
  raise ValueError("Empty time series!")
@@ -694,50 +815,363 @@ class PastaStore:
694
815
  recharge_name : str
695
816
  name of the RechargeModel
696
817
  """
697
- # get nearest prec and evap stns
698
- if "prec" not in self.stresses.kind.values:
699
- raise ValueError(
700
- "No stresses with kind='prec' found in store. "
701
- "add_recharge() requires stresses with kind='prec'!"
702
- )
703
- if "evap" not in self.stresses.kind.values:
704
- raise ValueError(
705
- "No stresses with kind='evap' found in store. "
706
- "add_recharge() requires stresses with kind='evap'!"
707
- )
708
- names = []
709
- for var in ("prec", "evap"):
710
- try:
711
- name = self.get_nearest_stresses(ml.oseries.name, kind=var).iloc[0, 0]
712
- except AttributeError:
713
- msg = "No precipitation or evaporation time series found!"
714
- raise Exception(msg)
715
- if isinstance(name, float):
716
- if np.isnan(name):
818
+ if recharge is None:
819
+ recharge = ps.rch.Linear()
820
+ if rfunc is None:
821
+ rfunc = ps.Exponential
822
+
823
+ self.add_stressmodel(
824
+ ml,
825
+ stresses={"prec": "nearest", "evap": "nearest"},
826
+ rfunc=rfunc,
827
+ stressmodel=ps.RechargeModel,
828
+ stressmodel_name=recharge_name,
829
+ recharge=recharge,
830
+ )
831
+
832
+ def _parse_stresses(
833
+ self,
834
+ stresses: Union[str, List[str], Dict[str, str]],
835
+ kind: Optional[str],
836
+ stressmodel,
837
+ oseries: Optional[str] = None,
838
+ ):
839
+ # parse stresses for RechargeModel, allow list of len 2 or 3 and
840
+ # set correct kwarg names
841
+ if stressmodel._name == "RechargeModel":
842
+ if isinstance(stresses, list):
843
+ if len(stresses) == 2:
844
+ stresses = {
845
+ "prec": stresses[0],
846
+ "evap": stresses[1],
847
+ }
848
+ elif len(stresses) == 3:
849
+ stresses = {
850
+ "prec": stresses[0],
851
+ "evap": stresses[1],
852
+ "temp": stresses[2],
853
+ }
854
+ else:
717
855
  raise ValueError(
718
- f"Unable to find nearest '{var}' stress! "
719
- "Check x and y coordinates."
856
+ "RechargeModel requires 2 or 3 stress names, "
857
+ f"got: {len(stresses)}!"
720
858
  )
859
+ # if stresses is list, create dictionary normally
860
+ elif isinstance(stresses, list):
861
+ stresses = {"stress": stresses}
862
+ # if stresses is str, make it a list of len 1
863
+ elif isinstance(stresses, str):
864
+ stresses = {"stress": [stresses]}
865
+
866
+ # check if stresses is a dictionary, else raise TypeError
867
+ if not isinstance(stresses, dict):
868
+ raise TypeError("stresses must be a list, string or dictionary!")
869
+
870
+ # if no kind specified, set to well for WellModel
871
+ if stressmodel._name == "WellModel":
872
+ if kind is None:
873
+ kind = "well"
874
+
875
+ # store a copy of the user input for kind
876
+ if isinstance(kind, list):
877
+ _kind = kind.copy()
878
+ else:
879
+ _kind = kind
880
+
881
+ # create empty list for gathering metadata
882
+ metadata = []
883
+ # loop over stresses keys/values
884
+ for i, (k, v) in enumerate(stresses.items()):
885
+ # if entry in dictionary is str, make it list of len 1
886
+ if isinstance(v, str):
887
+ v = [v]
888
+ # parse value
889
+ if isinstance(v, list):
890
+ for item in v:
891
+ names = [] # empty list for names
892
+ # parse nearest
893
+ if item.startswith("nearest"):
894
+ # check oseries defined if nearest option is used
895
+ if not oseries:
896
+ raise ValueError(
897
+ "Getting nearest stress(es) requires oseries name!"
898
+ )
899
+ try:
900
+ if len(item.split()) == 3: # nearest <n> <kind>
901
+ n = int(item.split()[1])
902
+ kind = item.split()[2]
903
+ elif len(item.split()) == 2: # nearest <n> | <kind>
904
+ try:
905
+ n = int(item.split()[1]) # try converting to <n>
906
+ except ValueError:
907
+ n = 1
908
+ kind = item.split()[1] # interpret as <kind>
909
+ else: # nearest
910
+ n = 1
911
+ # if RechargeModel, we can infer kind
912
+ if (
913
+ _kind is None
914
+ and stressmodel._name == "RechargeModel"
915
+ ):
916
+ kind = k
917
+ elif _kind is None: # catch no kind with bare nearest
918
+ raise ValueError(
919
+ "Bare 'nearest' found but no kind specified."
920
+ )
921
+ elif isinstance(_kind, list):
922
+ kind = _kind[i] # if multiple kind, select i-th
923
+ except Exception as e:
924
+ # raise if nearest parsing failed
925
+ raise ValueError(
926
+ f"Could not parse stresses: '{item}'! "
927
+ "When using option 'nearest', use 'nearest' and specify"
928
+ " kind, or 'nearest <kind>' or 'nearest <n> <kind>'!"
929
+ ) from e
930
+ # check if kind exists at all
931
+ if kind not in self.stresses.kind.values:
932
+ raise ValueError(
933
+ f"Could not find stresses with kind='{kind}'!"
934
+ )
935
+ # get stress names of <n> nearest <kind> stresses
936
+ inames = self.get_nearest_stresses(
937
+ oseries, kind=kind, n=n
938
+ ).iloc[0]
939
+ # check if any NaNs in result
940
+ if inames.isna().any():
941
+ nkind = (self.stresses.kind == kind).sum()
942
+ raise ValueError(
943
+ f"Could not find {n} nearest stress(es) for '{kind}'! "
944
+ f"There are only {nkind} '{kind}' stresses."
945
+ )
946
+ # append names
947
+ names += inames.tolist()
948
+ else:
949
+ # assume name is name of stress
950
+ names.append(item)
951
+ # get stresses and metadata
952
+ stress_series, imeta = self.get_stresses(
953
+ names, return_metadata=True, squeeze=True
954
+ )
955
+ # replace stress name(s) with time series
956
+ if len(names) > 1:
957
+ stresses[k] = list(stress_series.values())
958
+ else:
959
+ stresses[k] = stress_series
960
+ # gather metadata
961
+ if isinstance(imeta, list):
962
+ metadata += imeta
963
+ else:
964
+ metadata.append(imeta)
965
+
966
+ return stresses, metadata
967
+
968
+ def get_stressmodel(
969
+ self,
970
+ stresses: Union[str, List[str], Dict[str, str]],
971
+ stressmodel=ps.StressModel,
972
+ stressmodel_name: Optional[str] = None,
973
+ rfunc=ps.Exponential,
974
+ rfunc_kwargs: Optional[dict] = None,
975
+ kind: Optional[Union[List[str], str]] = None,
976
+ oseries: Optional[str] = None,
977
+ **kwargs,
978
+ ):
979
+ """Get a Pastas stressmodel from stresses time series in Pastastore.
980
+
981
+ Supports "nearest" selection. Any stress name can be replaced by
982
+ "nearest [<n>] <kind>" where <n> is optional and represents the number of
983
+ nearest stresses and <kind> and represents the kind of stress to
984
+ consider. <kind> can also be specified directly with the `kind` kwarg.
985
+
986
+ Note: the 'nearest' option requires the oseries name to be provided.
987
+ Additionally, 'x' and 'y' metadata must be stored for oseries and stresses.
988
+
989
+ Parameters
990
+ ----------
991
+ stresses : str, list of str, or dict
992
+ name(s) of the time series to use for the stressmodel, or dictionary
993
+ with key(s) and value(s) as time series name(s). Options include:
994
+ - name of stress: `"prec_stn"`
995
+ - list of stress names: `["prec_stn", "evap_stn"]`
996
+ - dict for RechargeModel: `{"prec": "prec_stn", "evap": "evap_stn"}`
997
+ - dict for StressModel: `{"stress": "well1"}`
998
+ - nearest, specifying kind: `"nearest well"`
999
+ - nearest specifying number and kind: `"nearest 2 well"`
1000
+ stressmodel : str or class
1001
+ stressmodel class to use, by default ps.StressModel
1002
+ stressmodel_name : str, optional
1003
+ name of the stressmodel, by default None, which uses the stress name,
1004
+ if there is 1 stress otherwise the name of the stressmodel type. For
1005
+ RechargeModels, the name defaults to 'recharge'.
1006
+ rfunc : str or class
1007
+ response function class to use, by default ps.Exponential
1008
+ rfunc_kwargs : dict, optional
1009
+ keyword arguments to pass to the response function, by default None
1010
+ kind : str or list of str, optional
1011
+ specify kind of stress(es) to use, by default None, useful in combination
1012
+ with 'nearest' option for defining stresses
1013
+ oseries : str, optional
1014
+ name of the oseries to use for the stressmodel, by default None, used when
1015
+ 'nearest' option is used for defining stresses.
1016
+ **kwargs
1017
+ additional keyword arguments to pass to the stressmodel
1018
+
1019
+ Returns
1020
+ -------
1021
+ stressmodel : pastas.StressModel
1022
+ pastas StressModel that can be added to pastas Model.
1023
+ """
1024
+ # get stressmodel class, if str was provided
1025
+ if isinstance(stressmodel, str):
1026
+ stressmodel = getattr(ps, stressmodel)
1027
+
1028
+ # parse stresses names to get time series and metadata
1029
+ stresses, metadata = self._parse_stresses(
1030
+ stresses=stresses, stressmodel=stressmodel, kind=kind, oseries=oseries
1031
+ )
1032
+
1033
+ # get stressmodel name if not provided
1034
+ if stressmodel_name is None:
1035
+ if stressmodel._name == "RechargeModel":
1036
+ stressmodel_name = "recharge"
1037
+ elif len(metadata) == 1:
1038
+ stressmodel_name = stresses["stress"].squeeze().name
1039
+ else:
1040
+ stressmodel_name = stressmodel._name
1041
+
1042
+ # check if metadata is list of len 1 and unpack
1043
+ if isinstance(metadata, list) and len(metadata) == 1:
1044
+ metadata = metadata[0]
1045
+
1046
+ # get stressmodel time series settings
1047
+ if kind and "settings" not in kwargs:
1048
+ # try using kind to get predefined settings options
1049
+ if isinstance(kind, str):
1050
+ kwargs["settings"] = ps.rcParams["timeseries"].get(kind, None)
721
1051
  else:
722
- names.append(name)
723
- if len(names) == 0:
724
- msg = "No precipitation or evaporation time series found!"
725
- raise Exception(msg)
726
-
727
- # get data
728
- tsdict = self.conn.get_stresses(names)
729
- metadata = self.conn.get_metadata("stresses", names, as_frame=False)
730
- # add recharge to model
731
- rch = ps.RechargeModel(
732
- tsdict[names[0]],
733
- tsdict[names[1]],
1052
+ kwargs["settings"] = [
1053
+ ps.rcParams["timeseries"].get(ikind, None) for ikind in kind
1054
+ ]
1055
+ elif kind is None and "settings" not in kwargs:
1056
+ # try using kind stored in metadata to get predefined settings options
1057
+ if isinstance(metadata, list):
1058
+ kwargs["settings"] = [
1059
+ ps.rcParams["timeseries"].get(imeta.get("kind", None), None)
1060
+ for imeta in metadata
1061
+ ]
1062
+ elif isinstance(metadata, dict):
1063
+ kwargs["settings"] = ps.rcParams["timeseries"].get(
1064
+ metadata.get("kind", None), None
1065
+ )
1066
+
1067
+ # get rfunc class if str was provided
1068
+ if isinstance(rfunc, str):
1069
+ rfunc = getattr(ps, rfunc)
1070
+
1071
+ # create empty rfunc_kwargs if not provided
1072
+ if rfunc_kwargs is None:
1073
+ rfunc_kwargs = {}
1074
+
1075
+ # special for WellModels
1076
+ if stressmodel._name == "WellModel":
1077
+ names = [s.squeeze().name for s in stresses["stress"]]
1078
+ # check oseries is provided
1079
+ if oseries is None:
1080
+ raise ValueError("WellModel requires 'oseries' to compute distances!")
1081
+ # compute distances and add to kwargs
1082
+ distances = (
1083
+ self.get_distances(oseries=oseries, stresses=names).T.squeeze().values
1084
+ )
1085
+ kwargs["distances"] = distances
1086
+ # set settings to well
1087
+ if "settings" not in kwargs:
1088
+ kwargs["settings"] = "well"
1089
+ # override rfunc and set to HantushWellModel
1090
+ rfunc = ps.HantushWellModel
1091
+
1092
+ # do not add metadata for pastas 0.22 and WellModel
1093
+ if not PASTAS_LEQ_022 and (stressmodel._name != "WellModel"):
1094
+ kwargs["metadata"] = metadata
1095
+
1096
+ return stressmodel(
1097
+ **stresses,
1098
+ rfunc=rfunc(**rfunc_kwargs),
1099
+ name=stressmodel_name,
1100
+ **kwargs,
1101
+ )
1102
+
1103
+ def add_stressmodel(
1104
+ self,
1105
+ ml: Union[ps.Model, str],
1106
+ stresses: Union[str, List[str], Dict[str, str]],
1107
+ stressmodel=ps.StressModel,
1108
+ stressmodel_name: Optional[str] = None,
1109
+ rfunc=ps.Exponential,
1110
+ rfunc_kwargs: Optional[dict] = None,
1111
+ kind: Optional[Union[List[str], str]] = None,
1112
+ **kwargs,
1113
+ ):
1114
+ """Add a pastas StressModel from stresses time series in Pastastore.
1115
+
1116
+ Supports "nearest" selection. Any stress name can be replaced by
1117
+ "nearest [<n>] <kind>" where <n> is optional and represents the number of
1118
+ nearest stresses and <kind> and represents the kind of stress to
1119
+ consider. <kind> can also be specified directly with the `kind` kwarg.
1120
+
1121
+ Note: the 'nearest' option requires the oseries name to be provided.
1122
+ Additionally, 'x' and 'y' metadata must be stored for oseries and stresses.
1123
+
1124
+ Parameters
1125
+ ----------
1126
+ ml : pastas.Model or str
1127
+ pastas.Model object to add StressModel to, if passed as string,
1128
+ model is loaded from store, the stressmodel is added and then written
1129
+ back to the store.
1130
+ stresses : str, list of str, or dict
1131
+ name(s) of the time series to use for the stressmodel, or dictionary
1132
+ with key(s) and value(s) as time series name(s). Options include:
1133
+ - name of stress: `"prec_stn"`
1134
+ - list of stress names: `["prec_stn", "evap_stn"]`
1135
+ - dict for RechargeModel: `{"prec": "prec_stn", "evap": "evap_stn"}`
1136
+ - dict for StressModel: `{"stress": "well1"}`
1137
+ - nearest, specifying kind: `"nearest well"`
1138
+ - nearest specifying number and kind: `"nearest 2 well"`
1139
+ stressmodel : str or class
1140
+ stressmodel class to use, by default ps.StressModel
1141
+ stressmodel_name : str, optional
1142
+ name of the stressmodel, by default None, which uses the stress name,
1143
+ if there is 1 stress otherwise the name of the stressmodel type. For
1144
+ RechargeModels, the name defaults to 'recharge'.
1145
+ rfunc : str or class
1146
+ response function class to use, by default ps.Exponential
1147
+ rfunc_kwargs : dict, optional
1148
+ keyword arguments to pass to the response function, by default None
1149
+ kind : str or list of str, optional
1150
+ specify kind of stress(es) to use, by default None, useful in combination
1151
+ with 'nearest' option for defining stresses
1152
+ **kwargs
1153
+ additional keyword arguments to pass to the stressmodel
1154
+ """
1155
+ sm = self.get_stressmodel(
1156
+ stresses=stresses,
1157
+ stressmodel=stressmodel,
1158
+ stressmodel_name=stressmodel_name,
734
1159
  rfunc=rfunc,
735
- name=recharge_name,
736
- recharge=recharge,
737
- settings=("prec", "evap"),
738
- metadata=metadata,
1160
+ rfunc_kwargs=rfunc_kwargs,
1161
+ kind=kind,
1162
+ oseries=ml if isinstance(ml, str) else ml.oseries.name,
1163
+ **kwargs,
739
1164
  )
740
- ml.add_stressmodel(rch)
1165
+ if isinstance(ml, str):
1166
+ ml = self.get_model(ml)
1167
+ ml.add_stressmodel(sm)
1168
+ self.conn.add_model(ml, overwrite=True)
1169
+ logger.info(
1170
+ f"Stressmodel '{sm.name}' added to model '{ml.name}' "
1171
+ "and stored in database."
1172
+ )
1173
+ else:
1174
+ ml.add_stressmodel(sm)
741
1175
 
742
1176
  def solve_models(
743
1177
  self,
@@ -828,8 +1262,8 @@ class PastaStore:
828
1262
  """
829
1263
  try:
830
1264
  from art_tools import pastas_get_model_results
831
- except Exception:
832
- raise ModuleNotFoundError("You need 'art_tools' to use this method!")
1265
+ except Exception as e:
1266
+ raise ModuleNotFoundError("You need 'art_tools' to use this method!") from e
833
1267
 
834
1268
  if mls is None:
835
1269
  mls = self.conn.models
@@ -870,7 +1304,7 @@ class PastaStore:
870
1304
  "File already exists! " "Use 'overwrite=True' to " "force writing file."
871
1305
  )
872
1306
  elif os.path.exists(fname):
873
- warnings.warn(f"Overwriting file '{os.path.basename(fname)}'")
1307
+ warnings.warn(f"Overwriting file '{os.path.basename(fname)}'", stacklevel=1)
874
1308
 
875
1309
  with ZipFile(fname, "w", compression=ZIP_DEFLATED) as archive:
876
1310
  # oseries
@@ -1011,13 +1445,12 @@ class PastaStore:
1011
1445
  matches : list
1012
1446
  list of names that match search result
1013
1447
  """
1014
-
1015
1448
  if libname == "models":
1016
- lib_names = getattr(self, "model_names")
1449
+ lib_names = self.model_names
1017
1450
  elif libname == "stresses":
1018
- lib_names = getattr(self, "stresses_names")
1451
+ lib_names = self.stresses_names
1019
1452
  elif libname == "oseries":
1020
- lib_names = getattr(self, "oseries_names")
1453
+ lib_names = self.oseries_names
1021
1454
  else:
1022
1455
  raise ValueError("Provide valid libname: 'models', 'stresses' or 'oseries'")
1023
1456
 
@@ -1064,7 +1497,6 @@ class PastaStore:
1064
1497
  indicating whether a stress is contained within a time series
1065
1498
  model.
1066
1499
  """
1067
-
1068
1500
  model_names = self.conn._parse_names(modelnames, libname="models")
1069
1501
  structure = pd.DataFrame(
1070
1502
  index=model_names, columns=["oseries"] + self.stresses_names
@@ -1138,6 +1570,23 @@ class PastaStore:
1138
1570
  return result
1139
1571
 
1140
1572
  def within(self, extent, names=None, libname="oseries"):
1573
+ """Get names of items within extent.
1574
+
1575
+ Parameters
1576
+ ----------
1577
+ extent : list
1578
+ list with [xmin, xmax, ymin, ymax]
1579
+ names : str, list of str, optional
1580
+ list of names to include, by default None
1581
+ libname : str, optional
1582
+ name of library, must be one of ('oseries', 'stresses', 'models'), by
1583
+ default "oseries"
1584
+
1585
+ Returns
1586
+ -------
1587
+ list
1588
+ list of items within extent
1589
+ """
1141
1590
  xmin, xmax, ymin, ymax = extent
1142
1591
  names = self.conn._parse_names(names, libname)
1143
1592
  if libname == "oseries":