pyPRMS 0.9.7__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.
Files changed (51) hide show
  1. pyPRMS/Exceptions_custom.py +31 -0
  2. pyPRMS/__init__.py +52 -0
  3. pyPRMS/cbh/Cbh.py +431 -0
  4. pyPRMS/cbh/CbhAscii.py +458 -0
  5. pyPRMS/cbh/CbhNetcdf.py +199 -0
  6. pyPRMS/cbh/__init__.py +3 -0
  7. pyPRMS/constants.py +131 -0
  8. pyPRMS/control/Control.py +362 -0
  9. pyPRMS/control/ControlFile.py +161 -0
  10. pyPRMS/control/ControlVariable.py +208 -0
  11. pyPRMS/control/__init__.py +3 -0
  12. pyPRMS/dimensions/Dimension.py +154 -0
  13. pyPRMS/dimensions/Dimensions.py +256 -0
  14. pyPRMS/dimensions/__init__.py +2 -0
  15. pyPRMS/input/DataFile.py +354 -0
  16. pyPRMS/input/InputVariable.py +61 -0
  17. pyPRMS/input/__init__.py +0 -0
  18. pyPRMS/metadata/__init__.py +1 -0
  19. pyPRMS/metadata/metadata.py +430 -0
  20. pyPRMS/parameters/ParamDb.py +73 -0
  21. pyPRMS/parameters/Parameter.py +624 -0
  22. pyPRMS/parameters/ParameterFile.py +190 -0
  23. pyPRMS/parameters/ParameterNetCDF.py +74 -0
  24. pyPRMS/parameters/ParameterSet.py +96 -0
  25. pyPRMS/parameters/Parameters.py +1506 -0
  26. pyPRMS/parameters/__init__.py +5 -0
  27. pyPRMS/plot_helpers.py +305 -0
  28. pyPRMS/prms_helpers.py +235 -0
  29. pyPRMS/py.typed +0 -0
  30. pyPRMS/summary/OutputCSV.py +64 -0
  31. pyPRMS/summary/OutputVariable.py +163 -0
  32. pyPRMS/summary/OutputVariables.py +227 -0
  33. pyPRMS/summary/__init__.py +2 -0
  34. pyPRMS/utilities/__init__.py +0 -0
  35. pyPRMS/utilities/convert_cbh.py +107 -0
  36. pyPRMS/utilities/convert_model_output.py +91 -0
  37. pyPRMS/utilities/convert_params.py +60 -0
  38. pyPRMS/version.py +13 -0
  39. pyPRMS/xml/cbh.xml +163 -0
  40. pyPRMS/xml/control.xml +1447 -0
  41. pyPRMS/xml/dimensions.xml +311 -0
  42. pyPRMS/xml/modules.xml +251 -0
  43. pyPRMS/xml/parameters.xml +6932 -0
  44. pyPRMS/xml/time_series_input.xml +198 -0
  45. pyPRMS/xml/variables.xml +8173 -0
  46. pyprms-0.9.7.dist-info/LICENSE.md +21 -0
  47. pyprms-0.9.7.dist-info/METADATA +67 -0
  48. pyprms-0.9.7.dist-info/RECORD +51 -0
  49. pyprms-0.9.7.dist-info/WHEEL +5 -0
  50. pyprms-0.9.7.dist-info/entry_points.txt +3 -0
  51. pyprms-0.9.7.dist-info/top_level.txt +1 -0
