pybfs 0.1.1__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.
- pybfs/__init__.py +72 -0
- pybfs/bfs.py +577 -0
- pybfs/calibrate.py +1160 -0
- pybfs/plot.py +319 -0
- pybfs/utilities.py +1135 -0
- pybfs-0.1.1.dist-info/LICENSE +22 -0
- pybfs-0.1.1.dist-info/METADATA +139 -0
- pybfs-0.1.1.dist-info/RECORD +10 -0
- pybfs-0.1.1.dist-info/WHEEL +5 -0
- pybfs-0.1.1.dist-info/top_level.txt +1 -0
pybfs/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""PyBFS - Python Baseflow Separation
|
|
3
|
+
|
|
4
|
+
A Python implementation of Baseflow Separation algorithms for hydrological analysis.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.1"
|
|
8
|
+
__author__ = "BYU Hydroinformatics"
|
|
9
|
+
__email__ = ""
|
|
10
|
+
|
|
11
|
+
# Import from utilities module
|
|
12
|
+
from .utilities import (
|
|
13
|
+
sur_z,
|
|
14
|
+
sur_store,
|
|
15
|
+
sur_q,
|
|
16
|
+
dir_q,
|
|
17
|
+
infiltration,
|
|
18
|
+
recharge,
|
|
19
|
+
get_values_for_site,
|
|
20
|
+
base_table,
|
|
21
|
+
forecast,
|
|
22
|
+
flow_metrics,
|
|
23
|
+
bf_ci,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Import from bfs module
|
|
27
|
+
from .bfs import bfs
|
|
28
|
+
|
|
29
|
+
# Import from plot module
|
|
30
|
+
from .plot import (
|
|
31
|
+
plot_baseflow_simulation,
|
|
32
|
+
plot_forecast,
|
|
33
|
+
plot_forecast_baseflow,
|
|
34
|
+
plot_forecast_baseflow_streamflow,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Import from calibrate module
|
|
38
|
+
from .calibrate import (
|
|
39
|
+
bfs_calibrate,
|
|
40
|
+
objective,
|
|
41
|
+
ini_params,
|
|
42
|
+
cal_initial,
|
|
43
|
+
cal_basetable,
|
|
44
|
+
cal_base,
|
|
45
|
+
cal_surface,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"sur_z",
|
|
50
|
+
"sur_store",
|
|
51
|
+
"sur_q",
|
|
52
|
+
"dir_q",
|
|
53
|
+
"infiltration",
|
|
54
|
+
"recharge",
|
|
55
|
+
"get_values_for_site",
|
|
56
|
+
"base_table",
|
|
57
|
+
"flow_metrics",
|
|
58
|
+
"bf_ci",
|
|
59
|
+
"bfs",
|
|
60
|
+
"forecast",
|
|
61
|
+
"bfs_calibrate",
|
|
62
|
+
"objective",
|
|
63
|
+
"ini_params",
|
|
64
|
+
"cal_initial",
|
|
65
|
+
"cal_basetable",
|
|
66
|
+
"cal_base",
|
|
67
|
+
"cal_surface",
|
|
68
|
+
"plot_baseflow_simulation",
|
|
69
|
+
"plot_forecast",
|
|
70
|
+
"plot_forecast_baseflow",
|
|
71
|
+
"plot_forecast_baseflow_streamflow",
|
|
72
|
+
]
|
pybfs/bfs.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Baseflow Separation (BFS) function
|
|
3
|
+
|
|
4
|
+
Main function for performing physically-based baseflow separation using
|
|
5
|
+
a coupled surface-subsurface reservoir model.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import math
|
|
11
|
+
from .utilities import (
|
|
12
|
+
sur_z, sur_store, sur_q, dir_q, infiltration, recharge
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from numba import jit
|
|
16
|
+
from .utilities import (
|
|
17
|
+
sur_z_jit, sur_store_jit, sur_q_jit, dir_q_jit,
|
|
18
|
+
infiltration_jit, recharge_jit
|
|
19
|
+
)
|
|
20
|
+
HAS_NUMBA = True
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if HAS_NUMBA:
|
|
24
|
+
@jit(nopython=True, cache=True)
|
|
25
|
+
def _bfs_core_loop(qin, sbt_xb, sbt_z, sbt_s, sbt_q, rise, p,
|
|
26
|
+
lb, alpha, ws, por, ks, kz, prec, ifact, qmean,
|
|
27
|
+
X, qcomp, ETA, I, Z, ST, EXC):
|
|
28
|
+
"""
|
|
29
|
+
JIT-compiled core computation loop for BFS algorithm.
|
|
30
|
+
This is the computationally intensive part that benefits from JIT.
|
|
31
|
+
"""
|
|
32
|
+
# Find first valid time step
|
|
33
|
+
ts = 0
|
|
34
|
+
while ts < p and math.isnan(qin[ts]):
|
|
35
|
+
ts += 1
|
|
36
|
+
|
|
37
|
+
if ts >= p:
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
# Initialization
|
|
41
|
+
ts_ini = True
|
|
42
|
+
qb_in = min(qin[ts], qmean)
|
|
43
|
+
|
|
44
|
+
# Find index in SBT - match original: (SBT["Q"] <= qb_in).sum() - 1
|
|
45
|
+
idx = 0
|
|
46
|
+
for i in range(len(sbt_q)):
|
|
47
|
+
if sbt_q[i] <= qb_in:
|
|
48
|
+
idx = i + 1 # Count (like .sum())
|
|
49
|
+
else:
|
|
50
|
+
break
|
|
51
|
+
idx = idx - 1 if idx > 0 else -1 # Convert count to index (idx - 1)
|
|
52
|
+
|
|
53
|
+
xb_in = sbt_xb[idx] if idx >= 0 and idx < len(sbt_xb) else np.nan
|
|
54
|
+
zb_in = sbt_z[idx] if idx >= 0 and idx < len(sbt_z) else np.nan
|
|
55
|
+
sb_in = sbt_s[idx] if idx >= 0 and idx < len(sbt_s) else np.nan
|
|
56
|
+
|
|
57
|
+
# Calculate available base storage
|
|
58
|
+
sba = 0.0
|
|
59
|
+
for i in range(len(sbt_s)):
|
|
60
|
+
if sbt_s[i] > sba:
|
|
61
|
+
sba = sbt_s[i]
|
|
62
|
+
sba = sba - sb_in
|
|
63
|
+
|
|
64
|
+
# Surface flow calculations
|
|
65
|
+
qs_in_val = qin[ts] - qb_in
|
|
66
|
+
if math.isnan(qs_in_val):
|
|
67
|
+
qs_in = 0.0
|
|
68
|
+
else:
|
|
69
|
+
qs_in = max(0.0, qs_in_val)
|
|
70
|
+
|
|
71
|
+
zs_in_val = qs_in / (2 * lb * ks * alpha)
|
|
72
|
+
if math.isnan(zs_in_val):
|
|
73
|
+
zs_in = ws * alpha
|
|
74
|
+
else:
|
|
75
|
+
zs_in = min(zs_in_val, ws * alpha)
|
|
76
|
+
|
|
77
|
+
ss_in = sur_store_jit(lb, alpha, ws, por, zs_in)
|
|
78
|
+
ssa = sur_store_jit(lb, alpha, ws, por, ws * alpha) - ss_in
|
|
79
|
+
|
|
80
|
+
infil_in = 0.0
|
|
81
|
+
rech_in = recharge_jit(lb, xb_in, ws, kz, zs_in, por)
|
|
82
|
+
|
|
83
|
+
# Main time step loop - this is the bottleneck
|
|
84
|
+
while ts < p:
|
|
85
|
+
if not ts_ini:
|
|
86
|
+
xb_in = X[ts - 1]
|
|
87
|
+
zb_in = Z[ts - 1, 1]
|
|
88
|
+
sb_in = ST[ts - 1, 1]
|
|
89
|
+
|
|
90
|
+
# Find index in SBT - match original: (SBT["Xb"] <= xb_in).sum() - 1
|
|
91
|
+
idx = 0
|
|
92
|
+
for i in range(len(sbt_xb)):
|
|
93
|
+
if sbt_xb[i] <= xb_in:
|
|
94
|
+
idx = i + 1 # Count (like .sum())
|
|
95
|
+
else:
|
|
96
|
+
break
|
|
97
|
+
idx = idx - 1 if idx > 0 else -1 # Convert count to index (idx - 1)
|
|
98
|
+
qb_in = sbt_q[idx] if idx >= 0 and idx < len(sbt_q) else np.nan
|
|
99
|
+
|
|
100
|
+
zs_in = Z[ts - 1, 0]
|
|
101
|
+
ss_in = ST[ts - 1, 0]
|
|
102
|
+
qs_in = sur_q_jit(lb, alpha, ks, zs_in)
|
|
103
|
+
|
|
104
|
+
ssa = sur_store_jit(lb, alpha, ws, por, ws * alpha) - ss_in
|
|
105
|
+
sba = 0.0
|
|
106
|
+
for i in range(len(sbt_s)):
|
|
107
|
+
if sbt_s[i] > sba:
|
|
108
|
+
sba = sbt_s[i]
|
|
109
|
+
sba = sba - sb_in
|
|
110
|
+
rech_in = min(recharge_jit(lb, xb_in, ws, kz, zs_in, por), sba + qb_in)
|
|
111
|
+
qd = 0.0
|
|
112
|
+
infil_in = 0.0
|
|
113
|
+
|
|
114
|
+
I[ts] = 0.0
|
|
115
|
+
etaest = max(0.0, qin[ts] - qb_in - qs_in)
|
|
116
|
+
|
|
117
|
+
# Impulse calculation loop - nested loops, computationally intensive
|
|
118
|
+
if ts > 0 and etaest > 0:
|
|
119
|
+
if rise[ts] or (ts > 0 and rise[ts - 1]):
|
|
120
|
+
I[ts] = etaest / (2 * lb * ws)
|
|
121
|
+
zs = zs_in
|
|
122
|
+
qs = qs_in
|
|
123
|
+
|
|
124
|
+
for x in ifact:
|
|
125
|
+
i = I[ts]
|
|
126
|
+
eta = etaest
|
|
127
|
+
while eta > max(prec, qin[ts] / 100) and i > 0:
|
|
128
|
+
I[ts] = i
|
|
129
|
+
etaest = eta
|
|
130
|
+
i = x * i
|
|
131
|
+
infil = min(infiltration_jit(lb, ws, ks, alpha, (zs_in + zs) / 2, i), ssa)
|
|
132
|
+
ss = max(ss_in + infil - rech_in - qs, 0.0)
|
|
133
|
+
zs = sur_z_jit(lb, alpha, ws, por, ss)
|
|
134
|
+
qs = sur_q_jit(lb, alpha, ks, zs)
|
|
135
|
+
qd = (dir_q_jit(lb, alpha, zs_in, i) +
|
|
136
|
+
dir_q_jit(lb, alpha, (zs - zs_in), i / 2) +
|
|
137
|
+
max(2 * lb * (ws - zs_in / alpha) * (I[ts] - ks), 0.0))
|
|
138
|
+
eta = qin[ts] - qs - qd - qb_in
|
|
139
|
+
|
|
140
|
+
infil_in = min(infiltration_jit(lb, ws, ks, alpha, zs_in, I[ts]), ssa)
|
|
141
|
+
|
|
142
|
+
# End of time step calculations
|
|
143
|
+
ss_en = max(ss_in + infil_in - rech_in - qs_in, 0.0)
|
|
144
|
+
zs_en = sur_z_jit(lb, alpha, ws, por, ss_en)
|
|
145
|
+
qs_en = sur_q_jit(lb, alpha, ks, zs_en)
|
|
146
|
+
|
|
147
|
+
infil_val = infiltration_jit(lb, ws, ks, alpha, zs_en, I[ts])
|
|
148
|
+
if math.isnan(infil_val):
|
|
149
|
+
infil_en = ssa
|
|
150
|
+
else:
|
|
151
|
+
infil_en = min(infil_val, ssa)
|
|
152
|
+
|
|
153
|
+
rech_en = min(recharge_jit(lb, xb_in, ws, kz, zs_en, por), sba + qb_in)
|
|
154
|
+
sb_en = max(sb_in + rech_en - qb_in, 0.0)
|
|
155
|
+
|
|
156
|
+
# Find index in SBT - match original: max((SBT["S"] < sb_en).sum(), 1) - 1
|
|
157
|
+
idx = 0
|
|
158
|
+
for i in range(len(sbt_s)):
|
|
159
|
+
if sbt_s[i] < sb_en:
|
|
160
|
+
idx = i + 1 # Count (like .sum())
|
|
161
|
+
else:
|
|
162
|
+
break
|
|
163
|
+
idx = max(idx, 1) - 1 # max(count, 1) - 1
|
|
164
|
+
|
|
165
|
+
xb_en = sbt_xb[idx] if 0 <= idx < len(sbt_xb) else np.nan
|
|
166
|
+
zb_en = sbt_z[idx] if 0 <= idx < len(sbt_z) else np.nan
|
|
167
|
+
qb_en = sbt_q[idx] if 0 <= idx < len(sbt_q) else np.nan
|
|
168
|
+
|
|
169
|
+
# Final calculations
|
|
170
|
+
qcomp[ts, 0] = (qs_in + qs_en) / 2
|
|
171
|
+
qcomp[ts, 1] = (qb_in + qb_en) / 2
|
|
172
|
+
|
|
173
|
+
EXC[ts, 0] = (infil_in + infil_en) / 2
|
|
174
|
+
EXC[ts, 1] = (rech_in + rech_en) / 2
|
|
175
|
+
|
|
176
|
+
if ts_ini:
|
|
177
|
+
ST[ts, 0] = ss_en
|
|
178
|
+
ST[ts, 1] = sb_en
|
|
179
|
+
Z[ts, 0] = zs_en
|
|
180
|
+
Z[ts, 1] = zb_en
|
|
181
|
+
|
|
182
|
+
if not ts_ini:
|
|
183
|
+
ST[ts, 0] = max(ST[ts - 1, 0] + EXC[ts, 0] - qcomp[ts, 0] - EXC[ts, 1], 0.0)
|
|
184
|
+
ST[ts, 0] = min(ST[ts, 0], sur_store_jit(lb, alpha, ws, por, ws * alpha))
|
|
185
|
+
Z[ts, 0] = sur_z_jit(lb, alpha, ws, por, ST[ts, 0])
|
|
186
|
+
ST[ts, 1] = max(ST[ts - 1, 1] + EXC[ts, 1] - qcomp[ts, 1], 0.0)
|
|
187
|
+
|
|
188
|
+
max_s = 0.0
|
|
189
|
+
for i in range(len(sbt_s)):
|
|
190
|
+
if sbt_s[i] > max_s:
|
|
191
|
+
max_s = sbt_s[i]
|
|
192
|
+
ST[ts, 1] = min(ST[ts, 1], max_s)
|
|
193
|
+
|
|
194
|
+
# Find index in SBT - match original: max((SBT['S'] <= ST[ts, 1]).sum(), 1) - 1
|
|
195
|
+
idx = 0
|
|
196
|
+
for i in range(len(sbt_s)):
|
|
197
|
+
if sbt_s[i] <= ST[ts, 1]:
|
|
198
|
+
idx = i + 1 # Count (like .sum())
|
|
199
|
+
else:
|
|
200
|
+
break
|
|
201
|
+
idx = max(idx, 1) - 1 # max(count, 1) - 1
|
|
202
|
+
Z[ts, 1] = sbt_z[idx] if idx >= 0 and idx < len(sbt_z) else np.nan
|
|
203
|
+
|
|
204
|
+
qcomp[ts, 2] = (dir_q_jit(lb, alpha, zs_in, I[ts]) +
|
|
205
|
+
dir_q_jit(lb, alpha, (Z[ts, 0] - zs_in), I[ts] / 2) +
|
|
206
|
+
max(2 * lb * (ws - zs_in / alpha) * (I[ts] - ks), 0.0))
|
|
207
|
+
|
|
208
|
+
ETA[ts] = qin[ts] - (qcomp[ts, 0] + qcomp[ts, 1] + qcomp[ts, 2])
|
|
209
|
+
|
|
210
|
+
# Find index in SBT - match original: max((SBT['S'] <= ST[ts, 1]).sum(), 1) - 1
|
|
211
|
+
idx = 0
|
|
212
|
+
for i in range(len(sbt_s)):
|
|
213
|
+
if sbt_s[i] <= ST[ts, 1]:
|
|
214
|
+
idx = i + 1 # Count (like .sum())
|
|
215
|
+
else:
|
|
216
|
+
break
|
|
217
|
+
idx = max(idx, 1) - 1 # max(count, 1) - 1
|
|
218
|
+
X[ts] = sbt_xb[idx] if idx >= 0 and idx < len(sbt_xb) else np.nan
|
|
219
|
+
|
|
220
|
+
ts += 1
|
|
221
|
+
ts_ini = False
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def bfs(streamflow, SBT, basin_char, gw_hyd, flow, timestep='day', error_basis='total', use_jit=True):
|
|
225
|
+
"""Main BFS function for baseflow separation
|
|
226
|
+
|
|
227
|
+
Performs physically-based baseflow separation using a coupled surface-subsurface
|
|
228
|
+
reservoir model. Separates total streamflow into three components: surface flow,
|
|
229
|
+
baseflow, and direct runoff. Uses an iterative approach to estimate precipitation
|
|
230
|
+
impulses needed to match observed streamflow.
|
|
231
|
+
|
|
232
|
+
Parameters
|
|
233
|
+
----------
|
|
234
|
+
streamflow : pd.DataFrame
|
|
235
|
+
DataFrame with columns 'Date' (datetime) and 'Streamflow' (m³/day,
|
|
236
|
+
observed streamflow)
|
|
237
|
+
SBT : pd.DataFrame
|
|
238
|
+
Baseflow table with columns ['Xb','Z','S','Q']. Generated by
|
|
239
|
+
base_table() function
|
|
240
|
+
basin_char : list
|
|
241
|
+
Basin characteristics [area, lb, x1, wb, por] where:
|
|
242
|
+
|
|
243
|
+
- area: Basin area (m²)
|
|
244
|
+
- lb: Basin length (m)
|
|
245
|
+
- x1: Initial longitudinal position (m)
|
|
246
|
+
- wb: Basin width (m)
|
|
247
|
+
- por: Porosity (0-1)
|
|
248
|
+
gw_hyd : list
|
|
249
|
+
Groundwater hydraulic parameters [alpha, beta, ks, kb, kz] where:
|
|
250
|
+
|
|
251
|
+
- alpha: Surface reservoir shape parameter
|
|
252
|
+
- beta: Base reservoir shape parameter
|
|
253
|
+
- ks: Surface hydraulic conductivity (m/day)
|
|
254
|
+
- kb: Base hydraulic conductivity (m/day)
|
|
255
|
+
- kz: Vertical hydraulic conductivity (m/day)
|
|
256
|
+
flow : list
|
|
257
|
+
Flow metrics [qthresh, rs, rb1, rb2, prec, fr4rise] where:
|
|
258
|
+
|
|
259
|
+
- qthresh: Flow threshold
|
|
260
|
+
- rs: Recession slope parameter
|
|
261
|
+
- rb1, rb2: Baseflow recession parameters
|
|
262
|
+
- prec: Precision threshold
|
|
263
|
+
- fr4rise: Fraction for rise detection
|
|
264
|
+
timestep : str, optional
|
|
265
|
+
Time step for calculations ('day' or 'hour'), default 'day'
|
|
266
|
+
error_basis : str, optional
|
|
267
|
+
Basis for error calculation ('base' or 'total'), default 'total'
|
|
268
|
+
use_jit : bool, optional
|
|
269
|
+
Whether to use NUMBA JIT compilation for faster computation (default True).
|
|
270
|
+
If False, uses the non-JIT Python implementation.
|
|
271
|
+
|
|
272
|
+
Returns
|
|
273
|
+
-------
|
|
274
|
+
pd.DataFrame
|
|
275
|
+
Results DataFrame with columns:
|
|
276
|
+
|
|
277
|
+
- Date: Date of observation
|
|
278
|
+
- Qob: Observed streamflow (m³/day)
|
|
279
|
+
- Qsim: Simulated total streamflow (m³/day)
|
|
280
|
+
- SurfaceFlow: Surface flow component (m³/day)
|
|
281
|
+
- Baseflow: Baseflow component (m³/day)
|
|
282
|
+
- DirectRunoff: Direct runoff component (m³/day)
|
|
283
|
+
- X: Longitudinal location of base water level (m)
|
|
284
|
+
- Eta: Streamflow residual (m³/day)
|
|
285
|
+
- StSur: Surface storage (m³)
|
|
286
|
+
- StBase: Base storage (m³)
|
|
287
|
+
- Impulse.L: Estimated precipitation (m/day)
|
|
288
|
+
- Zs.L: Surface water elevation (m)
|
|
289
|
+
- Zb.L: Base water elevation (m)
|
|
290
|
+
- Infil: Infiltration rate (m³/day)
|
|
291
|
+
- Rech: Recharge rate (m³/day)
|
|
292
|
+
- RecessCount.T: Recession day counter
|
|
293
|
+
- AdjPctEr: Adjusted percent error
|
|
294
|
+
- Weight: Error weighting factor
|
|
295
|
+
|
|
296
|
+
Notes
|
|
297
|
+
-----
|
|
298
|
+
The model uses a two-reservoir system where surface and base reservoirs
|
|
299
|
+
interact through infiltration and recharge. The algorithm iteratively
|
|
300
|
+
estimates precipitation impulses during rising limbs to minimize streamflow
|
|
301
|
+
residuals (Eta).
|
|
302
|
+
|
|
303
|
+
Examples
|
|
304
|
+
--------
|
|
305
|
+
>>> streamflow_data = pd.read_csv('streamflow.csv')
|
|
306
|
+
>>> params_df = pd.read_csv('site_parameters.csv')
|
|
307
|
+
>>> basin_char, gw_hyd, flow = get_values_for_site(params_df, site_no)
|
|
308
|
+
>>> SBT = base_table(lb, x1, wb, beta, kb, streamflow_data, por)
|
|
309
|
+
>>> results = bfs(streamflow_data, SBT, basin_char, gw_hyd, flow)
|
|
310
|
+
"""
|
|
311
|
+
date = pd.to_datetime(streamflow["Date"])
|
|
312
|
+
qin = np.array(streamflow['Streamflow'])
|
|
313
|
+
# Remove negative flow values (matching R: qin[qin<0]=NA)
|
|
314
|
+
qin[qin < 0] = np.nan
|
|
315
|
+
qmean = np.nanmean(qin)
|
|
316
|
+
|
|
317
|
+
# Error tolerance used sequentially to refine impulse
|
|
318
|
+
ifact = [2, 1.1]
|
|
319
|
+
# Number of time steps
|
|
320
|
+
p = len(qin)
|
|
321
|
+
|
|
322
|
+
# calculates the change in streamflow (dq) between consecutive time steps. This change helps identify recessional periods (when streamflow is decreasing).
|
|
323
|
+
dq = np.zeros(p)
|
|
324
|
+
if timestep == 'day':
|
|
325
|
+
dq[1:] = qin[1:] - qin[:-1]
|
|
326
|
+
elif timestep == 'hour':
|
|
327
|
+
for y in range(24, p):
|
|
328
|
+
dq[y] = qin[y] - np.nanmax(qin[(y-24):y])
|
|
329
|
+
|
|
330
|
+
#basin characteristics
|
|
331
|
+
area, lb, x1, wb, por, ws = basin_char[0], basin_char[1], basin_char[2], basin_char[3], basin_char[4], basin_char[3] / 2
|
|
332
|
+
|
|
333
|
+
# Groundwater hydraulic parameters
|
|
334
|
+
alpha, beta, ks, kb, kz = gw_hyd[0], gw_hyd[1], gw_hyd[2], gw_hyd[3], gw_hyd[4]
|
|
335
|
+
|
|
336
|
+
# Flow metrics
|
|
337
|
+
qthresh, rs, rb1, rb2, prec, fr4rise = flow[0], flow[1], flow[2], flow[3], flow[4], flow[5]
|
|
338
|
+
|
|
339
|
+
#dqfr represents the fractional change in streamflow. #It's used to determine the nature of the change in streamflow relative to the current flow.
|
|
340
|
+
# Handle divide by zero: when qin is 0, set dqfr based on conditions
|
|
341
|
+
dqfr = np.zeros_like(dq, dtype=float)
|
|
342
|
+
mask_zero_both = (dq == 0) & (qin == 0)
|
|
343
|
+
mask_neg_dq_zero_qin = (dq < 0) & (qin == 0)
|
|
344
|
+
mask_valid = qin != 0
|
|
345
|
+
|
|
346
|
+
dqfr[mask_zero_both] = 0
|
|
347
|
+
dqfr[mask_neg_dq_zero_qin] = 1
|
|
348
|
+
dqfr[mask_valid] = dq[mask_valid] / qin[mask_valid]
|
|
349
|
+
|
|
350
|
+
rise = (dqfr > fr4rise) & (dq > prec)
|
|
351
|
+
rise[np.isnan(rise)] = False
|
|
352
|
+
|
|
353
|
+
recess = (dqfr <= fr4rise) | (dq < prec)
|
|
354
|
+
recess[np.isnan(recess)] = False
|
|
355
|
+
|
|
356
|
+
#recess_day: An array counting the number of consecutive recession periods up to each time step
|
|
357
|
+
recess_day = np.cumsum(recess) - np.maximum.accumulate((~recess).astype(int) * np.cumsum(recess))
|
|
358
|
+
|
|
359
|
+
# Output variables
|
|
360
|
+
X = np.full(p, np.nan) #LONGITUDINAL LOCATION OF BASE WATER LEVEL INTERSECTION WITH SURFACE, xb
|
|
361
|
+
qcomp = np.full((p, 3), np.nan) ##THREE FLOW COMPONENTS: surface flow; base flow; direct runoff from saturated areas
|
|
362
|
+
ETA = np.full(p, np.nan) #STATE DISTURBANCES (POSITIVE VALUES REPRESENT INPUTS)
|
|
363
|
+
I = np.full(p, np.nan) #PRECIPITATION CALCULATED FROM eta
|
|
364
|
+
Z = np.full((p, 2), np.nan) #WATER SURFACE ELEVATION OF SURFACE (CHANNEL IS DATUM) AND BASE (BASIN OUTLET IS DATUM), ZS and Zb
|
|
365
|
+
ST = np.full((p, 2), np.nan) #STORAGE, surface and base
|
|
366
|
+
EXC = np.full((p, 2), np.nan) #EXCHANGES, INFILTRATION AND RECHARGE
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
#CHECK PARAMETERS, END PROCESS IF PARAMETERS ARE BAD
|
|
370
|
+
if np.any(np.array([lb, x1, wb, alpha, beta, ks, kb, ks, por, qthresh, -rs, -rb1, -rb2, prec, fr4rise]) < 0):
|
|
371
|
+
ts = 10 * p # 10 * p is considered invalid
|
|
372
|
+
print('Negative parameter(s)')
|
|
373
|
+
if lb * wb > area:
|
|
374
|
+
ts = 10 * p
|
|
375
|
+
print('lb x wb > area')
|
|
376
|
+
if np.any(np.isnan(SBT)):
|
|
377
|
+
ts = 10 * p
|
|
378
|
+
print('Cannot calculate discharge for base parameters')
|
|
379
|
+
|
|
380
|
+
#basin characteristics
|
|
381
|
+
area, lb, x1, wb, por, ws = basin_char[0], basin_char[1], basin_char[2], basin_char[3], basin_char[4], basin_char[3] / 2
|
|
382
|
+
|
|
383
|
+
# Groundwater hydraulic parameters
|
|
384
|
+
alpha, beta, ks, kb, kz = gw_hyd[0], gw_hyd[1], gw_hyd[2], gw_hyd[3], gw_hyd[4]
|
|
385
|
+
|
|
386
|
+
# Flow metrics
|
|
387
|
+
qthresh, rs, rb1, rb2, prec, fr4rise = flow[0], flow[1], flow[2], flow[3], flow[4], flow[5]
|
|
388
|
+
|
|
389
|
+
ts = 0 #INITIAL TIME STEP
|
|
390
|
+
stts = ts #STARTING TIME STEP, stts, FOR ERROR CALCULATION
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
#This code iterates through the qin array starting from a time step ts, skipping over any NaN values.
|
|
394
|
+
#When it finds the first valid (non-NaN) value, it stores that time step in the variable sttts.
|
|
395
|
+
while np.isnan(qin[ts]):
|
|
396
|
+
ts += 1
|
|
397
|
+
stts = ts #
|
|
398
|
+
# Initialize Variables
|
|
399
|
+
ts_ini = True
|
|
400
|
+
# Initialize variables
|
|
401
|
+
qb_in = min(qin[ts], qmean)
|
|
402
|
+
qb_en = np.nan
|
|
403
|
+
idx = (SBT["Q"] <= qb_in).sum()
|
|
404
|
+
|
|
405
|
+
# Adjust for zero-based indexing and avoid out-of-bounds errors
|
|
406
|
+
xb_in = SBT["Xb"].iloc[idx - 1] if idx > 0 else np.nan
|
|
407
|
+
zb_in = SBT["Z"].iloc[idx - 1] if idx > 0 else np.nan
|
|
408
|
+
sb_in = SBT["S"].iloc[idx - 1] if idx > 0 else np.nan
|
|
409
|
+
|
|
410
|
+
# Calculate available base storage
|
|
411
|
+
sba = np.max(SBT['S']) - sb_in
|
|
412
|
+
|
|
413
|
+
# Surface flow and other calculations
|
|
414
|
+
# Handle NaN values (matching R's na.rm=T)
|
|
415
|
+
qs_in_val = qin[ts] - qb_in
|
|
416
|
+
if np.isnan(qs_in_val):
|
|
417
|
+
qs_in = 0
|
|
418
|
+
else:
|
|
419
|
+
qs_in = max(0, qs_in_val) # Ensure surface flow is non-negative
|
|
420
|
+
|
|
421
|
+
zs_in_val = qs_in / (2 * lb * ks * alpha)
|
|
422
|
+
if np.isnan(zs_in_val):
|
|
423
|
+
zs_in = ws * alpha
|
|
424
|
+
else:
|
|
425
|
+
zs_in = min(zs_in_val, ws * alpha) # Saturated thickness of surface reservoir
|
|
426
|
+
ss_in = sur_store(lb, alpha, ws, por, zs_in) # Surface storage
|
|
427
|
+
ssa = sur_store(lb, alpha, ws, por, ws * alpha) - ss_in # AVAILABLE SURFACE STORAGE
|
|
428
|
+
|
|
429
|
+
# Infiltration and recharge
|
|
430
|
+
infil_in = 0
|
|
431
|
+
rech_in = recharge(lb, xb_in, ws, kz, zs_in, por)
|
|
432
|
+
|
|
433
|
+
# Use JIT-compiled core loop if requested
|
|
434
|
+
if use_jit:
|
|
435
|
+
# Convert SBT DataFrame to numpy arrays for JIT
|
|
436
|
+
sbt_xb = np.array(SBT['Xb'].values)
|
|
437
|
+
sbt_z = np.array(SBT['Z'].values)
|
|
438
|
+
sbt_s = np.array(SBT['S'].values)
|
|
439
|
+
sbt_q = np.array(SBT['Q'].values)
|
|
440
|
+
ifact_arr = np.array(ifact)
|
|
441
|
+
|
|
442
|
+
# Call JIT-compiled core loop
|
|
443
|
+
_bfs_core_loop(qin, sbt_xb, sbt_z, sbt_s, sbt_q, rise, p,
|
|
444
|
+
lb, alpha, ws, por, ks, kz, prec, ifact_arr, qmean,
|
|
445
|
+
X, qcomp, ETA, I, Z, ST, EXC)
|
|
446
|
+
else:
|
|
447
|
+
# Original implementation - keep the while loop as-is
|
|
448
|
+
while ts < p:
|
|
449
|
+
|
|
450
|
+
# Initialize Time Step Using State Variables for Previous Time Step if Available
|
|
451
|
+
if not ts_ini:
|
|
452
|
+
xb_in = X[ts - 1]
|
|
453
|
+
zb_in = Z[ts - 1, 1]
|
|
454
|
+
sb_in = ST[ts - 1, 1]
|
|
455
|
+
|
|
456
|
+
idx = (SBT["Xb"] <= xb_in).sum()
|
|
457
|
+
qb_in = SBT["Q"].iloc[idx - 1] if idx > 0 else np.nan
|
|
458
|
+
|
|
459
|
+
zs_in = Z[ts - 1, 0]
|
|
460
|
+
ss_in = ST[ts - 1, 0]
|
|
461
|
+
qs_in = sur_q(lb, alpha, ks, zs_in)
|
|
462
|
+
|
|
463
|
+
# Storage Capacity Available
|
|
464
|
+
ssa = sur_store(lb, alpha, ws, por, ws * alpha) - ss_in
|
|
465
|
+
sba = max(SBT['S']) - sb_in
|
|
466
|
+
rech_in = min(recharge(lb, xb_in, ws, kz, zs_in, por), sba + qb_in)
|
|
467
|
+
qd = 0
|
|
468
|
+
infil_in = 0
|
|
469
|
+
|
|
470
|
+
# Impulse (PPT) Needed to Generate Observed Streamflow
|
|
471
|
+
I[ts] = 0 # Set Impulse to Zero
|
|
472
|
+
etaest = max(0, qin[ts] - qb_in - qs_in)
|
|
473
|
+
|
|
474
|
+
# Initial Estimate of Impulse Required for Additional Surface Flow
|
|
475
|
+
if ts > 1 and etaest > 0:
|
|
476
|
+
if rise[ts] or rise[ts - 1]:
|
|
477
|
+
I[ts] = etaest / (2 * lb * ws)
|
|
478
|
+
zs = zs_in
|
|
479
|
+
qs = qs_in
|
|
480
|
+
|
|
481
|
+
# Loop to Calculate Additional Impulse Needed to Reduce ETA
|
|
482
|
+
# Use Progressively Smaller Incremental Changes in Impulse (ifact) for Iterations
|
|
483
|
+
for x in ifact:
|
|
484
|
+
i = I[ts]
|
|
485
|
+
eta = etaest
|
|
486
|
+
while eta > max(prec, qin[ts] / 100) and i > 0:
|
|
487
|
+
I[ts] = i
|
|
488
|
+
etaest = eta
|
|
489
|
+
i = x * i
|
|
490
|
+
infil = min(infiltration(lb, ws, ks, alpha, (zs_in + zs) / 2, i), ssa) # Limit Infiltration to Available Storage
|
|
491
|
+
ss = max(ss_in + infil - rech_in - qs, 0) # Update Surface Storage
|
|
492
|
+
zs = sur_z(lb, alpha, ws, por, ss)
|
|
493
|
+
qs = sur_q(lb, alpha, ks, zs)
|
|
494
|
+
qd = dir_q(lb, alpha, zs_in, i) + dir_q(lb, alpha, (zs - zs_in), i / 2) + max(2 * lb * (ws - zs_in / alpha) * (I[ts] - ks), 0)
|
|
495
|
+
eta = qin[ts] - qs - qd - qb_in
|
|
496
|
+
|
|
497
|
+
infil_in = min(infiltration(lb, ws, ks, alpha, zs_in, I[ts]), ssa) # Close Initial Calculations When Streamflow Record is Available (Not Projection)
|
|
498
|
+
|
|
499
|
+
# End of Time Step Calculations
|
|
500
|
+
ss_en = max(ss_in + infil_in - rech_in - qs_in, 0)
|
|
501
|
+
zs_en = sur_z(lb, alpha, ws, por, ss_en)
|
|
502
|
+
qs_en = sur_q(lb, alpha, ks, zs_en)
|
|
503
|
+
# Handle NaN values in infil_en calculation (matching R's na.rm=T)
|
|
504
|
+
infil_val = infiltration(lb, ws, ks, alpha, zs_en, I[ts])
|
|
505
|
+
if np.isnan(infil_val):
|
|
506
|
+
infil_en = ssa
|
|
507
|
+
else:
|
|
508
|
+
infil_en = min(infil_val, ssa)
|
|
509
|
+
rech_en = min(recharge(lb, xb_in, ws, kz, zs_en, por), sba + qb_in)
|
|
510
|
+
sb_en = max(sb_in + rech_en - qb_in, 0)
|
|
511
|
+
idx = max((SBT["S"] < sb_en).sum(), 1) - 1
|
|
512
|
+
|
|
513
|
+
# Safely extract the values from the DataFrame
|
|
514
|
+
xb_en = SBT["Xb"].iloc[idx] if 0 <= idx < len(SBT) else np.nan
|
|
515
|
+
zb_en = SBT["Z"].iloc[idx] if 0 <= idx < len(SBT) else np.nan
|
|
516
|
+
qb_en = SBT["Q"].iloc[idx] if 0 <= idx < len(SBT) else np.nan
|
|
517
|
+
|
|
518
|
+
# Final Calculations for Time Step
|
|
519
|
+
qcomp[ts, 0] = (qs_in + qs_en) / 2 # Surface Flow
|
|
520
|
+
qcomp[ts, 1] = (qb_in + qb_en) / 2 # Base Flow
|
|
521
|
+
|
|
522
|
+
EXC[ts, 0] = (infil_in + infil_en) / 2
|
|
523
|
+
EXC[ts, 1] = (rech_in + rech_en) / 2
|
|
524
|
+
|
|
525
|
+
# For Initial Time Step
|
|
526
|
+
if ts_ini:
|
|
527
|
+
ST[ts, :] = [ss_en, sb_en]
|
|
528
|
+
Z[ts, :] = [zs_en, zb_en]
|
|
529
|
+
# Direct runoff is NOT set for initial time step in R (remains NA), so leave as NaN
|
|
530
|
+
# qcomp[ts, 2] remains np.nan
|
|
531
|
+
|
|
532
|
+
# For Time Steps When States Are Available for Previous Time Step
|
|
533
|
+
if not ts_ini:
|
|
534
|
+
ST[ts, 0] = max(ST[ts - 1, 0] + EXC[ts, 0] - qcomp[ts, 0] - EXC[ts, 1], 0)
|
|
535
|
+
ST[ts, 0] = min(ST[ts, 0], sur_store(lb, alpha, ws, por, ws * alpha))
|
|
536
|
+
Z[ts, 0] = sur_z(lb, alpha, ws, por, ST[ts, 0])
|
|
537
|
+
ST[ts, 1] = max(ST[ts - 1, 1] + EXC[ts, 1] - qcomp[ts, 1], 0)
|
|
538
|
+
ST[ts, 1] = min(ST[ts, 1], max(SBT['S']))
|
|
539
|
+
|
|
540
|
+
idx = max((SBT['S'] <= ST[ts, 1]).sum(), 1) - 1
|
|
541
|
+
Z[ts, 1] = SBT['Z'].iloc[idx] if 0 <= idx < len(SBT) else np.nan
|
|
542
|
+
|
|
543
|
+
# Direct Runoff includes additional saturated area x half of rainfall (excess after infiltration), and any precipitation that exceeds infiltration rate
|
|
544
|
+
qcomp[ts, 2] = dir_q(lb, alpha, zs_in, I[ts]) + dir_q(lb, alpha, (Z[ts, 0] - zs_in), I[ts] / 2) + max(2 * lb * (ws - zs_in / alpha) * (I[ts] - ks), 0)
|
|
545
|
+
|
|
546
|
+
ETA[ts] = qin[ts] - np.sum(qcomp[ts, 0:3]) # Streamflow Residual
|
|
547
|
+
idx = max((SBT['S'] <= ST[ts, 1]).sum(), 1) - 1
|
|
548
|
+
X[ts] = SBT['Xb'].iloc[idx] if 0 <= idx < len(SBT) else np.nan
|
|
549
|
+
|
|
550
|
+
ts += 1
|
|
551
|
+
ts_ini = False
|
|
552
|
+
#CLOSE CONDITION ts<p
|
|
553
|
+
|
|
554
|
+
if error_basis == 'base':
|
|
555
|
+
q4er = qcomp[:, 1]
|
|
556
|
+
elif error_basis == 'total':
|
|
557
|
+
q4er = np.sum(qcomp, axis=1)
|
|
558
|
+
|
|
559
|
+
# ADJUSTED PERCENT ERROR
|
|
560
|
+
APE = (q4er - qin) / (qin + prec)
|
|
561
|
+
APE[(qin == 0) & (q4er == 0)] = 0
|
|
562
|
+
|
|
563
|
+
# WEIGHT VARIES FROM 0 TO 1 WITH INCREASING LENGTH OF RECESSION
|
|
564
|
+
Weight = 1 - np.exp(rs * recess_day)
|
|
565
|
+
|
|
566
|
+
# WEIGHT OF 1 IS ASSIGNED TO OVER PREDICTION
|
|
567
|
+
Weight[APE > 0] = 1
|
|
568
|
+
|
|
569
|
+
# Calculate Qsim - if any component is NaN, result should be NaN (matching R's rowSums behavior)
|
|
570
|
+
qsim = np.sum(qcomp, axis=1)
|
|
571
|
+
# R's rowSums returns NA if any element is NA, so set to NaN if any component is NaN
|
|
572
|
+
qsim[np.isnan(qcomp).any(axis=1)] = np.nan
|
|
573
|
+
|
|
574
|
+
tmp = pd.DataFrame({'Date': date, 'Qob': qin, 'Qsim': qsim, 'SurfaceFlow': qcomp[:, 0], 'Baseflow': qcomp[:, 1], 'DirectRunoff': qcomp[:, 2], 'X': X,'Eta': ETA, 'StSur': ST[:, 0], 'StBase': ST[:, 1], 'Impulse.L': I, 'Zs.L': Z[:, 0], 'Zb.L': Z[:, 1], 'Infil': EXC[:, 0], 'Rech': EXC[:, 1], 'RecessCount.T': recess_day, 'AdjPctEr': APE, 'Weight': Weight})
|
|
575
|
+
tmp = tmp[['Date', 'Qob', 'Qsim', 'SurfaceFlow', 'Baseflow', 'DirectRunoff', 'X','Eta', 'StSur', 'StBase', 'Impulse.L', 'Zs.L', 'Zb.L', 'Infil', 'Rech', 'RecessCount.T', 'AdjPctEr', 'Weight']]
|
|
576
|
+
return tmp
|
|
577
|
+
|