HBV-Lab 0.1.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.
HBV_Lab/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .HBV_model import HBVModel
HBV_Lab/calibration.py ADDED
@@ -0,0 +1,243 @@
1
+ class calibration:
2
+
3
+ def calibrate(self, method='SLSQP', objective='NSE', iterations=100,
4
+ verbose=True, plot_results=True):
5
+ """
6
+ Calibrate an HBV model's parameters to optimize the objective function.
7
+
8
+ Parameters:
9
+ -----------
10
+ self : HBVmodel
11
+ The HBV model instance to calibrate
12
+ method : str, default 'SLSQP'
13
+ Optimization method to use (see scipy.optimize.minimize).
14
+ Options include 'SLSQP', 'L-BFGS-B', 'Nelder-Mead', etc.
15
+ objective : str, default 'NSE'
16
+ Objective function to maximize. Options are:
17
+ - 'NSE': Nash-Sutcliffe Efficiency (higher is better)
18
+ - 'KGE': Kling-Gupta Efficiency (higher is better)
19
+ - 'RMSE': Root Mean Square Error (lower is better)
20
+ - 'MAE': Mean Absolute Error (lower is better)
21
+ iterations : int, default 100
22
+ Maximum number of iterations for the optimizer
23
+ verbose : bool, default True
24
+ Whether to print progress information
25
+ plot_results : bool, default True
26
+ Whether to plot the final results after calibration
27
+
28
+ Returns:
29
+ --------
30
+ dict
31
+ Dictionary containing optimized parameters and performance metrics
32
+ """
33
+ import scipy.optimize as opt
34
+ import numpy as np
35
+ import time
36
+ import copy
37
+
38
+ if self.data is None:
39
+ raise ValueError("No data loaded. Use load_data() method first.")
40
+
41
+ # Check if observed discharge data is available
42
+ if (self.column_names['obs_q'] is None or
43
+ self.column_names['obs_q'] not in self.data.columns):
44
+ raise ValueError("Observed discharge data is required for calibration.")
45
+ # Store the initial states to use it later for reseting the model
46
+ initial_states = self.states
47
+ # Extract observed discharge data
48
+ obs_q = self.data[self.column_names['obs_q']].values
49
+
50
+ # Get valid indices (where obs_q is not NaN)
51
+ valid_idx = ~np.isnan(obs_q)
52
+ if np.sum(valid_idx) == 0:
53
+ raise ValueError("No valid observed discharge values found.")
54
+
55
+ # Initial parameter values (using default values)
56
+ initial_params = []
57
+ param_names = []
58
+ param_bounds = []
59
+ param_groups = []
60
+
61
+ # Create flat parameter list for optimization
62
+ for group_name, group in self.params.items():
63
+ for param_name, param_info in group.items():
64
+ initial_params.append(param_info['default'])
65
+ param_names.append(f"{group_name}_{param_name}")
66
+ param_bounds.append((param_info['min'], param_info['max']))
67
+ param_groups.append(group_name)
68
+
69
+ # Helper function to convert flat parameter array to nested dictionary
70
+ def create_param_dict(flat_params):
71
+ # Create a deep copy of current parameters structure
72
+ param_dict = {group: {} for group in set(param_groups)}
73
+
74
+ # Fill in the parameter values
75
+ for i, (name, group) in enumerate(zip(param_names, param_groups)):
76
+ param_name = name.split('_', 1)[1] # Extract parameter name
77
+
78
+ # Initialize if not exists
79
+ if param_name not in param_dict[group]:
80
+ param_dict[group][param_name] = {}
81
+
82
+ # Copy min/max from original params
83
+ orig_group = self.params[group]
84
+ param_dict[group][param_name]['min'] = orig_group[param_name]['min']
85
+ param_dict[group][param_name]['max'] = orig_group[param_name]['max']
86
+
87
+ # Set the default to the optimized value
88
+ param_dict[group][param_name]['default'] = flat_params[i]
89
+
90
+ return param_dict
91
+
92
+
93
+
94
+ # Define the objective function to minimize
95
+ def objective_function(params):
96
+ # Update parameters structure with flat parameter array
97
+ param_dict = create_param_dict(params)
98
+
99
+ # Store original parameters to restore later
100
+ original_params = copy.deepcopy(self.params)
101
+
102
+ # Update model parameters
103
+ self.params = param_dict
104
+
105
+
106
+ # Run the model
107
+ self.run(verbose)
108
+
109
+ # Get simulated discharge and valid observed discharge
110
+ sim_q = self.results['discharge'][valid_idx]
111
+ obs_q_valid = obs_q[valid_idx]
112
+ self.calculate_performance_metrics
113
+ # Calculate objective function value
114
+ if objective == 'NSE':
115
+ # Nash-Sutcliffe Efficiency (to be maximized)
116
+ # mean_obs = np.mean(obs_q_valid)
117
+ # nse_numerator = np.sum((obs_q_valid - sim_q) ** 2)
118
+ # nse_denominator = np.sum((obs_q_valid - mean_obs) ** 2)
119
+ # value = 1 - (nse_numerator / nse_denominator)
120
+ # # For minimization, return negative NSE
121
+
122
+ return - self.performance_metrics['NSE']
123
+
124
+ elif objective == 'KGE':
125
+ # # Kling-Gupta Efficiency (to be maximized)
126
+ # mean_sim = np.mean(sim_q)
127
+ # mean_obs = np.mean(obs_q_valid)
128
+ # std_sim = np.std(sim_q)
129
+ # std_obs = np.std(obs_q_valid)
130
+
131
+ # r = np.corrcoef(obs_q_valid, sim_q)[0, 1] # Correlation
132
+ # alpha = (std_sim/mean_sim) / (std_obs/mean_sim) # Relative variability
133
+ # beta = mean_sim / mean_obs # Bias
134
+
135
+ # kge = 1 - np.sqrt((r - 1) ** 2 + (alpha - 1) ** 2 + (beta - 1) ** 2)
136
+ # For minimization, return negative KGE
137
+ return - self.performance_metrics['KGE']
138
+
139
+ elif objective == 'RMSE':
140
+ # Root Mean Square Error (to be minimized)
141
+ # rmse = np.sqrt(np.mean((obs_q_valid - sim_q) ** 2))
142
+ return self.performance_metrics['RMSE']
143
+
144
+ elif objective == 'MAE':
145
+ # Mean Absolute Error (to be minimized)
146
+ # mae = np.mean(np.abs(obs_q_valid - sim_q))
147
+ return self.performance_metrics['MAE']
148
+
149
+ else:
150
+ raise ValueError(f"Unknown objective function: {objective}")
151
+
152
+ # Callback function to track progress
153
+ num_iter = [0]
154
+ best_value = [float('inf') if objective in ['RMSE', 'MAE'] else float('-inf')]
155
+ start_time = time.time()
156
+
157
+ def callback(params):
158
+ num_iter[0] += 1
159
+ current_value = objective_function(params)
160
+
161
+ # For NSE and KGE, we're minimizing the negative value
162
+ if objective in ['NSE', 'KGE']:
163
+ display_value = -current_value
164
+ is_better = current_value < best_value[0]
165
+ else:
166
+ display_value = current_value
167
+ is_better = current_value < best_value[0]
168
+
169
+ if is_better:
170
+ best_value[0] = current_value
171
+
172
+ if verbose and num_iter[0] % max(1, iterations // 10) == 0:
173
+ elapsed = time.time() - start_time
174
+ print(f"Iteration {num_iter[0]}/{iterations}, "
175
+ f"{objective}: {display_value:.4f}, "
176
+ f"Time: {elapsed:.1f}s")
177
+
178
+ if verbose:
179
+ print(f"Starting calibration using {method} method...")
180
+ print(f"Optimizing {objective} with {len(param_names)} parameters and {iterations} iterations")
181
+
182
+ # Store original parameters to restore if needed
183
+ original_params = copy.deepcopy(self.params)
184
+
185
+ try:
186
+ # Run optimization
187
+ result = opt.minimize(
188
+ objective_function,
189
+ initial_params,
190
+ method=method,
191
+ bounds=param_bounds,
192
+ callback=callback,
193
+ options={'maxiter': iterations}
194
+ )
195
+
196
+ # Get optimized parameters
197
+ opt_params = result.x
198
+
199
+ # Create parameter dictionary from optimized values
200
+ optimized_params = create_param_dict(opt_params)
201
+
202
+ # Update model with optimized parameters
203
+ self.params = optimized_params
204
+
205
+ # Run the model with optimized parameters
206
+
207
+ self.run(verbose)
208
+
209
+ # Calculate final performance metrics
210
+ self.calculate_performance_metrics(verbose)
211
+
212
+ # Display results
213
+ if verbose:
214
+ print("\nCalibration completed!")
215
+ print(f"Final {objective}: {-result.fun if objective in ['NSE', 'KGE'] else result.fun:.4f}")
216
+ print(f"Success: {result.success}, Message: {result.message}")
217
+ print("\nOptimized Parameters:")
218
+
219
+ for group_name, group in optimized_params.items():
220
+ print(f"\n{group_name.upper()} parameters:")
221
+ for param_name, param_info in group.items():
222
+ print(f" {param_name}: {param_info['default']:.4f} (range: {param_info['min']}-{param_info['max']})")
223
+
224
+ print("\nPerformance Metrics:")
225
+ for metric, value in self.performance_metrics.items():
226
+ print(f" {metric}: {value:.4f}")
227
+
228
+ # Plot results if requested
229
+ if plot_results:
230
+ self.plot_results(show_plots=True)
231
+
232
+ # Return optimized parameters and performance
233
+ return {
234
+ 'parameters': optimized_params,
235
+ 'performance': self.performance_metrics,
236
+ 'optimization_result': result
237
+ }
238
+
239
+ except Exception as e:
240
+ # Restore original parameters on error
241
+ self.params = original_params
242
+ print(f"Calibration failed with error: {str(e)}")
243
+ raise
HBV_Lab/hbv_step.py ADDED
@@ -0,0 +1,74 @@
1
+ from snow import snow_routine
2
+ from soil import soil_routine
3
+ from response import response_routine_two_tanks
4
+
5
+ def hbv_step(precipitation, temperature, potential_et, params, initial_conditions):
6
+ """
7
+ HBV-light hydrological model (single timestep version).
8
+
9
+ Runs one timestep of the model using snow, soil, and groundwater response routines.
10
+
11
+ Args:
12
+ precipitation (float): Precipitation at the timestep (mm).
13
+ temperature (float): Air temperature at the timestep (°C).
14
+ potential_et (float): Potential evapotranspiration at the timestep (mm).
15
+ params (dict): Dictionary with parameters and their ranges:
16
+ - 'snow': parameters for the snow routine
17
+ - 'soil': parameters for the soil routine
18
+ - 'response': parameters for the groundwater response routine
19
+ initial_conditions (dict): Dictionary with initial storages:
20
+ - 'snowpack': initial snowpack (mm)
21
+ - 'liquid_water': initial liquid water in snowpack (mm)
22
+ - 'soil_moisture': initial soil moisture (mm)
23
+ - 'upper_storage': initial upper groundwater storage (mm)
24
+ - 'lower_storage': initial lower groundwater storage (mm)
25
+
26
+ Returns:
27
+ new_states (dict): Updated storages after the timestep.
28
+ fluxes (dict): Fluxes generated during the timestep (runoff, ET, etc.).
29
+ """
30
+
31
+ # Unpack initial conditions
32
+ snowpack = initial_conditions['snowpack']
33
+ liquid_water = initial_conditions['liquid_water']
34
+ soil_moisture = initial_conditions['soil_moisture']
35
+ upper_storage = initial_conditions['upper_storage']
36
+ lower_storage = initial_conditions['lower_storage']
37
+
38
+ # Step 1: Snow routine
39
+ snowpack, liquid_water, runoff_from_snow = snow_routine(
40
+ precipitation, temperature, snowpack, liquid_water, params['snow']
41
+ )
42
+
43
+ # Step 2: Soil routine
44
+ soil_moisture, out_to_response, recharge, runoff_soil, actual_et = soil_routine(
45
+ runoff_from_snow, temperature, potential_et, soil_moisture, params['soil']
46
+ )
47
+
48
+ # Step 3: Groundwater response routine
49
+ upper_storage, lower_storage, discharge, quick_flow, intermediate_flow, baseflow = response_routine_two_tanks(
50
+ out_to_response, upper_storage, lower_storage, params['response']
51
+ )
52
+
53
+ # Updated states
54
+ new_states = {
55
+ 'snowpack': snowpack,
56
+ 'liquid_water': liquid_water,
57
+ 'soil_moisture': soil_moisture,
58
+ 'upper_storage': upper_storage,
59
+ 'lower_storage': lower_storage,
60
+ }
61
+
62
+ # Outputs/Fluxes
63
+ fluxes = {
64
+ 'discharge': discharge,
65
+ 'quick_flow': quick_flow,
66
+ 'intermediate_flow': intermediate_flow,
67
+ 'baseflow': baseflow,
68
+ 'actual_et': actual_et,
69
+ 'recharge': recharge,
70
+ 'runoff_soil': runoff_soil,
71
+ 'runoff_from_snow': runoff_from_snow,
72
+ }
73
+
74
+ return new_states, fluxes
HBV_Lab/response.py ADDED
@@ -0,0 +1,78 @@
1
+ def response_routine_two_tanks(out_to_response, upper_storage, lower_storage, params):
2
+ """
3
+ Groundwater response routine for HBV-light model with three flow components.
4
+
5
+ Based on the HBV structure
6
+
7
+ Parameters:
8
+ -----------
9
+ out_to_response : float
10
+ Water from soil routine (recharge + surface runoff) (mm/day).
11
+ upper_storage : float
12
+ Current storage in the upper zone reservoir (SUZ) (mm).
13
+ lower_storage : float
14
+ Current storage in the lower zone reservoir (SLZ) (mm).
15
+ params : dict
16
+ Model parameters:
17
+ - 'K0' : Recession coefficient for quick flow (upper zone above threshold) [day^-1].
18
+ - 'K1' : Recession coefficient for intermediate flow (upper zone) [day^-1].
19
+ - 'K2' : Recession coefficient for baseflow (lower zone) [day^-1].
20
+ - 'UZL' : Threshold parameter for upper zone [mm].
21
+ - 'PERC' : Percolation rate from upper to lower zone (mm/day).
22
+
23
+ Returns:
24
+ --------
25
+ upper_storage : float
26
+ Updated upper zone storage (mm).
27
+ lower_storage : float
28
+ Updated lower zone storage (mm).
29
+ discharge : float
30
+ Total discharge (fast + intermediate + slow runoff) (mm/day).
31
+ quick_flow : float
32
+ Quick flow component from upper zone (above threshold).
33
+ intermediate_flow : float
34
+ Intermediate flow component from upper zone.
35
+ baseflow : float
36
+ Baseflow component from lower zone.
37
+ """
38
+
39
+ # Extract parameters
40
+ K0 = params['K0'] ['default'] # Quick flow coefficient (above threshold)
41
+ K1 = params['K1'] ['default'] # Intermediate flow coefficient
42
+ K2 = params['K2'] ['default'] # Baseflow coefficient
43
+ UZL = params['UZL'] ['default'] # Upper zone threshold
44
+ PERC = params['PERC'] ['default'] # Percolation from upper to lower zone
45
+
46
+ # --- Step 1: Add incoming water to upper zone reservoir ---
47
+ upper_storage += out_to_response
48
+
49
+ # --- Step 2: Percolation from upper to lower zone ---
50
+ percolation = min(PERC, upper_storage)
51
+ upper_storage -= percolation
52
+ lower_storage += percolation
53
+
54
+ # --- Step 3: Calculate runoff components ---
55
+ # Quick flow (Q0): only occurs if upper zone storage exceeds threshold
56
+ if upper_storage > UZL:
57
+ quick_flow = K0 * (upper_storage - UZL)
58
+ else:
59
+ quick_flow = 0.0
60
+
61
+ # Intermediate flow (Q1): from all upper zone storage
62
+ intermediate_flow = K1 * upper_storage
63
+
64
+ # Baseflow (Q2): from lower zone storage
65
+ baseflow = K2 * lower_storage
66
+
67
+ # --- Step 4: Update storages after runoff ---
68
+ upper_storage -= (quick_flow + intermediate_flow)
69
+ lower_storage -= baseflow
70
+
71
+ # --- Step 5: Total discharge ---
72
+ discharge = quick_flow + intermediate_flow + baseflow
73
+
74
+ # --- Step 6: Prevent negative storages ---
75
+ upper_storage = max(upper_storage, 0.0)
76
+ lower_storage = max(lower_storage, 0.0)
77
+
78
+ return upper_storage, lower_storage, discharge, quick_flow, intermediate_flow, baseflow
HBV_Lab/routing.py ADDED
@@ -0,0 +1,41 @@
1
+ import numpy as np
2
+ from scipy.ndimage import convolve1d
3
+
4
+ def route_with_maxbas(runoff_series= None, maxbas=1):
5
+ """
6
+ Correct linear interpolation routing as per HBV specifications
7
+ - MAXBAS=1: No routing (weights = [1.0])
8
+ - MAXBAS=2: Weights = [0.5, 0.5]
9
+ - MAXBAS=3: Weights = [0.25, 0.5, 0.25]
10
+ - MAXBAS=4: Weights = [0.125, 0.375, 0.375, 0.125]
11
+ - And so on...
12
+ """
13
+ if maxbas < 1:
14
+ raise ValueError("MAXBAS must be ≥1")
15
+
16
+ # Special case - no routing
17
+ if maxbas == 1:
18
+ return runoff_series.copy()
19
+
20
+ # Create proper weights using linear interpolation
21
+ if maxbas % 2 == 0: # Even MAXBAS
22
+ n = maxbas // 2
23
+ x = np.linspace(0.5, n - 0.5, n)
24
+ weights = np.concatenate([x, x[::-1]]) / (n)
25
+ else: # Odd MAXBAS
26
+ n = (maxbas + 1) // 2
27
+ x = np.linspace(0.5, n - 1, n)
28
+ weights = np.concatenate([x, x[-2::-1]]) / (n - 0.5)
29
+
30
+ weights /= weights.sum() # Ensure exact sum=1
31
+
32
+ # Apply convolution
33
+ routed = convolve1d(
34
+ runoff_series,
35
+ weights,
36
+ origin=-(len(weights)//2),
37
+ mode='constant',
38
+ cval=0.0
39
+ )
40
+
41
+ return routed[:len(runoff_series)]
HBV_Lab/snow.py ADDED
@@ -0,0 +1,70 @@
1
+ def snow_routine(precipitation, temperature, snowpack, liquid_water, params):
2
+ """
3
+ Snow routine: calculates snow accumulation, melt, refreezing, and release of meltwater.
4
+
5
+ Args:
6
+ precipitation (float): Precipitation at timestep (mm).
7
+ temperature (float): Air temperature at timestep (°C).
8
+ snowpack (float): Current snow water equivalent (mm).
9
+ liquid_water (float): Liquid water stored in snowpack (mm).
10
+ params (dict): Model parameters:
11
+ - TT (float): Threshold temperature (°C).
12
+ - CFMAX (float): Degree-day factor (mm/d/°C).
13
+ - CFR (float): Refreezing factor (-).
14
+ - CWH (float): Water holding capacity of snowpack (-, e.g., 0.1).
15
+ - SFCF (float): Snowfall correction factor (-).
16
+ - PCF (float): Precipitation correction factor——to account for bias in precipitation (-).
17
+
18
+
19
+ Returns:
20
+ new_snowpack (float): Updated snowpack (mm).
21
+ new_liquid_water (float): Updated liquid water in snowpack (mm).
22
+ runoff (float): Released meltwater to soil (mm).
23
+ """
24
+ # Extract parameters
25
+ TT = params['TT']['default']
26
+ CFMAX = params['CFMAX']['default']
27
+ CFR = params['CFR']['default']
28
+ CWH = params['CWH']['default']
29
+ SFCF = params['SFCF']['default']
30
+ #PCF = params['PCF']['default']
31
+
32
+ # Give the model a room to account for any biases in the estimation of precipitation
33
+
34
+ precipitation= precipitation
35
+
36
+ # Initialize snowfall and rainfall
37
+ snowfall = 0.0
38
+ rainfall = 0.0
39
+ # Calculate melting or refreezing
40
+ melt = 0.0
41
+ refreeze = 0.0
42
+
43
+ # Determine if precipitation is snow or rain
44
+ if temperature < TT:
45
+
46
+ snowfall = precipitation * SFCF
47
+ snowpack += snowfall
48
+
49
+ refreeze = CFR * CFMAX * (TT - temperature)
50
+ refreeze = min(refreeze, liquid_water)
51
+ snowpack += refreeze
52
+ liquid_water -= refreeze
53
+ else:
54
+
55
+ rainfall = precipitation
56
+ melt = CFMAX * (temperature - TT)
57
+ melt = min(melt, snowpack) # Cannot melt more than available snow
58
+ snowpack -= melt
59
+ liquid_water += melt + rainfall
60
+
61
+
62
+ # Water release from snowpack if above holding capacity
63
+ holding_capacity = CWH * snowpack
64
+ if liquid_water > holding_capacity:
65
+ runoff = liquid_water - holding_capacity
66
+ liquid_water = holding_capacity
67
+ else:
68
+ runoff = 0.0
69
+
70
+ return snowpack, liquid_water, runoff
HBV_Lab/soil.py ADDED
@@ -0,0 +1,73 @@
1
+ def soil_routine(runoff_from_snow, temperature, potential_et, soil_moisture, params):
2
+ """
3
+ Soil moisture routine for HBV-light model.
4
+
5
+ Simulates soil moisture dynamics, actual evapotranspiration (ET),
6
+ groundwater recharge, and surface runoff based on incoming water
7
+ and current soil moisture conditions.
8
+
9
+ Parameters:
10
+ -----------
11
+ runoff_from_snow : float
12
+ Incoming liquid wls
13
+ ater from snow routine (mm/day).
14
+ temperature : float
15
+ Daily average temperature (°C). (Currently unused but kept for possible extensions).
16
+ potential_et : float
17
+ Potential evapotranspiration (mm/day).
18
+ soil_moisture : float
19
+ Current soil moisture storage (mm).
20
+ params : dict
21
+ Model parameters:
22
+ - 'FC' : Maximum soil moisture storage (Field Capacity) [mm].
23
+ - 'BETA' : Shape parameter controlling recharge [-].
24
+ - 'LP' : Soil moisture threshold for full evapotranspiration [-].
25
+
26
+ Returns:
27
+ --------
28
+ soil_moisture : float
29
+ Updated soil moisture storage (mm).
30
+ recharge : float
31
+ Water recharging to the groundwater (mm/day).
32
+ actual_et : float
33
+ Actual evapotranspiration (mm/day).
34
+ runoff : float
35
+ Surface runoff due to soil overflow (mm/day).
36
+ """
37
+
38
+ FC = params['FC'] ['default'] # Maximum soil moisture capacity
39
+ BETA = params['BETA'] ['default'] # Recharge curve parameter
40
+ LP = params['LP'] ['default'] # Limit for potential ET to be fully achieved
41
+
42
+ # --- Step 1: Calculate actual evapotranspiration ---
43
+ if soil_moisture > LP * FC:
44
+ actual_et = potential_et # Full potential ET when soil is wet enough
45
+ else:
46
+ actual_et = potential_et * (soil_moisture / (LP * FC)) # Reduced ET if soil is dry
47
+
48
+ # Ensure we don't evaporate more than what's available
49
+ actual_et = min(actual_et, soil_moisture)
50
+
51
+ # --- Step 2: Calculate groundwater recharge ---
52
+ if soil_moisture > 0:
53
+ recharge = runoff_from_snow * (soil_moisture / FC) ** BETA
54
+ else:
55
+ recharge = 0.0
56
+
57
+ # --- Step 3: Update soil moisture balance ---
58
+ soil_moisture = soil_moisture + runoff_from_snow - actual_et - recharge
59
+
60
+ # --- Step 4: Handle surface runoff if soil moisture exceeds FC ---
61
+ if soil_moisture > FC:
62
+ runoff = soil_moisture - FC # Excess water becomes surface runoff
63
+ soil_moisture = FC # Cap soil moisture at field capacity
64
+
65
+ else:
66
+ runoff = 0.0 # No surface runoff
67
+
68
+ # --- Step 5: Prevent negative soil moisture ---
69
+ soil_moisture = max(soil_moisture, 0.0)
70
+
71
+ out_to_response = recharge + runoff
72
+
73
+ return soil_moisture, out_to_response, recharge, runoff, actual_et