flowfreq 0.8.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.
flowfreq/__init__.py ADDED
@@ -0,0 +1,272 @@
1
+ """
2
+ flowfreq - Python library for hydrologic analysis
3
+
4
+ Includes:
5
+ - USGS gage data download (daily and peak flows)
6
+ - Summary hydrograph plotting
7
+ - Bulletin 17C flood frequency analysis:
8
+ - Method of Moments (MOM)
9
+ - Expected Moments Algorithm (EMA) with full PeakFQ parity
10
+ - Historical flood information handling
11
+ - Technical report generation
12
+ - One-call analysis workflow (flowfreq.workflow.run_ffa)
13
+ """
14
+
15
+ import logging
16
+ from importlib.metadata import PackageNotFoundError
17
+ from importlib.metadata import version as _installed_version
18
+
19
+ from .bulletin17c import (
20
+ Bulletin17C,
21
+ ExpectedMomentsAlgorithm,
22
+ FloodFrequencyAnalysis,
23
+ MethodOfMoments,
24
+ )
25
+ from .core import (
26
+ AnalysisMethod,
27
+ EMAParameters,
28
+ FlowInterval,
29
+ FrequencyResults,
30
+ LowFlowResults,
31
+ SkewMethod,
32
+ grubbs_beck_critical_value,
33
+ kfactor,
34
+ kfactor_array,
35
+ )
36
+ from .flowio import load_flow_frame, save_flow_frame
37
+ from .hydrograph import Hydrograph
38
+ from .lowflow import LOW_FLOW_YEAR_TYPES, LowFlowFrequency, annual_minimum_flow
39
+ from .qppq import (
40
+ MONTHLY_SEASONS,
41
+ SNOWMELT_SEASONS,
42
+ FlowDurationCurve,
43
+ QppqResult,
44
+ apply_lag,
45
+ center_of_timing,
46
+ estimate_donor_lag,
47
+ loocv_qppq,
48
+ performance,
49
+ qppq,
50
+ rank_donors,
51
+ seasonal_curves,
52
+ )
53
+ from .regime import (
54
+ BASEFLOW_METHODS,
55
+ DEFAULT_EXCEEDANCE_PCT,
56
+ FlowRegime,
57
+ baseflow_index,
58
+ diel_variation,
59
+ diel_variation_summary,
60
+ flow_duration_curve,
61
+ monthly_flow_summary,
62
+ richards_baker_flashiness,
63
+ seasonal_flow_summary,
64
+ separate_baseflow,
65
+ tqmean,
66
+ )
67
+ from .report import HydroReport
68
+ from .transpose import (
69
+ DEFAULT_AREA_RATIO_RANGE,
70
+ LOW_FLOW_AREA_RATIO_RANGE,
71
+ PROBABILITY_KINDS,
72
+ RegressionExponents,
73
+ TransposedResults,
74
+ TranspositionProvenance,
75
+ transpose_duration,
76
+ transpose_frequency,
77
+ transpose_low_flow,
78
+ )
79
+ from .usgs import (
80
+ GageAttributes,
81
+ NoInstantaneousDataError,
82
+ USGSgage,
83
+ fetch_nwis_batch,
84
+ fetch_nwis_peaks,
85
+ )
86
+ from .workflow import (
87
+ B17C_DEFAULT_SKEW,
88
+ DEFAULT_AEP,
89
+ DEFAULT_RETURN_INTERVALS,
90
+ SKEW_OPTIONS,
91
+ build_skew_curves_dict,
92
+ compute_skew_tables,
93
+ run_ffa,
94
+ )
95
+
96
+ # Alias for backwards compatibility
97
+ USGSGage = USGSgage
98
+
99
+ logger = logging.getLogger(__name__)
100
+
101
+
102
+ def analyze_gage(
103
+ site_no: str,
104
+ method: str = "ema",
105
+ regional_skew: float = None,
106
+ regional_skew_mse: float = None,
107
+ historical_peaks: list = None,
108
+ output_dir: str = "./output",
109
+ ) -> dict:
110
+ """
111
+ Complete flood frequency analysis for a USGS gage.
112
+
113
+ Parameters
114
+ ----------
115
+ site_no : str
116
+ USGS site number
117
+ method : str
118
+ 'mom' or 'ema' (default: 'ema')
119
+ regional_skew : float, optional
120
+ Regional skew coefficient
121
+ regional_skew_mse : float, optional
122
+ Mean squared error of regional skew
123
+ historical_peaks : list of (year, flow) tuples, optional
124
+ Historical peak observations
125
+ output_dir : str
126
+ Output directory
127
+ """
128
+ import os
129
+
130
+ os.makedirs(output_dir, exist_ok=True)
131
+
132
+ logger.info("Downloading data for USGS %s...", site_no)
133
+ gage = USGSgage(site_no)
134
+
135
+ try:
136
+ gage.download_daily_flow()
137
+ logger.info("Downloaded %d days of daily flow data", len(gage.daily_data))
138
+ except Exception as e:
139
+ logger.warning("Could not download daily flow data: %s", e)
140
+
141
+ gage.download_peak_flow()
142
+ logger.info("Downloaded %d annual peak flow records", len(gage.peak_data))
143
+ logger.info("Site name: %s", gage.site_name)
144
+
145
+ logger.info("Running Bulletin 17C analysis (method=%s)...", method.upper())
146
+
147
+ water_years = gage.peak_data["water_year"].values
148
+
149
+ analysis = Bulletin17C(
150
+ gage.peak_data["peak_flow_cfs"].values,
151
+ water_years=water_years,
152
+ regional_skew=regional_skew,
153
+ regional_skew_mse=regional_skew_mse,
154
+ historical_peaks=historical_peaks,
155
+ )
156
+
157
+ results = analysis.run_analysis(method=method)
158
+
159
+ logger.info("Station skew: %.4f", results.skew_station)
160
+ if results.skew_weighted is not None:
161
+ logger.info("Weighted skew: %.4f", results.skew_weighted)
162
+ logger.info("Low outlier threshold: %s cfs", f"{results.low_outlier_threshold:,.0f}")
163
+
164
+ if results.method == AnalysisMethod.EMA:
165
+ logger.info("EMA iterations: %s", results.ema_iterations)
166
+ logger.info("EMA converged: %s", results.ema_converged)
167
+
168
+ logger.info("Generating report and figures...")
169
+ report = HydroReport(gage, analysis)
170
+ figures = report.generate_all_figures(output_dir)
171
+
172
+ report_path = os.path.join(output_dir, "flood_frequency_report.md")
173
+ report.save_report(report_path)
174
+ logger.info("Report saved to: %s", report_path)
175
+
176
+ return {
177
+ "gage": gage,
178
+ "analysis": analysis,
179
+ "results": results,
180
+ "figures": figures,
181
+ "report_path": report_path,
182
+ }
183
+
184
+
185
+ # Read from the installed distribution rather than repeated here as a
186
+ # literal. The literal drifted every time: it read "0.3.0" through the 0.4.0
187
+ # release (docs/PHASE1_RUNBOOK.md still records it saying so) and "0.4.0"
188
+ # through 0.5.0, 0.6.0 and 0.6.1, so `flowfreq.__version__` has spent most of
189
+ # this project's life reporting a version that was not the one installed.
190
+ # pyproject.toml is the single source of truth; this reads what pip actually
191
+ # installed from it, which cannot disagree.
192
+ try:
193
+ __version__ = _installed_version("flowfreq")
194
+ except PackageNotFoundError: # pragma: no cover - only in an uninstalled checkout
195
+ # No dist-info: someone is importing from a source tree they never
196
+ # installed. Saying so is better than naming a version that may be wrong.
197
+ __version__ = "unknown"
198
+
199
+ __author__ = "FlowFreq"
200
+
201
+ __all__ = [
202
+ # Core
203
+ "AnalysisMethod",
204
+ "SkewMethod",
205
+ "FlowInterval",
206
+ "EMAParameters",
207
+ "FrequencyResults",
208
+ "kfactor",
209
+ "kfactor_array",
210
+ "grubbs_beck_critical_value",
211
+ # QPPQ daily-series transfer
212
+ "FlowDurationCurve",
213
+ "QppqResult",
214
+ "qppq",
215
+ "seasonal_curves",
216
+ "center_of_timing",
217
+ "estimate_donor_lag",
218
+ "apply_lag",
219
+ "rank_donors",
220
+ "performance",
221
+ "loocv_qppq",
222
+ "SNOWMELT_SEASONS",
223
+ "MONTHLY_SEASONS",
224
+ # Transposition to an ungaged site
225
+ "RegressionExponents",
226
+ "TransposedResults",
227
+ "TranspositionProvenance",
228
+ "transpose_frequency",
229
+ "transpose_duration",
230
+ "transpose_low_flow",
231
+ "PROBABILITY_KINDS",
232
+ "DEFAULT_AREA_RATIO_RANGE",
233
+ "LOW_FLOW_AREA_RATIO_RANGE",
234
+ # Low-flow frequency analysis
235
+ "LowFlowResults",
236
+ "LowFlowFrequency",
237
+ "annual_minimum_flow",
238
+ "LOW_FLOW_YEAR_TYPES",
239
+ # Flow regime metrics
240
+ "FlowRegime",
241
+ "richards_baker_flashiness",
242
+ "tqmean",
243
+ "baseflow_index",
244
+ "flow_duration_curve",
245
+ "DEFAULT_EXCEEDANCE_PCT",
246
+ "separate_baseflow",
247
+ "monthly_flow_summary",
248
+ "seasonal_flow_summary",
249
+ "BASEFLOW_METHODS",
250
+ "diel_variation",
251
+ "diel_variation_summary",
252
+ # USGS data retrieval
253
+ "USGSgage",
254
+ "USGSGage", # Alias for backwards compatibility
255
+ "GageAttributes",
256
+ "NoInstantaneousDataError",
257
+ "fetch_nwis_peaks",
258
+ "fetch_nwis_batch",
259
+ "save_flow_frame",
260
+ "load_flow_frame",
261
+ # Hydrograph
262
+ "Hydrograph",
263
+ # Bulletin 17C
264
+ "Bulletin17C",
265
+ "MethodOfMoments",
266
+ "ExpectedMomentsAlgorithm",
267
+ "FloodFrequencyAnalysis",
268
+ # Report
269
+ "HydroReport",
270
+ # Convenience
271
+ "analyze_gage",
272
+ ]
flowfreq/_detrat.py ADDED
@@ -0,0 +1,202 @@
1
+ """Native port of peakfq's detrat: the Halloween determinant ratio, Wd.
2
+
3
+ TODO.md P3's second open item, independent of the ``var_mom``/``mse_ema``
4
+ port (``flowfreq._var_mom``/``flowfreq._mse_ema``). ``Wd`` is HWN's other
5
+ weighting input, alongside ``as_G_mse``: ``emafit.f``::
6
+
7
+ nG = n * Wd * as_G_mse / r_G_mse
8
+
9
+ ``Wd`` corrects for something ``as_G_mse`` alone does not capture: on a
10
+ censored record, the at-site mean, variance, and skew estimates are
11
+ correlated with each other, so the skew estimate carries less *independent*
12
+ information than its own marginal MSE suggests. ``detrat`` measures that
13
+ via a determinant ratio -- ``det(I - F)`` for the full (mean, variance,
14
+ skew) system versus the (mean, variance)-only subsystem -- rather than
15
+ treating skew's uncertainty in isolation. Below an at-site skew magnitude of
16
+ 0.04 the Fortran short-circuits to ``Wd = 1`` (``emafit.f:3654``); flowfreq
17
+ matches that, so this module is only ever exercised above that floor.
18
+
19
+ Everything here is a direct, function-for-function transcription of
20
+ ``vendor/peakfqr/src/emafit.f``'s ``detrat`` and ``vendor/peakfqr/src/probfun.f``'s
21
+ ``EXPMOMCDERIV`` (Greg Schwarz's derivation, cited in-line as "EQ n" the way
22
+ the Fortran comments do), checked against two Fortran oracles
23
+ ``build_fortran/_emafort.pyf`` exposes (``expmomcderiv``, and Phase 1's
24
+ ``detratsub``) -- see ``tests/fortran_parity/test_fortran_oracles.py``.
25
+
26
+ ``EXPMOMCDERIV`` reuses ``flowfreq._var_mom._dexpect`` for the open-tail
27
+ expected moments and their Jacobian -- the same building block
28
+ ``expmomderiv``/``d_est`` use -- rather than a second implementation of the
29
+ same truncated-gamma machinery.
30
+
31
+ Nothing in this module is wired into ``Bulletin17C``/``ExpectedMomentsAlgorithm``
32
+ yet.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import math
38
+
39
+ import numpy as np
40
+
41
+ from flowfreq._p3_moments import _fp_g3_cdf, m2p
42
+ from flowfreq._var_mom import _dexpect
43
+
44
+ __all__ = ["detrat"]
45
+
46
+ #: emafit.f:3654 -- below this at-site skew magnitude, Wd = 1 outright.
47
+ #: Matches bulletin17c.py's _HWN_SKEW_FLOOR.
48
+ _SKEW_FLOOR = 0.04
49
+
50
+ #: probfun.f:916 -- EXPMOMCDERIV's own "infinity", log10(1e20). A different
51
+ #: sentinel from flowfreq._var_mom's _D_EST_INF (1e19): this one is in the
52
+ #: same log10(flow) units as tl/tu themselves, not an arbitrarily large
53
+ #: number, and must match exactly for the open-tail Jacobian to line up
54
+ #: with what the Fortran computes.
55
+ _INF = 20.0
56
+
57
+ #: probfun.f:940 -- below this censoring probability, EXPMOMCDERIV falls
58
+ #: back to the unconditional (-inf, inf) moments/Jacobian rather than
59
+ #: dividing by a near-zero PICK.
60
+ _PICK_FLOOR = 1e-10
61
+
62
+
63
+ def _p2m(parms: np.ndarray) -> np.ndarray:
64
+ """Pearson III params (tau, alpha, beta) -> central moments (mean, var, skew).
65
+
66
+ ``probfun.f`` ``P2M`` (line 752): the direct gamma-distribution moment
67
+ formulas (variance ``alpha*beta**2``, skew ``2/sqrt(alpha)`` signed by
68
+ beta), not ``m2p``'s inverse via the noncentral-moment route -- though
69
+ the two agree exactly, since both describe the same distribution.
70
+ """
71
+ tau, alpha, beta = float(parms[0]), float(parms[1]), float(parms[2])
72
+ mean = alpha * beta + tau
73
+ var = alpha * beta**2
74
+ skew = 2.0 / math.sqrt(alpha)
75
+ if beta < 0.0:
76
+ skew = -skew
77
+ return np.array([mean, var, skew])
78
+
79
+
80
+ def _expmomcderiv(parms: np.ndarray, tl: float, tu: float):
81
+ """E[X^j | X outside [tl, tu]] and the Jacobian of the resulting expected
82
+
83
+ central moments w.r.t. the fit's own central moments (mean, variance,
84
+ skew). ``probfun.f`` ``EXPMOMCDERIV`` (line 872), Schwarz's EQ 27.
85
+
86
+ "Outside [tl, tu]" -- the *censored* region for this threshold group,
87
+ not the perceived one -- is deliberate: this asks what the moments
88
+ would look like restricted to exactly the data ``detrat`` never gets to
89
+ see, which is the whole point of a censoring bias correction.
90
+ """
91
+ mc = _p2m(parms)
92
+ mc1, mc2 = float(mc[0]), float(mc[1])
93
+ s = math.sqrt(mc2)
94
+ tau, alpha, beta = float(parms[0]), float(parms[1]), float(parms[2])
95
+
96
+ pil = _fp_g3_cdf(tl, parms)
97
+ pig = 1.0 - _fp_g3_cdf(tu, parms)
98
+ pick = pil + pig
99
+
100
+ if pick > _PICK_FLOOR:
101
+ mnel, jacl = _dexpect(tau, alpha, beta, -_INF, tl)
102
+ mneg, jacg = _dexpect(tau, alpha, beta, tu, _INF)
103
+ mne = (pil * mnel + pig * mneg) / pick
104
+ jac = (pil * jacl + pig * jacg) / pick
105
+ else:
106
+ mne, jac = _dexpect(tau, alpha, beta, -_INF, _INF)
107
+
108
+ db = np.array(
109
+ [
110
+ [0.0, 0.0, 0.0],
111
+ [2.0 * mc1, 0.0, 0.0],
112
+ [-3.0 * mc1**2 / s**3, 3.0 * mc1**3 / (2.0 * s**5), 0.0],
113
+ ]
114
+ )
115
+ b = np.array(
116
+ [
117
+ [1.0, 0.0, 0.0],
118
+ [-2.0 * mc1, 1.0, 0.0],
119
+ [3.0 * mc1**2 / s**3, -3.0 * mc1 / s**3, 1.0 / s**3],
120
+ ]
121
+ )
122
+ # d(tau, alpha, beta)/d(mean, variance, skew) directly -- not the same
123
+ # basis as flowfreq._var_mom._dpdm, which differentiates w.r.t.
124
+ # noncentral moments instead.
125
+ skew = float(mc[2])
126
+ tp = np.array(
127
+ [
128
+ [1.0, -1.0 / (s * skew), 2.0 * s / skew**2],
129
+ [0.0, 0.0, -8.0 / skew**3],
130
+ [0.0, skew / (4.0 * s), s / 2.0],
131
+ ]
132
+ )
133
+ d3 = np.array(
134
+ [
135
+ [0.0, 0.0, 0.0],
136
+ [-2.0 * mne[0], 0.0, 0.0],
137
+ [
138
+ 6.0 * mc1 / s**3 * mne[0] - 3.0 / s**3 * mne[1],
139
+ (-4.5 * mc1**2 * mne[0] + 4.5 * mc1 * mne[1] - 1.5 * mne[2]) / s**5,
140
+ 0.0,
141
+ ],
142
+ ]
143
+ )
144
+
145
+ dedmc = db + b @ jac @ tp + d3
146
+ return mc, mne, dedmc
147
+
148
+
149
+ def detrat(mc: np.ndarray, n: int, nobs: np.ndarray, tl: np.ndarray, tu: np.ndarray) -> float:
150
+ """The Halloween determinant ratio, Wd. ``emafit.f`` ``detrat`` (line 3615).
151
+
152
+ Parameters
153
+ ----------
154
+ mc : central moments (mean, variance, skew) of the at-site fit.
155
+ n : total record length (not the number of threshold groups).
156
+ nobs, tl, tu : perception-threshold groups, ``var_mom``'s convention --
157
+ ``nobs[i]`` observations share the pair ``(tl[i], tu[i])``.
158
+
159
+ Returns
160
+ -------
161
+ float
162
+ ``1.0`` below the 0.04 at-site skew floor; otherwise
163
+ ``det(I - F) / det(I - F[:2, :2])``, the 3x3-vs-2x2 determinant
164
+ ratio.
165
+ """
166
+ skew = float(mc[2])
167
+ if abs(skew) < _SKEW_FLOOR:
168
+ return 1.0
169
+
170
+ parms = m2p(mc)
171
+ mc1, mc2 = float(mc[0]), float(mc[1])
172
+ s = math.sqrt(mc2)
173
+
174
+ f0phi = np.zeros((3, 3))
175
+ for nobs_k, tl_k, tu_k in zip(nobs, tl, tu):
176
+ pick = 1.0 - (_fp_g3_cdf(tu_k, parms) - _fp_g3_cdf(tl_k, parms))
177
+ etak = float(nobs_k) / n
178
+
179
+ _, mne, dck = _expmomcderiv(parms, tl_k, tu_k)
180
+
181
+ # Expected central moments given censored, about the *population*
182
+ # mean/variance (not the conditional mean) -- Schwarz's EQ 23.
183
+ mcexpect = np.array(
184
+ [
185
+ mne[0],
186
+ mc1**2 - 2.0 * mc1 * mne[0] + mne[1],
187
+ (-(mc1**3) + 3.0 * mc1**2 * mne[0] - 3.0 * mc1 * mne[1] + mne[2]) / s**3,
188
+ ]
189
+ )
190
+
191
+ f1 = np.zeros((3, 3))
192
+ f1[1, 0] = 2.0 * pick * (mcexpect[0] - mc1)
193
+ f1[2, 0] = 3.0 * (pick * mcexpect[1] - mc2) / s**3
194
+ f1[2, 1] = 3.0 * (pick * mcexpect[2] - skew) / (2.0 * mc2)
195
+
196
+ f0phi += (f1 + pick * dck) * etak
197
+
198
+ imf = np.eye(3) - f0phi
199
+ imful = imf[:2, :2]
200
+ det2 = np.linalg.det(imful)
201
+ det3 = np.linalg.det(imf)
202
+ return det3 / det2