@@ -0,0 +1,624 @@
1
+ import functools
2
+ import numpy as np
3
+ import numpy.typing as npt
4
+ import pandas as pd # type: ignore
5
+ from typing import Any, cast, Dict, List, NamedTuple, Optional, Union
6
+ import xml.etree.ElementTree as xmlET
7
+
8
+ from ..constants import NEW_PTYPE_TO_DTYPE
9
+ from ..dimensions.Dimensions import ParamDimensions
10
+ from ..Exceptions_custom import FixedDimensionError
11
+
12
+ ParamDataRawType = Union[npt.NDArray, np.int32, np.float32, np.float64, np.str_]
13
+ ParamDataType = Union[npt.NDArray, np.int32, np.float32, np.float64, np.str_, int, float, str]
14
+
15
+
16
+ class Outliers(NamedTuple):
17
+ name: str
18
+ under: int
19
+ over: int
20
+
21
+
22
+ class Stats(NamedTuple):
23
+ name: str
24
+ min: Optional[npt.DTypeLike]
25
+ max: Optional[npt.DTypeLike]
26
+ mean: Optional[npt.DTypeLike]
27
+ median: Optional[npt.DTypeLike]
28
+
29
+
30
+ class Parameter(object):
31
+ """Container for a single Parameter object.
32
+
33
+ A parameter has a name, datatype, optional units, one or more dimensions, and
34
+ associated data.
35
+ """
36
+
37
+ # Container for a single parameter
38
+ def __init__(self, name: str,
39
+ meta: Optional[Dict] = None,
40
+ global_dims=None,
41
+ strict: Optional[bool] = True):
42
+ """
43
+ Initialize a parameter object.
44
+
45
+ :param name: A valid PRMS parameter name
46
+ """
47
+
48
+ # Set the parameter name
49
+ self.__name = name
50
+ self.__dimensions = ParamDimensions(strict=False)
51
+
52
+ if meta is None:
53
+ if strict:
54
+ raise ValueError(f'Strict is true but no metadata was supplied')
55
+ else:
56
+ # NOTE: Having no metadata creates a parameter with no dimensions
57
+ self.meta = {}
58
+ else:
59
+ if strict:
60
+ if name in meta:
61
+ self.meta = meta[name]
62
+
63
+ # Add the dimensions for this parameter
64
+ for cname in self.meta['dimensions']:
65
+ self.__dimensions.add(cname)
66
+
67
+ if global_dims is not None:
68
+ self.__dimensions[cname].size = global_dims.get(cname).size
69
+ self.__dimensions[cname].meta = global_dims[cname].meta
70
+ else:
71
+ raise ValueError(f'`{self.name}` does not exist in metadata')
72
+ else:
73
+ # The meta must be supplied as an adhoc dictionary
74
+ self.meta = meta
75
+
76
+ self.__data: Optional[ParamDataRawType] = None
77
+ self.__modified = False
78
+
79
+ def __str__(self) -> str:
80
+ """Pretty-print string representation of the parameter information.
81
+
82
+ :return: Pretty-print string of arameter information
83
+ """
84
+
85
+ outstr = f'----- Parameter -----\n'
86
+ outstr += f'name: {self.name}\n'
87
+
88
+ for kk, vv in self.meta.items():
89
+ outstr += f'{kk}: {vv}\n'
90
+
91
+ return outstr
92
+
93
+ @property
94
+ def as_dataframe(self) -> pd.DataFrame:
95
+ """Returns the parameter data as a pandas DataFrame with local model indices as the index.
96
+
97
+ :returns: dataframe of parameter data
98
+ """
99
+
100
+ if len(self.data_raw.shape) == 2:
101
+ df = pd.DataFrame(self.data_raw)
102
+ df.rename(columns=lambda xx: '{}_{}'.format(self.name,
103
+ df.columns.get_loc(xx) + 1), inplace=True) # type: ignore
104
+ else:
105
+ # Assuming 1D array
106
+ df = pd.DataFrame(self.data_raw, columns=[self.name]) # type: ignore
107
+
108
+ df.rename(index={k: k + 1 for k in df.index}, inplace=True)
109
+
110
+ if self.is_hru_param():
111
+ idx_name = 'model_hru_idx'
112
+ elif self.is_seg_param():
113
+ idx_name = 'model_seg_idx'
114
+ elif self.is_poi_param():
115
+ idx_name = 'model_poi_idx'
116
+ else:
117
+ idx_name = 'idx'
118
+
119
+ df.index.name = idx_name
120
+ return df
121
+
122
+ @property
123
+ def data(self) -> ParamDataType:
124
+ """Returns the data associated with the parameter.
125
+
126
+ :returns: parameter data
127
+ """
128
+
129
+ # TODO: Best way to prevent modification of data elements?
130
+ if self.__data is not None:
131
+ if self.is_scalar:
132
+ return self.__data.item()
133
+ return self.__data
134
+ raise TypeError(f'Parameter, {self.__name}, has no data')
135
+
136
+ @data.setter
137
+ def data(self, data_in: ParamDataRawType):
138
+ """Sets the data for the parameter.
139
+
140
+ :param data_in: A list containing the parameter data
141
+ :raises TypeError: if the datatype for the parameter is invalid
142
+ :raises ValueError: if the number of dimensions for the parameter is greater than 2
143
+ """
144
+
145
+ # Metadata required: datatype, dimensions
146
+ if self.is_scalar:
147
+ if isinstance(data_in, np.ndarray):
148
+ if data_in.size > 1:
149
+ raise IndexError(f'{self.__name}: parameter expects a scalar but '
150
+ f'incoming data has size={data_in.size}')
151
+
152
+ if data_in.dtype == NEW_PTYPE_TO_DTYPE[self.meta['datatype']]:
153
+ self.__data = data_in
154
+ else:
155
+ # Attempt to convert to correct datatype
156
+ self.__data = np.array(data_in, dtype=NEW_PTYPE_TO_DTYPE[self.meta['datatype']])
157
+ else:
158
+ self.__data = np.array([data_in], dtype=NEW_PTYPE_TO_DTYPE[self.meta['datatype']])
159
+
160
+ if self.__dimensions.get('one').size == 0:
161
+ self.__dimensions.get('one').size = 1
162
+ elif isinstance(data_in, np.ndarray) or isinstance(data_in, np.generic):
163
+ expected_shape = tuple(ss.size for ss in self.dimensions.values())
164
+ expected_size = functools.reduce(lambda x, y: x * y, expected_shape)
165
+
166
+ if expected_size > 0 and data_in.shape != expected_shape:
167
+ # If this is a parameter that was collapsed to a scalar we
168
+ # can broadcast it to the correct shape
169
+ if data_in.size == 1:
170
+ data_in = np.repeat(data_in, expected_size)
171
+
172
+ # if data_in.size == 12 and expected_shape[1] == 12:
173
+ if data_in.size == 12 and 'nmonths' in self.__dimensions.keys():
174
+ # Expand nmonths to nhru, nmonth
175
+ data_in = np.resize(data_in, expected_shape)
176
+ else:
177
+ # Try to reshape the data to match the dimensionality
178
+ try:
179
+ data_in = data_in.reshape(expected_shape, order='F')
180
+ except ValueError:
181
+ raise IndexError(f'{self.__name}: Shape of incoming data, {data_in.shape}, '
182
+ f'does not match the expected shape, {expected_shape} '
183
+ 'and cannot be reshaped to expected shape.')
184
+
185
+ if data_in.ndim != len(self.meta['dimensions']):
186
+ raise IndexError(f'{self.__name}: Number of dimensions do not match '
187
+ f'({data_in.ndim} != {len(self.meta["dimensions"])})')
188
+
189
+ if data_in.dtype == NEW_PTYPE_TO_DTYPE[self.meta['datatype']]:
190
+ self.__data = data_in
191
+ else:
192
+ # Attempt to convert to correct datatype
193
+ self.__data = np.array(data_in, dtype=NEW_PTYPE_TO_DTYPE[self.meta['datatype']])
194
+
195
+ if expected_size == 0:
196
+ # Set the dimension size(s) if existing dimension sizes are zero
197
+ for cname, cdim in zip(self.meta['dimensions'], self.__data.shape):
198
+ self.__dimensions.get(cname).size = cdim
199
+ else:
200
+ # TODO: 2023-11-13 PAN - This should raise an error
201
+ pass
202
+
203
+ @property
204
+ def data_raw(self) -> ParamDataRawType:
205
+ """Returns the raw data associated with the parameter.
206
+
207
+ :returns: parameter data
208
+ """
209
+ if self.__data is not None:
210
+ return self.__data
211
+ raise TypeError(f'Parameter, {self.__name}, has no data')
212
+
213
+ @property
214
+ def dimensions(self) -> ParamDimensions:
215
+ """Returns the Dimensions object associated with the parameter.
216
+
217
+ :returns: Dimensions object for the parameter"""
218
+
219
+ return self.__dimensions
220
+
221
+ @property
222
+ def index_map(self) -> Union[Dict[Any, int], None]:
223
+ """Returns an ordered dictionary which maps data values of a 1D array
224
+ to index positions.
225
+
226
+ :returns: dictionary mapping data values to index position
227
+ """
228
+
229
+ # FIXME: 20230706 PAN - this is flawed; duplicated values overwrite
230
+ # index positions.
231
+ if self.data_raw.ndim == 1:
232
+ return dict((val.item(), idx[0]) for idx, val in np.ndenumerate(self.data_raw))
233
+ else:
234
+ return None
235
+
236
+ @property
237
+ def is_scalar(self):
238
+ try:
239
+ return 'one' in self.meta['dimensions']
240
+ except KeyError:
241
+ return True
242
+
243
+ @property
244
+ def modified(self) -> bool:
245
+ """Logical denoting whether elements in the parameter data have been modified.
246
+
247
+ :returns: True is parameter data was modified
248
+ """
249
+ return self.__modified
250
+
251
+ @property
252
+ def modules(self) -> List[str]:
253
+ """Returns the names of the PRMS modules that require the parameter.
254
+
255
+ :returns: names of PRMS modules that require the parameter
256
+ """
257
+ return self.meta.get('modules', [])
258
+
259
+ @property
260
+ def name(self) -> str:
261
+ """Returns the parameter name.
262
+
263
+ :returns: parameter name
264
+ """
265
+ return self.__name
266
+
267
+ @property
268
+ def ndim(self) -> int:
269
+ """Returns the number of dimensions that are defined for the parameter.
270
+
271
+ :returns: numbers of parameter dimensions
272
+ """
273
+ if self.is_scalar:
274
+ return 0
275
+ else:
276
+ return self.__dimensions.ndim
277
+
278
+ # @property
279
+ # def size(self) -> int:
280
+ # """Return the total size of the parameter for the defined dimensions.
281
+ #
282
+ # :returns total size of parameter dimensions"""
283
+ # arr_shp = [dd.size for dd in self.dimensions.dimensions.values()]
284
+ #
285
+ # # Compute the total size of the parameter
286
+ # return functools.reduce(lambda x, y: x * y, arr_shp)
287
+
288
+ @property
289
+ def xml(self) -> xmlET.Element:
290
+ """Return the xml metadata for the parameter as an xml Element.
291
+
292
+ :returns: xml element of parameter metadata
293
+ """
294
+ param_root = xmlET.Element('parameter')
295
+ param_root.set('name', cast(str, self.name))
296
+ param_root.set('version', 'ver')
297
+ param_root.append(self.dimensions.xml)
298
+ return param_root
299
+
300
+ def all_equal(self) -> bool:
301
+ """Check if all values for parameter are equal.
302
+
303
+ :returns true if all values are equal
304
+ """
305
+
306
+ if self.data_raw.size > 1:
307
+ return (self.data_raw == self.data_raw[0]).all() # type: ignore
308
+
309
+ return True # scalar
310
+
311
+ def check(self) -> str:
312
+ """Verifies the total size of the data for the parameter matches the total declared dimension(s) size
313
+ and returns a message.
314
+
315
+ :returns: OK for valid data size, BAD for invalid data size
316
+ """
317
+
318
+ # TODO: check that values are between min and max values
319
+ # Check a variable to see if the number of values it has is
320
+ # consistent with the given dimensions
321
+ if self.has_correct_size():
322
+ # The number of values for the defined dimensions match
323
+ return f'{self.name}: OK'
324
+ else:
325
+ return f'{self.name}: BAD'
326
+
327
+ def check_values(self) -> bool:
328
+ """Returns true if all data values are within the min/max values for the parameter.
329
+
330
+ :returns: true when all values are within the valid min/max range for the parameter
331
+ """
332
+ # if self.__data is not None:
333
+ minval = self.meta.get('minimum', None)
334
+ maxval = self.meta.get('maximum', None)
335
+
336
+ if minval is not None and maxval is not None:
337
+ # Check both ends of the range
338
+ if not (isinstance(minval, str) or isinstance(maxval, str)):
339
+ return (self.data_raw >= minval).all() and (self.data_raw <= maxval).all().item()
340
+ elif minval == 'bounded':
341
+ return (self.data_raw >= self.meta.get('default')).all().item() # type: ignore
342
+
343
+ return True
344
+
345
+ def has_correct_size(self) -> bool:
346
+ """Verifies the total size of the data for the parameter matches the total declared dimension(s) sizes.
347
+
348
+ :returns: true if size of parameter data matches declared size of dimensions
349
+ """
350
+
351
+ # Get the defined size for each dimension used by the variable
352
+ total_size = 1
353
+ for dd in self.dimensions.keys():
354
+ total_size *= self.dimensions.get(dd).size
355
+
356
+ return self.data_raw.size == total_size
357
+
358
+ def is_hru_param(self) -> bool:
359
+ """Test if parameter is dimensioned by HRU.
360
+
361
+ :returns: true if parameter is dimensioned by nhru, ngw, or nssr
362
+ """
363
+
364
+ return not set(self.meta.get('dimensions', [])).isdisjoint({'nhru', 'ngw', 'nssr'})
365
+
366
+ def is_poi_param(self) -> bool:
367
+ """Test if parameter is dimensioned by nsegment
368
+
369
+ :returns: true if parameter is dimensioned by npoigages
370
+ """
371
+
372
+ return not set(self.meta.get('dimensions', [])).isdisjoint({'npoigages'})
373
+
374
+ def is_seg_param(self) -> bool:
375
+ """Test if parameter is dimensioned by nsegment.
376
+
377
+ :returns: true if parameter is dimensioned by nsegment"""
378
+
379
+ return not set(self.meta.get('dimensions', [])).isdisjoint({'nsegment'})
380
+
381
+ def outliers(self) -> Outliers:
382
+ """Returns the number of values less than or greater than the valid range
383
+
384
+ :returns: NamedTuple containing count of values less than and values greater than valid range
385
+ """
386
+ # Outliers = namedtuple('Outliers', ['name', 'under', 'over'])
387
+
388
+ values_under = 0
389
+ values_over = 0
390
+
391
+ if self.meta.get('minimum', None) is not None:
392
+ values_under = np.count_nonzero(self.data_raw < self.meta.get('minimum')) # type: ignore
393
+
394
+ if self.meta.get('maximum', None) is not None:
395
+ values_over = np.count_nonzero(self.data_raw > self.meta.get('maximum')) # type: ignore
396
+
397
+ return Outliers(self.__name, values_under, values_over)
398
+
399
+ def remove_by_index(self, dim_name: str, indices: List[int]):
400
+ """Remove columns (nhru or nsegment) from data array given a list of indices.
401
+
402
+ :param dim_name: Name of dimension to reduce
403
+ :param indices: List of indices to remove"""
404
+
405
+ if isinstance(indices, type(dict().values())):
406
+ indices = list(indices)
407
+
408
+ if self.__data is not None:
409
+ if len(indices) > self.__data.size:
410
+ raise IndexError(f'{self.name}: Cannot remove more values than exist')
411
+
412
+ self.__data = np.delete(self.__data, indices, axis=self.dimensions.get_position(dim_name))
413
+ assert self.__data is not None # Needed so mypy doesn't fail on next line
414
+ self.dimensions[dim_name].size = self.__data.shape[self.dimensions.get_position(dim_name)]
415
+ else:
416
+ raise TypeError('Parameter data is not initialized')
417
+
418
+ # def reshape(self, new_dims: Dict):
419
+ # """Reshape a parameter, broadcasting existing values as necessary.
420
+ #
421
+ # :param new_dims: Dimension names and sizes that will be used to reshape the parameter data
422
+ # """
423
+ #
424
+ # if self.__data is None:
425
+ # # Reshape has no meaning if there is no data to reshape
426
+ # return
427
+ #
428
+ # if self.dimensions.ndim == 1:
429
+ # if 'one' in self.dimensions.keys():
430
+ # # Reshaping from a scalar to a 1D or 2D array
431
+ # # print('Scalar to 1D or 2D')
432
+ # new_sizes = [vv.size for vv in new_dims.values()]
433
+ # tmp_data = np.broadcast_to(self.__data, new_sizes)
434
+ #
435
+ # # Remove the original dimension
436
+ # self.dimensions.remove('one')
437
+ #
438
+ # # Add the new ones
439
+ # for kk, vv in new_dims.items():
440
+ # self.dimensions.add(kk, vv.size)
441
+ #
442
+ # self.__data = tmp_data
443
+ # elif set(self.dimensions.keys()).issubset(set(new_dims.keys())):
444
+ # # Reschaping a 1D to a 2D
445
+ # if len(new_dims) == 1:
446
+ # print('ERROR: Cannot reshape from 1D array to 1D array')
447
+ # else:
448
+ # # print('1D array to 2D array')
449
+ # new_sizes = [vv.size for vv in new_dims.values()]
450
+ # try:
451
+ # tmp_data = np.broadcast_to(self.__data, new_sizes)
452
+ # except ValueError:
453
+ # # operands could not be broadcast together with remapped shapes
454
+ # tmp_data = np.broadcast_to(self.__data, new_sizes[::-1]).T
455
+ #
456
+ # old_dim = list(self.dimensions.keys())[0]
457
+ # self.dimensions.remove(old_dim)
458
+ #
459
+ # for kk, vv in new_dims.items():
460
+ # self.dimensions.add(kk, vv.size)
461
+ #
462
+ # self.__data = tmp_data
463
+
464
+ def stats(self) -> Stats:
465
+ """Returns basic statistics on parameter values.
466
+
467
+ :returns: None (for strings or no data) or NamedTuple containing min, max, mean, and median of parameter values
468
+ """
469
+ # Stats = namedtuple('Stats', ['name', 'min', 'max', 'mean', 'median'])
470
+
471
+ try:
472
+ return Stats(self.__name, np.min(self.data_raw), np.max(self.data_raw),
473
+ np.mean(self.data_raw), np.median(self.data_raw)) # type: ignore
474
+ except TypeError:
475
+ # This happens with string data
476
+ return Stats(self.__name, None, None, None, None)
477
+
478
+ def subset_by_index(self, dim_name: str, indices):
479
+ """Reduce array by axis (nhru or nsegment) a list of local indices.
480
+
481
+ :param dim_name: name of dimension
482
+ :param indices: local indices of HRUs or segments to extract"""
483
+
484
+ if isinstance(indices, type(dict().values())):
485
+ indices = list(indices)
486
+
487
+ if self.dimensions[dim_name].is_fixed:
488
+ raise FixedDimensionError(f'{self.name}: Cannot reduce array on a fixed dimension {dim_name}')
489
+
490
+ # First get the index position of the given dimension name so data
491
+ # won't be changed if the dimension name does not exist
492
+ dim_idx = self.dimensions.get_position(dim_name)
493
+
494
+ # We can't use the data setter when modifying the shape of parameter data
495
+ self.__data = np.take(self.data_raw, indices, axis=dim_idx)
496
+ assert self.data_raw is not None # Needed so mypy doesn't fail on next line
497
+ self.dimensions[dim_name].size = self.data_raw.shape[dim_idx]
498
+
499
+ def tolist(self) -> List[Union[int, float, str]]:
500
+ """Returns the parameter data as a list.
501
+
502
+ :returns: Parameter data
503
+ """
504
+
505
+ # TODO: is this correct for snarea_curve?
506
+ # Return a list of the data
507
+ return self.data_raw.ravel(order='F').tolist()
508
+
509
+ def toparamdb(self) -> str:
510
+ """Outputs parameter data in the paramDb csv format.
511
+
512
+ :returns: parameter data in the paramDb CSV format
513
+ """
514
+
515
+ outstr = '$id,{}\n'.format(self.name)
516
+
517
+ ii = 0
518
+ # Do not use self.tolist() here because it causes minor changes
519
+ # to the values for floats.
520
+ for dd in self.data_raw.ravel(order='F'):
521
+ if self.meta.get('datatype', 'null') in ['float32', 'float64']:
522
+ # Float and double types have to be formatted specially so
523
+ # they aren't written in exponential notation or with
524
+ # extraneous zeroes
525
+ tmp = f'{dd:<20.7f}'.rstrip('0 ')
526
+ if tmp[-1] == '.':
527
+ tmp += '0'
528
+ outstr += f'{ii+1},{tmp}\n'
529
+ else:
530
+ outstr += f'{ii+1},{dd}\n'
531
+ ii += 1
532
+ return outstr
533
+
534
+ def tostructure(self) -> dict:
535
+ """Returns a dictionary structure of the parameter.
536
+
537
+ This is typically used for serializing parameters.
538
+
539
+ :returns: dictionary structure of the parameter
540
+ """
541
+
542
+ # Return all information about this parameter in the following form
543
+ param = {'name': self.name,
544
+ 'datatype': self.meta.get('datatype', 'null'),
545
+ 'dimensions': self.dimensions.tostructure(),
546
+ 'data': self.tolist()}
547
+ return param
548
+
549
+ def unique(self) -> Optional[npt.NDArray]:
550
+ """Create array of unique values from the parameter data.
551
+
552
+ :returns: Array of unique values
553
+ """
554
+ return np.unique(self.data_raw)
555
+
556
+ def update_element(self, index: int, value: Union[int, float, List[int], List[float]]):
557
+ """Update single value or row of values (e.g. nhru by nmonths) for a
558
+ given local zero-based index in the parameter data array.
559
+
560
+ :param index: scalar, zero-based array index
561
+ :param value: updated value(s)
562
+ """
563
+
564
+ # NOTE: index is zero-based
565
+ # Update a single element or single row (e.g. nhru x nmonth) in the
566
+ # parameter data array.
567
+ if self.is_scalar:
568
+ if isinstance(value, list):
569
+ if len(value) > 1:
570
+ raise TypeError(f'{self.name}: Cannot update scalar with list containing multiple values')
571
+ value = value[0]
572
+ elif isinstance(value, np.ndarray):
573
+ if value.size > 1:
574
+ raise TypeError(f'{self.name}: Cannot update scalar with array containing multiple values')
575
+ value = value.item()
576
+
577
+ if self.data != value:
578
+ # We use the data setter to make sure the new scalar is cast to a numpy array internally
579
+ self.data = value # type: ignore
580
+ self.__modified = True
581
+ else:
582
+ if self.data_raw.ndim == 1:
583
+ if isinstance(value, list):
584
+ if len(value) > 1:
585
+ raise TypeError(f'{self.name}: Cannot update single element with list '
586
+ f'containing multiple values')
587
+ value = value[0]
588
+ elif isinstance(value, np.ndarray):
589
+ if value.size > 1:
590
+ raise TypeError(f'{self.name}: Cannot update single element with array '
591
+ f'containing multiple values')
592
+ value = value.item()
593
+ elif self.data_raw.ndim == 2:
594
+ if isinstance(value, list):
595
+ if len(value) == 1:
596
+ value = value[0]
597
+ elif len(value) != self.data_raw.shape[1]:
598
+ raise TypeError(f'{self.name}: Cannot update row with list of incorrect size')
599
+ elif isinstance(value, np.ndarray):
600
+ if value.size == 1:
601
+ value = value.item()
602
+ elif value.size != self.data_raw.shape[1]:
603
+ raise TypeError(f'{self.name}: Cannot update row with array of incorrect size')
604
+
605
+ if not np.array_equal(self.__data[index], value): # type: ignore
606
+ # Change the element only if the incoming value is different
607
+ # from the existing value
608
+ self.__data[index] = value # type: ignore
609
+ self.__modified = True
610
+
611
+ def _value_index_1d(self, value: Union[int, float, str]) -> npt.NDArray:
612
+ """Given a scalar value return the indices where there is a match.
613
+
614
+ :param value: The value to find in the parameter data array
615
+
616
+ :returns: Array of zero-based indices matching the given value
617
+ """
618
+
619
+ if self.ndim == 1:
620
+ # Returns a list of indices where the data elements match value
621
+ return np.argwhere(self.data_raw == value)[:, 0] # .tolist()
622
+ # return np.where(self.data_raw == value)[0]
623
+ else:
624
+ raise TypeError(f'{self.name}: Cannot search for value in multi-dimensional array')