hydrating 0.0.3__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.
hydrating/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # -*- coding: utf-8 -*-
2
+ from .core.hydrating import RatingCurve as RatingCurve
File without changes
@@ -0,0 +1,663 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ RatingCurve class.
4
+ """
5
+
6
+ from typing import Optional
7
+
8
+ import lmfit
9
+ import matplotlib.pyplot as plt
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+ from hydrating.models import RatingModel
14
+
15
+ # from .grades import Grades
16
+
17
+
18
+ class RatingCurve:
19
+ """
20
+ Represent and fit a hydrologic rating curve.
21
+
22
+ A rating curve describes the relationship between stage (water level)
23
+ and discharge (flow). This class wraps a rating-model implementation,
24
+ manages initial parameter values, optionally stores observed data in a
25
+ DataFrame, and fits the selected model using ``lmfit`` package.
26
+
27
+ Parameters
28
+ ----------
29
+ model : RatingModel
30
+ RatingModel class used to create the underlying model and
31
+ parameters. The class is instantiated internally.
32
+ initial_parameters : dict or lmfit.Parameters, optional
33
+ Initial parameter values for the model. If a dictionary is
34
+ provided, it is converted to ``lmfit.Parameters`` using the
35
+ selected rating model. If omitted, default parameters are created.
36
+ It is recommended to provide initial parameter values for the model,
37
+ as they are often needed for successful fitting.
38
+ rating_name : str, optional
39
+ Optional name identifier for the rating curve.
40
+
41
+ Attributes
42
+ ----------
43
+ rating_name : str
44
+ User supplied name for the rating curve.
45
+ rating_model : RatingModel
46
+ Instantiated rating-model object used for fitting and prediction.
47
+ initial_parameters : lmfit.Parameters
48
+ Parameter set used as the starting point for fitting.
49
+ fit_result : lmfit.model.ModelResult or None
50
+ Result returned by the most recent fit.
51
+ stage_series : pandas.Series or numpy.ndarray or None
52
+ Optional stage time series used for prediction of discharge.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ model: RatingModel,
58
+ initial_parameters: dict | lmfit.Parameters | None = None,
59
+ rating_name: str = "",
60
+ ):
61
+ """
62
+ Initialize a rating curve.
63
+
64
+ Parameters
65
+ ----------
66
+ model : RatingModel
67
+ RatingModel class used to create the underlying model instance,
68
+ parameters, fitted values, and predictions. The class is
69
+ instantiated internally.
70
+ initial_parameters : dict or lmfit.Parameters, optional
71
+ Initial parameter values for the rating model. Dictionaries are
72
+ converted to ``lmfit.Parameters`` by the selected rating model. If
73
+ omitted, the rating model's default parameters are used. It is recommended
74
+ to provide initial parameter values for the rating model, as they are often
75
+ needed for successful fitting.
76
+ rating_name : str, optional
77
+ Name identifier for the rating curve.
78
+
79
+ Raises
80
+ ------
81
+ ValueError
82
+ If ``initial_parameters`` is not a dictionary, ``lmfit.Parameters``,
83
+ or ``None``.
84
+ """
85
+
86
+ self.rating_name = rating_name
87
+ self.rating_model = model # instance is created in the setter
88
+ self.initial_parameters = initial_parameters # done by model setter
89
+
90
+ # self.rc_grade = None # grade stage intervals with Enum grades
91
+ # def set_grades():
92
+ # ...# @setter/getter properties?
93
+
94
+ # self.rc_limit = None # tuple, min, max stage its applicable
95
+ # self.rc_period = None # tuple (datetime-like to from validity)
96
+
97
+ self.fit_result = None
98
+
99
+ self.stage_series = (
100
+ None # timeseries of stage, can be used to show extrapolation
101
+ )
102
+ # of the rating curve, when comparing rating curves
103
+
104
+ self._dataf = None # user can add a dataframe using add_data, but this is strictly not needed
105
+ # can also just call .fit() with two numpy arrays
106
+ self._user_df_added = False
107
+
108
+ # def set_limits():
109
+ # ... #@setter/getter properties?
110
+ # def set_valid_periods():
111
+ # ... #@setter/getter properties?
112
+
113
+ # or use property??
114
+ # rc.model = Callable
115
+ # also for parameter
116
+ # rc.parameter = dict # overwrites the default
117
+ @property
118
+ def rating_model(self): # numpydoc ignore=GL08
119
+ return self._rating_model
120
+
121
+ @rating_model.setter
122
+ def rating_model(self, model: RatingModel): # numpydoc ignore=GL08
123
+ self._rating_model = model()
124
+
125
+ @property
126
+ def initial_parameters(self): # numpydoc ignore=GL08
127
+ return self._initial_parameters
128
+
129
+ @initial_parameters.setter
130
+ def initial_parameters(self, params):
131
+ """
132
+ Set the initial parameters for the rating model.
133
+
134
+ Parameters
135
+ ----------
136
+ params : dict or lmfit.Parameters or None
137
+ If a dictionary is provided, it is converted to ``lmfit.Parameters``
138
+ using the selected rating model. If omitted, default parameters are
139
+ created. It is recommended to provide initial parameter values for the
140
+ model, as they are often needed for successful fitting.
141
+
142
+ Raises
143
+ ------
144
+ ValueError
145
+ If ``params`` is not a dictionary, ``lmfit.Parameters``, or ``None``.
146
+ """
147
+ model = self.rating_model.create_model()
148
+ if isinstance(params, dict):
149
+ self._initial_parameters = self.rating_model.create_parameters(
150
+ model, params
151
+ )
152
+ elif isinstance(params, lmfit.Parameters):
153
+ self._initial_parameters = params
154
+ elif params is None:
155
+ self._initial_parameters = self.rating_model.create_parameters(model)
156
+ else:
157
+ raise ValueError()
158
+
159
+ # Check the parameters
160
+
161
+ # @property
162
+ # def grade(self):
163
+ # return self._grade
164
+
165
+ # @grade.setter
166
+ # def grade(self, value):
167
+ # # [{'from': 1.5,
168
+ # # 'to': 2.5,
169
+ # # 'grade': Grades.Good},] ?
170
+ # self._grade = value
171
+
172
+ def add_data(
173
+ self,
174
+ data: pd.DataFrame,
175
+ stage: str,
176
+ discharge: str,
177
+ datetime_start: Optional[str] = None, # optional, key in data df
178
+ datetime_end: Optional[str] = None, # optional, key in data df
179
+ method: Optional[str] = None, # optional, key in data df
180
+ party: Optional[str] = None, # optional, key in data df
181
+ agency: Optional[str] = None, # optional, key in data df
182
+ uncertainty: Optional[str] = None, # optional, key in data df
183
+ grade: Optional[str] = None, # optional, key in data df
184
+ enabled: Optional[str] = None, # optional, key in data df
185
+ note: Optional[str] = None, # optional, key in data df
186
+ identifier: Optional[str] = None,
187
+ ): # optional, key in data df
188
+ """
189
+ Add stage-discharge measurements to the rating curve.
190
+
191
+ Several keys for columns in the provided dataframe can be specified, but only
192
+ the stage and discharge columns are required. The other columns are optional
193
+ but can be used to store metadata. The ``enabled`` column is added automatically
194
+ if not provided, and flags if a measurement is used or not in the fitting.
195
+
196
+ Parameters
197
+ ----------
198
+ data : pandas.DataFrame
199
+ Dataframe containing observed stage, discharge, and optional
200
+ metadata columns. A copy is stored internally.
201
+ stage : str
202
+ Column name in ``data`` containing stage values.
203
+ discharge : str
204
+ Column name in ``data`` containing discharge values.
205
+ datetime_start : str, optional
206
+ Column name in ``data`` containing observation start datetimes.
207
+ datetime_end : str, optional
208
+ Column name in ``data`` containing observation end datetimes.
209
+ method : str, optional
210
+ Column name in ``data`` containing measurement methods.
211
+ party : str, optional
212
+ Column name in ``data`` containing measurement parties.
213
+ agency : str, optional
214
+ Column name in ``data`` containing agencies responsible for the
215
+ measurements.
216
+ uncertainty : str, optional
217
+ Column name in ``data`` containing measurement uncertainty values.
218
+ grade : str, optional
219
+ Column name in ``data`` containing observation grades.
220
+ enabled : str, optional
221
+ Column name in ``data`` containing flags for whether observations
222
+ are enabled. If omitted, an ``enabled`` column with ``True`` values
223
+ is added to the internally stored dataframe.
224
+ note : str, optional
225
+ Column name in ``data`` containing observation notes.
226
+ identifier : str, optional
227
+ Column name in ``data`` containing observation identifiers.
228
+ """
229
+
230
+ self._k_discharge = discharge
231
+ self._k_stage = stage
232
+ self._k_datetime_start = datetime_start
233
+ self._k_datetime_end = datetime_end
234
+ self._k_method = method
235
+ self._k_party = party
236
+ self._k_agency = agency
237
+ self._k_uncertainty = uncertainty
238
+ self._k_grade = grade
239
+ self._k_enabled = enabled
240
+ self._k_note = note
241
+ self._k_identifer = identifier
242
+
243
+ # take the data DataFrame and make a copy
244
+ # keep all columns of DataFrame
245
+ # create a new columns "enabled" for True/False flag to turn on off
246
+ self._dataf = data.copy()
247
+ if self._k_enabled is None:
248
+ self._k_enabled = "enabled"
249
+ self._dataf["enabled"] = True
250
+
251
+ self._user_df_added = True
252
+
253
+ # TODO use a setter/getter for dataf in addition to this function to add_data?
254
+
255
+ def _user_data_from_fit(self, x, y):
256
+ """
257
+ If data is passed to fit() call then create _dataf here if the user
258
+ has not done so themselves by calling add_data().
259
+
260
+ FIXME: this needs improvement, will not work if user has added data with add_data()
261
+ and then calls fit() with different data, it will overwrite the user added data.
262
+ Maybe should check if _dataf is not None and raise an error if user is trying
263
+ to add data through fit() when they have already added data with add_data()?
264
+
265
+ Parameters
266
+ ----------
267
+ x : np.ndarray or pd.Series
268
+ Stage data passed to fit() call.
269
+ y : np.ndarray or pd.Series
270
+ Discharge data passed to fit() call.
271
+ """
272
+
273
+ self._k_stage = "stage"
274
+ self._k_discharge = "discharge"
275
+ self._dataf = pd.DataFrame(data={self._k_stage: x, self._k_discharge: y})
276
+
277
+ # fit uses the model and parameters
278
+ # FIXME need to handle this better, if user has added data with add_data() and then calls fit() with different data, it will overwrite the user added data. Maybe should check if _dataf is not None and raise an error if user is trying to add data through fit() when they have already added data with add_data()?
279
+ # data should be passed to fit() call, or when initialized maybe better
280
+ # try to adopt a scikit-learn like API?
281
+ # add_data should probably be removed, deprecate that
282
+ def fit(
283
+ self,
284
+ x: pd.Series = None, # FIXME np.ndarray, pd.Series or str for key in dataf
285
+ y: pd.Series = None,
286
+ engine: str = "lmfit",
287
+ weights: pd.Series = None,
288
+ **kwargs,
289
+ ):
290
+ """
291
+ Fit the rating curve to observed stage-discharge data.
292
+
293
+ Parameters
294
+ ----------
295
+ x : pandas.Series or numpy.ndarray or str, optional
296
+ Stage values used for fitting. If a string is provided, it is used
297
+ as a column name in the dataframe added with :meth:`add_data`. If
298
+ omitted, the stage column configured by :meth:`add_data` is used.
299
+ y : pandas.Series or numpy.ndarray or str, optional
300
+ Discharge values used for fitting. If a string is provided, it is
301
+ used as a column name in the dataframe added with :meth:`add_data`.
302
+ If omitted, the discharge column configured by :meth:`add_data` is
303
+ used.
304
+ engine : {"lmfit"}, default: "lmfit"
305
+ Fitting engine to use. Currently only ``"lmfit"`` is supported.
306
+ weights : pandas.Series, optional
307
+ Observation weights passed to ``lmfit.Model.fit``. If omitted, all
308
+ observations are weighted equally.
309
+ **kwargs
310
+ Additional keyword arguments passed to ``lmfit.Model.fit``.
311
+
312
+ Raises
313
+ ------
314
+ NotImplementedError
315
+ If ``engine`` is not ``"lmfit"``.
316
+
317
+ Notes
318
+ -----
319
+ The fitted result is stored in ``fit_result`` and the best-fit
320
+ parameter values are stored in ``fit_best_parameters``. Fit residuals
321
+ and percent errors are stored internally for plotting.
322
+ """
323
+
324
+ # Note: cannot use self. in default arguments as they are eval'ed at
325
+ # creation time
326
+ if x is None:
327
+ # try:
328
+ xf = self._dataf[self._k_stage].to_numpy()
329
+ # except: # TODO what error is this if dataf is None?
330
+ # ... # message to add data or pass data to fit
331
+ elif isinstance(x, str):
332
+ xf = self._dataf[x].to_numpy()
333
+ else: # is np.ndarray or Series, check for that?
334
+ xf = x.copy()
335
+
336
+ if y is None:
337
+ yf = self._dataf[self._k_discharge].to_numpy()
338
+ elif isinstance(y, str):
339
+ yf = self._dataf[y].to_numpy()
340
+ else: # is np.ndarray or Series, check for that?
341
+ yf = y.copy()
342
+
343
+ if not self._user_df_added:
344
+ self._user_data_from_fit(xf, yf)
345
+
346
+ if engine != "lmfit":
347
+ raise NotImplementedError(
348
+ f"{engine} is not supported, only lmfit is currently supported"
349
+ )
350
+
351
+ # lmfit engine
352
+ lmfit_model = self.rating_model.create_model()
353
+ lmfit_init_pars = self.initial_parameters
354
+ lmfit_weights = np.ones(len(yf)) if weights is None else weights.to_numpy()
355
+
356
+ # constrain parameters as defined in RatingModel
357
+ lmfit_init_pars = self.rating_model.constrain_pars_with_obs(
358
+ xf, yf, lmfit_init_pars
359
+ )
360
+
361
+ result = lmfit_model.fit(
362
+ data=yf, params=lmfit_init_pars, weights=lmfit_weights, h=xf, **kwargs
363
+ ) # test with using kwargs = {'method':'differential_evolution'}
364
+
365
+ self._fit_obs_data = pd.DataFrame(data=yf, index=xf, columns=["observed"])
366
+
367
+ self.fit_result = result
368
+ self.fit_best_parameters = result.best_values
369
+
370
+ self._calc_fit_residuals()
371
+
372
+ def _calc_fit_residuals(self): # numpydoc ignore=GL08
373
+ self._fit_obs_data["predicted"] = self.predict(stage=self._fit_obs_data.index)
374
+ self._fit_obs_data["residual"] = (
375
+ self._fit_obs_data["predicted"] - self._fit_obs_data["observed"]
376
+ )
377
+ self._fit_obs_data["percent_error"] = (
378
+ self._fit_obs_data["residual"] / self._fit_obs_data["observed"] * 100
379
+ )
380
+
381
+ def add_stage_series(self, stage_series): # numpydoc ignore=PR01
382
+ """Set the stage series to be used for prediction and plotting."""
383
+ self.stage_series = stage_series.copy()
384
+
385
+ def predict(self, stage=None):
386
+ """
387
+ Predict discharge at a given stage or array-like of stages using the fitted model.
388
+
389
+ Parameters
390
+ ----------
391
+ stage : float or array-like, optional
392
+ Stage value(s) at which to predict discharge. If omitted, the stage series added
393
+ with :meth:`add_stage_series` is used.
394
+ If no stage series is available, ``None`` is returned.
395
+
396
+ Returns
397
+ -------
398
+ float or np.ndarray
399
+ Discharge values predicted at the given stage(s). If no stage series is available,
400
+ ``None`` is returned.
401
+ """
402
+ if stage is None and self.stage_series is not None:
403
+ return self.rating_model.func(self.stage_series, **self.fit_best_parameters)
404
+ if stage is not None:
405
+ return self.rating_model.func(stage, **self.fit_best_parameters)
406
+ # FIXME raise
407
+ return None
408
+
409
+ def inverse(self, discharge, initial_guess=None):
410
+ """
411
+ Inverse the rating curve to find the stage at a given discharge.
412
+
413
+ Parameters
414
+ ----------
415
+ discharge : float or array-like
416
+ Discharge value(s) at which to predict stage.
417
+ initial_guess : float or array-like, optional
418
+ Initial guess for the stage value(s) corresponding to the given discharge value(s).
419
+ This is required for models that do not have an inverse method.
420
+ """
421
+ raise NotImplementedError()
422
+ # if model has inverse attribute, use that, else inverse it with minimizing
423
+ # this requires an initial guess
424
+ self.rating_model.inverse(
425
+ discharge, initial_guess, **self.fit_result.best_values
426
+ )
427
+
428
+ def fit_summary(self):
429
+ """Print a summary of the fitted model parameters and results."""
430
+ print(self.fit_result.fit_report())
431
+
432
+ def plot_residuals(
433
+ self,
434
+ scale="linear",
435
+ label_discharge="Discharge",
436
+ label_stage="Stage",
437
+ stage_on_y=False,
438
+ point_labels=None,
439
+ ):
440
+ """
441
+ Plot residuals and model performance for a fitted rating curve.
442
+
443
+ This function generates a plot to visualize the observed and predicted data, as well
444
+ as the residuals and percentage errors for the rating curve model fit. It supports optional
445
+ custom scaling for the axes and labeling.
446
+
447
+ Parameters
448
+ ----------
449
+ scale : str, optional
450
+ The scale type to be used for the y-axis of the observed/predicted plot and the x-axis
451
+ of the residual and percent error plots. Default is "linear".
452
+ label_discharge : str, optional
453
+ Label for the discharge axis on the plots. Default is "Discharge".
454
+ label_stage : str, optional
455
+ Label for the stage axis on the plots. Default is "Stage".
456
+ stage_on_y : bool, optional
457
+ If True, plots stage on the y-axis and discharge on the x-axis. Default is False.
458
+ point_labels : str, optional
459
+ Column name in the fitted data DataFrame to use for labeling points in the plots. If
460
+ provided, labels will be added to the observed data points in the first subplot.
461
+
462
+ Returns
463
+ -------
464
+ tuple
465
+ A tuple containing:
466
+ - fig: matplotlib.figure.Figure
467
+ The overall figure object for the plots.
468
+ - axes: numpy.ndarray of matplotlib.axes.Axes
469
+ An array of axes objects for the three subplots.
470
+
471
+ Raises
472
+ ------
473
+ ValueError
474
+ If the rating curve is not fitted before calling this method (i.e., `fit_result` is None).
475
+
476
+ Notes
477
+ -----
478
+ - The first subplot displays the observed vs predicted data.
479
+ - The second subplot shows the absolute residuals.
480
+ - The third subplot illustrates the percent error.
481
+ - If `stage_series` is provided, it defines the span of the stage axis; otherwise, the
482
+ minimum and maximum observed stage values from the fitted data are used.
483
+ - When `stage_on_y` is False (default), stage is the x-axis and discharge is the y-axis
484
+ for the first subplot, while other subplots share the x-axis (stage).
485
+ - Adjusts y-limits of the residual and percent error subplots for symmetric bounds.
486
+ """
487
+ if self.fit_result is None:
488
+ ValueError(
489
+ "Rating curve is not fitted, call .fit() first."
490
+ ) # TODO custom exception
491
+
492
+ if self.stage_series is not None:
493
+ min_stage = self.stage_series.min()
494
+ max_stage = self.stage_series.max()
495
+ else:
496
+ min_stage = self._fit_obs_data.index.min()
497
+ max_stage = self._fit_obs_data.index.max()
498
+
499
+ stage_plt = np.linspace(min_stage, max_stage, num=100)
500
+ q_plt = self.predict(stage=stage_plt)
501
+
502
+ fig, axes = plt.subplots(3, 1, sharex=True)
503
+
504
+ if stage_on_y:
505
+ y_plt = stage_plt
506
+ x_plt = q_plt
507
+
508
+ y_pts = self._fit_obs_data.index.to_numpy()
509
+ x_pts = self._fit_obs_data["observed"]
510
+
511
+ y_label = label_stage
512
+ x_label = label_discharge
513
+ else:
514
+ y_plt = q_plt
515
+ x_plt = stage_plt
516
+
517
+ y_pts = self._fit_obs_data["observed"]
518
+ x_pts = self._fit_obs_data.index.to_numpy()
519
+
520
+ x_label = label_stage
521
+ y_label = label_discharge
522
+
523
+ axes[0].plot(x_pts, y_pts, "ko")
524
+ axes[0].plot(x_plt, y_plt, "k-")
525
+
526
+ axes[1].axhline(0, color="k", linewidth=0.5)
527
+ axes[1].plot(x_pts, self._fit_obs_data["residual"], "ko")
528
+
529
+ axes[2].axhline(0, color="k", linewidth=0.5)
530
+ axes[2].plot(x_pts, self._fit_obs_data["percent_error"], "ko")
531
+
532
+ axes[0].set_yscale(scale)
533
+ axes[2].set_xscale(scale)
534
+
535
+ axes[0].set_ylabel(y_label)
536
+ axes[1].set_ylabel("Absolute error")
537
+ axes[2].set_ylabel("Percent error")
538
+ axes[2].set_xlabel(x_label)
539
+
540
+ axes[1].set_ylim(
541
+ (-max(np.abs(axes[1].get_ylim())), max(np.abs(axes[1].get_ylim())))
542
+ )
543
+ axes[2].set_ylim(
544
+ (-max(np.abs(axes[2].get_ylim())), max(np.abs(axes[2].get_ylim())))
545
+ )
546
+
547
+ return fig, axes
548
+
549
+ def plot(
550
+ self,
551
+ scale="linear",
552
+ label_discharge="Discharge",
553
+ label_stage="Stage",
554
+ stage_on_y=False,
555
+ point_labels=None,
556
+ cross_section=False,
557
+ ):
558
+ """
559
+ Plot the fitted rating curve and observational data.
560
+
561
+ Parameters
562
+ ----------
563
+ scale : str, optional
564
+ A string indicating the type of scaling for the axes. Default is "linear".
565
+ label_discharge : str, optional
566
+ Label for the discharge axis. Default is "Discharge".
567
+ label_stage : str, optional
568
+ Label for the stage axis. Default is "Stage".
569
+ stage_on_y : bool, optional
570
+ If True, stage is plotted along the y-axis; otherwise, discharge is plotted on
571
+ the y-axis. Default is False.
572
+ point_labels : str, optional
573
+ Labels for the individual data points on the plot. Default is None.
574
+ cross_section : bool, optional
575
+ Plots the cross-section profile if available. If True, forces stage to be plotted on the y-axis, overriding the `stage_on_y`
576
+ parameter. Default is False.
577
+
578
+ Returns
579
+ -------
580
+ tuple
581
+ Contains:
582
+ - fig : matplotlib.figure.Figure
583
+ The matplotlib figure object containing the plot.
584
+ - ax : matplotlib.axes._axes.Axes
585
+ The matplotlib axes object for the plot.
586
+
587
+ Raises
588
+ ------
589
+ ValueError
590
+ If the rating curve has not been fitted using the `.fit()` method.
591
+
592
+ Notes
593
+ -----
594
+ This function assumes that the rating curve has been fitted prior to calling it.
595
+ It also calculates the range of the stage series for generating the plot points
596
+ if such data is available. Otherwise, it derives the range from the fitted
597
+ observational data.
598
+ """
599
+ if self.fit_result is None:
600
+ ValueError(
601
+ "Rating curve is not fitted, call .fit() first."
602
+ ) # TODO custom exception
603
+
604
+ if self.stage_series is not None:
605
+ min_stage = self.stage_series.min()
606
+ max_stage = self.stage_series.max()
607
+ else:
608
+ min_stage = self._fit_obs_data.index.min()
609
+ max_stage = self._fit_obs_data.index.max()
610
+
611
+ stage_plt = np.linspace(min_stage, max_stage, num=100)
612
+ q_plt = self.predict(stage=stage_plt)
613
+
614
+ fig, ax = plt.subplots()
615
+
616
+ if cross_section:
617
+ stage_on_y = True
618
+
619
+ if stage_on_y:
620
+ y_plt = stage_plt
621
+ x_plt = q_plt
622
+
623
+ y_pts = self._fit_obs_data.index.to_numpy()
624
+ x_pts = self._fit_obs_data["observed"]
625
+
626
+ y_label = label_stage
627
+ x_label = label_discharge
628
+ else:
629
+ y_plt = q_plt
630
+ x_plt = stage_plt
631
+
632
+ y_pts = self._fit_obs_data["observed"]
633
+ x_pts = self._fit_obs_data.index.to_numpy()
634
+
635
+ x_label = label_stage
636
+ y_label = label_discharge
637
+
638
+ ax.plot(x_pts, y_pts, "ko")
639
+ ax.plot(x_plt, y_plt, "k-")
640
+
641
+ ax.set_yscale(scale)
642
+ ax.set_xscale(scale)
643
+
644
+ ax.set_ylabel(y_label)
645
+ ax.set_xlabel(x_label)
646
+
647
+ return fig, ax
648
+
649
+ # TODO
650
+ # def plot_shift():
651
+ # ...
652
+
653
+ # TODO
654
+ # def compare(self, other):
655
+ # ...
656
+
657
+ # TODO
658
+ # def add_obs_point(self):
659
+ # ...
660
+
661
+ @staticmethod
662
+ def _dict_from_parameters(parameters): # numpydoc ignore=GL08, PR01
663
+ return parameters.valuesdict()
@@ -0,0 +1,3 @@
1
+ # -*- coding: utf-8 -*-
2
+ from .models import PowerLaw as PowerLaw
3
+ from .models import RatingModel as RatingModel # , VNotchWeir
@@ -0,0 +1,260 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Rating curve models.
4
+ """
5
+
6
+ from typing import Protocol, Union
7
+
8
+ import numpy as np
9
+ from lmfit import Model, Parameters
10
+
11
+
12
+ class RatingModel(Protocol):
13
+ """
14
+ Protocol class for rating curve models.
15
+
16
+ Methods for RatingModel:
17
+
18
+ func: the rating curve equation, with arguments stage and **parameters, returning
19
+ discharge
20
+ create_model: function that returns the lmfit Model
21
+ create_parameters: function that return lmfit Parameters for the model
22
+ constrain_pars_with_obs: function that puts limits on Parameters based on observations
23
+ e.g. zero flow stage cannot exceed observed stage with flow
24
+ inverse: function that return stage for a given discharge, the inverse of func()
25
+ """
26
+
27
+ def func(self, x: np.ndarray, **parameters: float) -> np.ndarray:
28
+ """
29
+ The rating curve equation.
30
+
31
+ Parameters
32
+ ----------
33
+ x : np.ndarray
34
+ Stage values.
35
+ **parameters : float
36
+ Model parameters.
37
+
38
+ Returns
39
+ -------
40
+ float or np.ndarray
41
+ Discharge for the provided stage.
42
+ """
43
+ ...
44
+
45
+ def create_model(self) -> Model:
46
+ """
47
+ Create the lmfit Model.
48
+
49
+ Returns
50
+ -------
51
+ Model
52
+ The lmfit Model for the rating curve.
53
+ """
54
+ ...
55
+
56
+ def create_parameters(
57
+ self, model: Model, parameters: Union[None, Parameters, dict]
58
+ ) -> Parameters:
59
+ """
60
+ Create the lmfit Parameters for the rating curve model, and set initial values.
61
+ This function should provide some default values if parameters is None or a parameter is missing.
62
+
63
+ Parameters
64
+ ----------
65
+ model : Model
66
+ The lmfit Model for the rating curve.
67
+ parameters : Union[None, Parameters, dict]
68
+ Initial parameters for the model. Can be None, a dict, or lmfit Parameters.
69
+ """
70
+ ...
71
+
72
+ def constrain_pars_with_obs(
73
+ self, x: np.ndarray, y: np.ndarray, parameters: Parameters
74
+ ) -> Parameters:
75
+ """
76
+ Constraining on the parameters based on x and y values.
77
+ This function should put limits on Parameters based on observations.
78
+ For example, zero flow stage cannot exceed observed stage with flow.
79
+
80
+ Parameters
81
+ ----------
82
+ x : np.ndarray
83
+ Stage values.
84
+ y : np.ndarray
85
+ Discharge values.
86
+ parameters : Parameters
87
+ The lmfit Parameters for the model.
88
+
89
+ Returns
90
+ -------
91
+ Parameters
92
+ The lmfit Parameters with limits set based on observations.
93
+ """
94
+ ...
95
+
96
+ def inverse(
97
+ self, y: np.ndarray, initial_guess: float, **parameters: float
98
+ ) -> np.ndarray:
99
+ """
100
+ Inverse the rating curve to find the stage at a given discharge.
101
+ This method should return the stage corresponding to the given discharge.
102
+
103
+ Parameters
104
+ ----------
105
+ y : np.ndarray
106
+ Discharge values.
107
+ initial_guess : float
108
+ Initial guess for the stage corresponding to the given discharge, used for numerical methods if needed.
109
+ **parameters : float
110
+ Model parameters.
111
+
112
+ Returns
113
+ -------
114
+ np.ndarray
115
+ Stage values corresponding to the given discharge.
116
+ """
117
+ ...
118
+
119
+
120
+ class PowerLaw:
121
+ """
122
+ Power law rating curve model.
123
+ """
124
+
125
+ @classmethod
126
+ def func(cls, h: np.ndarray, a: float, h0: float, b: float) -> np.ndarray:
127
+ """
128
+ The power law rating curve function.
129
+
130
+ .. math::
131
+ Q(h) = a \\times (h-h0)^b
132
+
133
+ where :math:`Q` is discharge, :math:`h` is stage, :math:`h0` is stage at
134
+ zero flow, and :math:`a` and :math:`b` are fitted parameters.
135
+
136
+ Parameters
137
+ ----------
138
+ h : float or np.ndarray
139
+ Stage.
140
+ a : float
141
+ Parameter a.
142
+ h0 : float
143
+ Stage at zero flow.
144
+ b : float
145
+ Parameter b.
146
+
147
+ Returns
148
+ -------
149
+ float or np.ndarray
150
+ Discharge for the provided stage.
151
+ """
152
+ return a * (h - h0) ** b
153
+
154
+ @classmethod
155
+ def create_model(cls):
156
+ """
157
+ Create the lmfit Model.
158
+
159
+ Returns
160
+ -------
161
+ Model
162
+ The lmfit Model for the power law rating curve.
163
+ """
164
+ return Model(cls.func)
165
+
166
+ @classmethod
167
+ def create_parameters(cls, model, parameters=None):
168
+ """
169
+ Create the lmfit Parameters for the power law rating curve model.
170
+ This function sets default values for the parameters if they are missing.
171
+
172
+ Parameters
173
+ ----------
174
+ model : Model
175
+ The lmfit Model for the rating curve.
176
+ parameters : Union[None, Parameters, dict]
177
+ Initial parameters for the model. Can be None, a dict, or lmfit Parameters.
178
+
179
+ Returns
180
+ -------
181
+ Parameters
182
+ The lmfit Parameters for the model, with default values set for missing parameters.
183
+ """
184
+ # default parameters, used if missing from parameters argument
185
+ params_default = model.make_params()
186
+ params_default["h0"].value = 0
187
+ params_default["b"].value = 2
188
+ params_default["a"].value = 1
189
+ if parameters is None:
190
+ return params_default
191
+
192
+ # for dict and Parameters add the missing pars, if any
193
+ for k in set(params_default.keys()) - set(parameters.keys()):
194
+ parameters[k] = params_default.copy()[k]
195
+ if isinstance(parameters, Parameters):
196
+ return parameters
197
+ if isinstance(parameters, dict):
198
+ return model.make_params(**parameters)
199
+
200
+ @classmethod
201
+ def constrain_pars_with_obs(cls, x, y, parameters):
202
+ """
203
+ Constraining on the parameters based on observed values.
204
+ This function sets the maximum value of h0 to the minimum value of x, i.e. stage.
205
+ This is to avoid fitting a rating curve with zero flow stage that is higher than the observed stage with flow, which is not physically possible.
206
+ Avoid using this constrain if the observed stage goes below zero flow (i.e. flow is zero in the timeseries).
207
+
208
+ Parameters
209
+ ----------
210
+ x : np.ndarray
211
+ Stage values.
212
+ y : np.ndarray
213
+ Discharge values. Not used in this function.
214
+ parameters : Parameters
215
+ The lmfit Parameters for the model.
216
+
217
+ Returns
218
+ -------
219
+ Parameters
220
+ The lmfit Parameters with limits set for max h0.
221
+ """
222
+ # check if h0 was already set to a max value that is lower than the
223
+ # limit based on observations
224
+ h0_ceiling = min(np.min(x) - 1e-10, parameters["h0"].max)
225
+ parameters["h0"].max = h0_ceiling
226
+
227
+ return parameters
228
+
229
+ @classmethod
230
+ def inverse(
231
+ cls, y: np.ndarray, initial_guess: float, a: float, h0: float, b: float
232
+ ) -> np.ndarray:
233
+ """
234
+ Inverse the rating curve to find the stage at a given discharge.
235
+
236
+ Parameters
237
+ ----------
238
+ y : np.ndarray
239
+ Discharge values.
240
+ initial_guess : float
241
+ Initial guess not used in this function.
242
+ a : float
243
+ Parameter a.
244
+ h0 : float
245
+ Parameter h0.
246
+ b : float
247
+ Parameter b.
248
+
249
+ Returns
250
+ -------
251
+ np.ndarray
252
+ Stage values corresponding to the given discharge.
253
+ """
254
+
255
+ # todo see what's best, pass parameters dict or individual?
256
+ # a = parameters['a']
257
+ # b = parameters['b']
258
+ # h0 = parameters['h0']
259
+
260
+ return (y / a) ** (1 / b) - h0 # FIXME NOT TESTED WITH h0
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: hydrating
3
+ Version: 0.0.3
4
+ Summary: fitting hydrological stage discharge rating curves
5
+ Project-URL: Changelog, https://github.com/rhkarls/hydrating/blob/main/CHANGELOG.md
6
+ Project-URL: Documentation, https://github.com/rhkarls/hydrating
7
+ Project-URL: Homepage, https://github.com/rhkarls/hydrating
8
+ Project-URL: Issues, https://github.com/rhkarls/hydrating/issues
9
+ Author-email: Reinert Huseby Karlsen <rhkarls@proton.me>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Programming Language :: Python
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: Implementation :: CPython
20
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: lmfit>=1.3.4
23
+ Requires-Dist: matplotlib>=3.10.9
24
+ Requires-Dist: numpy>=2.4.4
25
+ Requires-Dist: pandas>=3.0.2
26
+ Requires-Dist: pyrefly>=0.60.0
@@ -0,0 +1,9 @@
1
+ hydrating/__init__.py,sha256=Mf_6rpsrPSXMg7lXeHGJXGrW9XeEP9NRFMkMZi6kZIs,81
2
+ hydrating/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ hydrating/core/hydrating.py,sha256=-FerU384en74z6uYw9uRqTwuswlssGITzRYLAY0ueZs,25557
4
+ hydrating/models/__init__.py,sha256=t1UQSB3QDap7srRMFxWjEZhnz_Bf20FJBmvGi3-4nGk,131
5
+ hydrating/models/models.py,sha256=1H5nygkcbArwC8jUpx1WCiDYJrLkVIIRTbnPWwoxE_U,8030
6
+ hydrating-0.0.3.dist-info/METADATA,sha256=x6HXG42ZfHRLt3q1dvBe78U4ZFz1tJHhsdTwJ_6VJO0,1151
7
+ hydrating-0.0.3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ hydrating-0.0.3.dist-info/licenses/LICENSE,sha256=tFmGZzAd-8yy4frO1irUx5o83awFhZvJ7PwGWdVOznE,1106
9
+ hydrating-0.0.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022-2026, Reinert Huseby Karlsen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.