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 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
+