HBV-Lab 0.1.0__tar.gz
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-0.1.0/HBV_Lab/HBV_model.py +792 -0
- hbv_lab-0.1.0/HBV_Lab/__init__.py +1 -0
- hbv_lab-0.1.0/HBV_Lab/calibration.py +243 -0
- hbv_lab-0.1.0/HBV_Lab/hbv_step.py +74 -0
- hbv_lab-0.1.0/HBV_Lab/response.py +78 -0
- hbv_lab-0.1.0/HBV_Lab/routing.py +41 -0
- hbv_lab-0.1.0/HBV_Lab/snow.py +70 -0
- hbv_lab-0.1.0/HBV_Lab/soil.py +73 -0
- hbv_lab-0.1.0/HBV_Lab/uncertainty.py +364 -0
- hbv_lab-0.1.0/HBV_Lab.egg-info/PKG-INFO +86 -0
- hbv_lab-0.1.0/HBV_Lab.egg-info/SOURCES.txt +17 -0
- hbv_lab-0.1.0/HBV_Lab.egg-info/dependency_links.txt +1 -0
- hbv_lab-0.1.0/HBV_Lab.egg-info/requires.txt +5 -0
- hbv_lab-0.1.0/HBV_Lab.egg-info/top_level.txt +1 -0
- hbv_lab-0.1.0/LICENSE +21 -0
- hbv_lab-0.1.0/PKG-INFO +86 -0
- hbv_lab-0.1.0/README.md +51 -0
- hbv_lab-0.1.0/setup.cfg +4 -0
- hbv_lab-0.1.0/setup.py +33 -0
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HBV Hydrological Model
|
|
3
|
+
|
|
4
|
+
This module integrates the snow, soil, and response routines into a complete
|
|
5
|
+
HBV-like hydrological model. It handles parameter management, data reading, model
|
|
6
|
+
execution, and output visualization.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from hbv_model import HBVModel
|
|
10
|
+
model = HBVModel()
|
|
11
|
+
model.load_data("path/to/data.csv")
|
|
12
|
+
model.set_parameters(params)
|
|
13
|
+
model.run()
|
|
14
|
+
model.plot_results()
|
|
15
|
+
model.save_results()
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import pandas as pd
|
|
20
|
+
import matplotlib.pyplot as plt
|
|
21
|
+
from matplotlib.dates import DateFormatter
|
|
22
|
+
import os
|
|
23
|
+
import datetime
|
|
24
|
+
from types import MethodType
|
|
25
|
+
from uncertainty import uncertainty
|
|
26
|
+
from calibration import calibration
|
|
27
|
+
# from calibration import calibrate_hbv_model
|
|
28
|
+
# from uncertainty import evaluate_uncertainty
|
|
29
|
+
from hbv_step import hbv_step
|
|
30
|
+
from routing import route_with_maxbas
|
|
31
|
+
|
|
32
|
+
class HBVModel(uncertainty, calibration):
|
|
33
|
+
"""
|
|
34
|
+
HBV hydrological model class that integrates snow, soil, and response routines.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self):
|
|
38
|
+
"""Initialize the HBV model with default values."""
|
|
39
|
+
self.data = None
|
|
40
|
+
self.results = None
|
|
41
|
+
self.params= {
|
|
42
|
+
'snow': {
|
|
43
|
+
'TT': {'min': -2.0, 'max': 2.0, 'default': 0.0},
|
|
44
|
+
'CFMAX': {'min': 1.0, 'max': 6.0, 'default': 3.5},
|
|
45
|
+
# 'PCF': {'min': 0.5, 'max': 1.5, 'default': 1.0},
|
|
46
|
+
'SFCF': {'min': 0.5, 'max': 1.5, 'default': 1.0},
|
|
47
|
+
'CFR': {'min': 0.0, 'max': 0.2, 'default': 0.05},
|
|
48
|
+
'CWH': {'min': 0.0, 'max': 0.2, 'default': 0.1}
|
|
49
|
+
},
|
|
50
|
+
'soil': {
|
|
51
|
+
'FC': {'min': 50.0, 'max': 300.0, 'default': 150.0},
|
|
52
|
+
'LP': {'min': 0.3, 'max': 1.0, 'default': 0.7},
|
|
53
|
+
'BETA': {'min': 1.0, 'max': 5.0, 'default': 2.0}
|
|
54
|
+
},
|
|
55
|
+
'response': {
|
|
56
|
+
'K0': {'min': 0.1, 'max': 0.9, 'default': 0.5},
|
|
57
|
+
'K1': {'min': 0.05, 'max': 0.5, 'default': 0.2},
|
|
58
|
+
'K2': {'min': 0.01, 'max': 0.1, 'default': 0.05},
|
|
59
|
+
'UZL': {'min': 5.0, 'max': 50.0, 'default': 20.0},
|
|
60
|
+
'PERC': {'min': 0.5, 'max': 3.0, 'default': 1.5},
|
|
61
|
+
'MAXBAS' :{'min': 1, 'max': 10, 'default': 3}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Initial states
|
|
66
|
+
self.states = {
|
|
67
|
+
'snowpack': 0.0, # Snow pack (mm)
|
|
68
|
+
'liquid_water': 0.0, # Liquid water in snow (mm)
|
|
69
|
+
'soil_moisture': 30.0, # Soil moisture (mm)
|
|
70
|
+
'upper_storage': 10.0, # Upper zone storage (mm)
|
|
71
|
+
'lower_storage': 20.0 # Lower zone storage (mm)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Initialize time tracking
|
|
75
|
+
self.start_date = None
|
|
76
|
+
self.end_date = None
|
|
77
|
+
self.time_step = 'D' # Default: daily
|
|
78
|
+
##### link externaly defined functions to the model
|
|
79
|
+
# self.calibrate = MethodType(calibrate_hbv_model, self)
|
|
80
|
+
# self.evaluate_uncertainty = MethodType(evaluate_uncertainty, self)
|
|
81
|
+
|
|
82
|
+
def load_data(self, file_path=None, data=None, date_column='Date',
|
|
83
|
+
precip_column='Precipitation', temp_column='Temperature',
|
|
84
|
+
pet_column='PotentialET', obs_q_column=None, date_format='%Y%m%d',
|
|
85
|
+
start_date=None, warmup_end=None, end_date=None ):
|
|
86
|
+
"""
|
|
87
|
+
Load data from file or DataFrame, handling PET interpolation and flexible date parsing.
|
|
88
|
+
|
|
89
|
+
Parameters:
|
|
90
|
+
-----------
|
|
91
|
+
file_path : str, optional
|
|
92
|
+
Path to CSV file.
|
|
93
|
+
data : pandas.DataFrame, optional
|
|
94
|
+
Pre-loaded DataFrame.
|
|
95
|
+
date_column : str, default 'Date'
|
|
96
|
+
Name of column containing date.
|
|
97
|
+
precip_column : str
|
|
98
|
+
temp_column : str
|
|
99
|
+
pet_column : str
|
|
100
|
+
obs_q_column : str, optional
|
|
101
|
+
start_date : str or datetime, optional
|
|
102
|
+
end_date : str or datetime, optional
|
|
103
|
+
date_format : str, optional
|
|
104
|
+
Format string for parsing dates (e.g. '%Y%m%d' for '19510601').
|
|
105
|
+
warmup_end : str or datetime, optional
|
|
106
|
+
The end date of the warmup period. Data before or at this date will be included
|
|
107
|
+
in the simulation but excluded from performance evaluation.
|
|
108
|
+
"""
|
|
109
|
+
import pandas as pd
|
|
110
|
+
|
|
111
|
+
if file_path is not None:
|
|
112
|
+
data = pd.read_csv(file_path)
|
|
113
|
+
|
|
114
|
+
if data is None:
|
|
115
|
+
raise ValueError("Either file_path or data must be provided.")
|
|
116
|
+
|
|
117
|
+
has_date = date_column in data.columns and data[date_column].notna().all()
|
|
118
|
+
|
|
119
|
+
if has_date:
|
|
120
|
+
try:
|
|
121
|
+
|
|
122
|
+
data[date_column] = pd.to_datetime(data[date_column], format=date_format)
|
|
123
|
+
|
|
124
|
+
except Exception as e:
|
|
125
|
+
print(f"Warning: Failed to convert {date_column} to datetime. {e}")
|
|
126
|
+
has_date = False
|
|
127
|
+
|
|
128
|
+
# Expand PET if monthly means
|
|
129
|
+
if pet_column in data.columns:
|
|
130
|
+
pet_data = data[pet_column].dropna()
|
|
131
|
+
if len(pet_data) == 12:
|
|
132
|
+
print("Detected 12 PET values (monthly means), expanding to daily values...")
|
|
133
|
+
monthly_pet = pd.DataFrame({'month': range(1, 13), 'pet': pet_data.values})
|
|
134
|
+
|
|
135
|
+
if has_date:
|
|
136
|
+
min_date = data[date_column].min()
|
|
137
|
+
max_date = data[date_column].max()
|
|
138
|
+
full_date_range = pd.date_range(start=min_date, end=max_date, freq='D')
|
|
139
|
+
daily_df = pd.DataFrame({date_column: full_date_range, 'month': full_date_range.month})
|
|
140
|
+
else:
|
|
141
|
+
daily_df = pd.DataFrame({'index': data.index, 'month': ((data.index // 30) % 12 + 1)})
|
|
142
|
+
|
|
143
|
+
daily_df = daily_df.merge(monthly_pet, on='month', how='left')
|
|
144
|
+
daily_df['pet'] = daily_df['pet'].where(daily_df['pet'] != daily_df['pet'].shift(), np.nan)
|
|
145
|
+
daily_df['pet'] = daily_df['pet'].interpolate(method='linear')
|
|
146
|
+
|
|
147
|
+
# smoothed_pet = pd.Series(monthly_pet['pet'].tolist() * 3).interpolate().iloc[12:24].values
|
|
148
|
+
# daily_df['pet'] = daily_df['month'].map(lambda m: smoothed_pet[m - 1])
|
|
149
|
+
|
|
150
|
+
data = data.drop(columns=[pet_column])
|
|
151
|
+
merge_col = date_column if has_date else 'index'
|
|
152
|
+
data = data.reset_index().merge(daily_df[[merge_col, 'pet']], on=merge_col, how='left').set_index('index')
|
|
153
|
+
data = data.rename(columns={'pet': pet_column})
|
|
154
|
+
|
|
155
|
+
if has_date:
|
|
156
|
+
# Convert all dates to datetime objects first
|
|
157
|
+
start_dt = pd.to_datetime(start_date, format=date_format) if start_date else None
|
|
158
|
+
end_dt = pd.to_datetime(end_date, format=date_format) if end_date else None
|
|
159
|
+
warmup_dt = pd.to_datetime(warmup_end, format=date_format) if warmup_end else None
|
|
160
|
+
|
|
161
|
+
# Validate date order
|
|
162
|
+
if start_dt is not None and end_dt is not None:
|
|
163
|
+
if start_dt >= end_dt:
|
|
164
|
+
raise ValueError("start_date must be earlier than end_date")
|
|
165
|
+
|
|
166
|
+
if warmup_dt is not None:
|
|
167
|
+
if start_dt is not None and warmup_dt <= start_dt:
|
|
168
|
+
raise ValueError("warmup_end must be after start_date")
|
|
169
|
+
if end_dt is not None and warmup_dt >= end_dt:
|
|
170
|
+
raise ValueError("warmup_end must be before end_date")
|
|
171
|
+
# apply filtering
|
|
172
|
+
if start_date is not None:
|
|
173
|
+
data = data[data[date_column] >= pd.to_datetime(start_date,format=date_format)]
|
|
174
|
+
if end_date is not None:
|
|
175
|
+
data = data[data[date_column] <= pd.to_datetime(end_date,format=date_format)]
|
|
176
|
+
|
|
177
|
+
self.data = data.reset_index(drop=True)
|
|
178
|
+
self.column_names = {
|
|
179
|
+
'date': date_column if has_date else None,
|
|
180
|
+
'precip': precip_column,
|
|
181
|
+
'temp': temp_column,
|
|
182
|
+
'pet': pet_column,
|
|
183
|
+
'obs_q': obs_q_column
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if has_date and len(data) > 0:
|
|
187
|
+
self.start_date = data[date_column].min()
|
|
188
|
+
self.end_date = data[date_column].max()
|
|
189
|
+
diff = data[date_column].diff().dropna()
|
|
190
|
+
if not diff.empty:
|
|
191
|
+
modal_diff = diff.mode().iloc[0]
|
|
192
|
+
self.time_step = 'D' if modal_diff == pd.Timedelta(days=1) else (
|
|
193
|
+
'H' if modal_diff == pd.Timedelta(hours=1) else str(modal_diff))
|
|
194
|
+
print(f"Time step detected: {self.time_step}")
|
|
195
|
+
else:
|
|
196
|
+
self.start_date = None
|
|
197
|
+
self.end_date = None
|
|
198
|
+
warmup_end = None
|
|
199
|
+
self.time_step = 'Index-based'
|
|
200
|
+
print("No date column found; using index as time step.")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# Store warmup_end
|
|
204
|
+
self.warmup_end = None
|
|
205
|
+
if warmup_end is not None:
|
|
206
|
+
try:
|
|
207
|
+
self.warmup_end = pd.to_datetime(warmup_end, format=date_format)
|
|
208
|
+
print(f"Warmup period ends at: {self.warmup_end}")
|
|
209
|
+
except:
|
|
210
|
+
print(f"Warning: Could not parse warmup_end date '{warmup_end}'. 10% warmup period will be used.")
|
|
211
|
+
else: print(f"No warmup_end specified. Excluding first 10% of data when evaluating——as warmup period.")
|
|
212
|
+
|
|
213
|
+
print(f"Loaded data with {len(self.data)} time steps, from {self.start_date} to {self.end_date}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def set_parameters(self, custom_ranges=None):
|
|
217
|
+
"""Set parameters and thier ranges to overwrite the default.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
custom_ranges (dict, optional): A dictionary with the same structure as `self.params`,
|
|
221
|
+
containing custom min/max/default values.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
# If custom ranges are provided, update the defaults
|
|
225
|
+
if custom_ranges is not None:
|
|
226
|
+
for group in custom_ranges:
|
|
227
|
+
if group in self.params:
|
|
228
|
+
for param in custom_ranges[group]:
|
|
229
|
+
if param in self.params[group]:
|
|
230
|
+
self.params[group][param].update(custom_ranges[group][param])
|
|
231
|
+
else:
|
|
232
|
+
raise ValueError(f"Unknown parameter '{param}' in group '{group}'")
|
|
233
|
+
else:
|
|
234
|
+
raise ValueError(f"Unknown group '{group}'")
|
|
235
|
+
else: raise ValueError(f"No parameters provided—–make sure to provide the input in the correct format")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def set_initial_conditions(self, snowpack=None, liquid_water=None,
|
|
239
|
+
soil_moisture=None, upper_storage=None,
|
|
240
|
+
lower_storage=None):
|
|
241
|
+
"""
|
|
242
|
+
Set initial conditions for model states.
|
|
243
|
+
|
|
244
|
+
Parameters:
|
|
245
|
+
-----------
|
|
246
|
+
snowpack : float, optional
|
|
247
|
+
Initial snow pack (mm)
|
|
248
|
+
liquid_water : float, optional
|
|
249
|
+
Initial liquid water in snow (mm)
|
|
250
|
+
soil_moisture : float, optional
|
|
251
|
+
Initial soil moisture (mm)
|
|
252
|
+
upper_storage : float, optional
|
|
253
|
+
Initial upper zone storage (mm)
|
|
254
|
+
lower_storage : float, optional
|
|
255
|
+
Initial lower zone storage (mm)
|
|
256
|
+
"""
|
|
257
|
+
if snowpack is not None:
|
|
258
|
+
self.states['snowpack'] = snowpack
|
|
259
|
+
if liquid_water is not None:
|
|
260
|
+
self.states['liquid_water'] = liquid_water
|
|
261
|
+
if soil_moisture is not None:
|
|
262
|
+
self.states['soil_moisture'] = soil_moisture
|
|
263
|
+
if upper_storage is not None:
|
|
264
|
+
self.states['upper_storage'] = upper_storage
|
|
265
|
+
if lower_storage is not None:
|
|
266
|
+
self.states['lower_storage'] = lower_storage
|
|
267
|
+
|
|
268
|
+
print("Initial conditions updated.")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def run(self, verbose= True):
|
|
272
|
+
"""
|
|
273
|
+
Run the HBV model for the entire simulation period.
|
|
274
|
+
"""
|
|
275
|
+
# Store the initial states to reset them at the end
|
|
276
|
+
initial_states = self.states
|
|
277
|
+
|
|
278
|
+
if self.data is None:
|
|
279
|
+
raise ValueError("No data loaded. Use load_data() method first.")
|
|
280
|
+
|
|
281
|
+
# Extract data arrays
|
|
282
|
+
precip = self.data[self.column_names['precip']].values
|
|
283
|
+
temp = self.data[self.column_names['temp']].values
|
|
284
|
+
pet = self.data[self.column_names['pet']].values
|
|
285
|
+
|
|
286
|
+
# Get dates if available
|
|
287
|
+
if self.column_names['date'] in self.data.columns:
|
|
288
|
+
dates = self.data[self.column_names['date']].values
|
|
289
|
+
else:
|
|
290
|
+
dates = np.arange(len(precip))
|
|
291
|
+
|
|
292
|
+
# Get observed discharge if available
|
|
293
|
+
if self.column_names['obs_q'] is not None and self.column_names['obs_q'] in self.data.columns:
|
|
294
|
+
obs_q = self.data[self.column_names['obs_q']].values
|
|
295
|
+
else:
|
|
296
|
+
obs_q = None
|
|
297
|
+
|
|
298
|
+
# Initialize storage arrays
|
|
299
|
+
n_steps = len(precip)
|
|
300
|
+
|
|
301
|
+
# Initialize results dictionary
|
|
302
|
+
results = {
|
|
303
|
+
'dates': dates,
|
|
304
|
+
'snowpack': np.zeros(n_steps),
|
|
305
|
+
'liquid_water': np.zeros(n_steps),
|
|
306
|
+
'runoff_from_snow': np.zeros(n_steps),
|
|
307
|
+
'soil_moisture': np.zeros(n_steps),
|
|
308
|
+
'recharge': np.zeros(n_steps),
|
|
309
|
+
'runoff_soil': np.zeros(n_steps),
|
|
310
|
+
'actual_et': np.zeros(n_steps),
|
|
311
|
+
'upper_storage': np.zeros(n_steps),
|
|
312
|
+
'lower_storage': np.zeros(n_steps),
|
|
313
|
+
'quick_flow': np.zeros(n_steps),
|
|
314
|
+
'intermediate_flow': np.zeros(n_steps),
|
|
315
|
+
'baseflow': np.zeros(n_steps),
|
|
316
|
+
'discharge': np.zeros(n_steps),
|
|
317
|
+
'precipitation': precip,
|
|
318
|
+
'temperature': temp,
|
|
319
|
+
'potential_et': pet
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if obs_q is not None:
|
|
323
|
+
results['observed_q'] = obs_q
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
if verbose: print(f"Starting model run for {n_steps} time steps...")
|
|
327
|
+
|
|
328
|
+
# Main simulation loop
|
|
329
|
+
for t in range(n_steps):
|
|
330
|
+
|
|
331
|
+
self.states, fluxes = hbv_step(precip[t], temp[t], pet[t], self.params, self.states)
|
|
332
|
+
|
|
333
|
+
# Store results
|
|
334
|
+
results['snowpack'][t] = self.states['snowpack']
|
|
335
|
+
results['liquid_water'][t] = self.states['liquid_water']
|
|
336
|
+
results['runoff_from_snow'][t] = fluxes['runoff_from_snow']
|
|
337
|
+
results['soil_moisture'][t] = self.states['soil_moisture']
|
|
338
|
+
results['recharge'][t] = fluxes['recharge']
|
|
339
|
+
results['runoff_soil'][t] = fluxes['runoff_soil']
|
|
340
|
+
results['actual_et'][t] = fluxes['actual_et']
|
|
341
|
+
results['upper_storage'][t] = self.states['upper_storage']
|
|
342
|
+
results['lower_storage'][t] = self.states['lower_storage']
|
|
343
|
+
results['quick_flow'][t] = fluxes['quick_flow']
|
|
344
|
+
results['intermediate_flow'][t] = fluxes['intermediate_flow']
|
|
345
|
+
results['baseflow'][t] = fluxes['baseflow']
|
|
346
|
+
results['discharge'][t] = fluxes['discharge']
|
|
347
|
+
|
|
348
|
+
# # Store final storage values
|
|
349
|
+
# self.storages['snowpack'] = snowpack
|
|
350
|
+
# self.storages['liquid_water'] = liquid_water
|
|
351
|
+
# self.storages['soil_moisture'] = soil_moisture
|
|
352
|
+
# self.storages['upper_storage'] = upper_storage
|
|
353
|
+
# self.storages['lower_storage'] = lower_storage
|
|
354
|
+
|
|
355
|
+
# Store results
|
|
356
|
+
# Add routing if MAXBAS > 1
|
|
357
|
+
maxbas = int(self.params['response']['MAXBAS']['default'])
|
|
358
|
+
if maxbas > 1:
|
|
359
|
+
if verbose:
|
|
360
|
+
print(f"Applying MAXBAS routing with n={maxbas} time steps")
|
|
361
|
+
routed_discharge = route_with_maxbas(results['discharge'], maxbas)
|
|
362
|
+
results['discharge'] = routed_discharge
|
|
363
|
+
|
|
364
|
+
# Also route components if needed
|
|
365
|
+
results['quick_flow'] = route_with_maxbas(results['quick_flow'], maxbas)
|
|
366
|
+
results['intermediate_flow'] = route_with_maxbas(results['intermediate_flow'], maxbas)
|
|
367
|
+
results['baseflow'] = route_with_maxbas(results['baseflow'], maxbas)
|
|
368
|
+
self.results = results
|
|
369
|
+
|
|
370
|
+
if verbose: print("Model run completed successfully!")
|
|
371
|
+
self.states= initial_states # states restored to the initial
|
|
372
|
+
|
|
373
|
+
# Calculate performance metrics if observed discharge is available
|
|
374
|
+
if obs_q is not None:
|
|
375
|
+
self.calculate_performance_metrics(verbose)
|
|
376
|
+
|
|
377
|
+
return results
|
|
378
|
+
|
|
379
|
+
def calculate_performance_metrics(self, verbose = True):
|
|
380
|
+
"""
|
|
381
|
+
Calculate performance metrics if observed discharge is available.
|
|
382
|
+
Uses the warmup_end parameter that was set during data loading to exclude
|
|
383
|
+
warmup period from performance evaluation.
|
|
384
|
+
"""
|
|
385
|
+
if 'observed_q' not in self.results:
|
|
386
|
+
print("No observed discharge data available for performance evaluation.")
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
# Get simulated and observed discharge
|
|
390
|
+
sim_q = self.results['discharge']
|
|
391
|
+
obs_q = self.results['observed_q']
|
|
392
|
+
|
|
393
|
+
# Define the evaluation period (exclude warmup period)
|
|
394
|
+
warmup_idx = 0
|
|
395
|
+
|
|
396
|
+
if hasattr(self, 'warmup_end') and self.warmup_end is not None:
|
|
397
|
+
# If warmup_end is stored in the model and dates are available
|
|
398
|
+
if 'dates' in self.results:
|
|
399
|
+
dates = self.results['dates']
|
|
400
|
+
# Find the index of the first date after warmup_end
|
|
401
|
+
warmup_idx = np.sum(dates <= self.warmup_end)
|
|
402
|
+
if verbose: print(f"Excluding data up to {self.warmup_end} ({warmup_idx} timesteps) as warmup period.")
|
|
403
|
+
else:
|
|
404
|
+
if verbose: print("Warning: No dates found in results. Unable to apply date-based warmup period.")
|
|
405
|
+
# Default to 10% if no dates available
|
|
406
|
+
warmup_idx = int(len(obs_q) * 0.1)
|
|
407
|
+
if verbose: print(f"Defaulting to exclude first {warmup_idx} timesteps (10% of data) as warmup period.")
|
|
408
|
+
else:
|
|
409
|
+
# Default: exclude first 10% of the data
|
|
410
|
+
warmup_idx = int(len(obs_q) * 0.1)
|
|
411
|
+
if verbose: print(f"No warmup_end specified. Excluding first {warmup_idx} timesteps (10% of data) as warmup period.")
|
|
412
|
+
|
|
413
|
+
# Apply the warmup period
|
|
414
|
+
if warmup_idx > 0:
|
|
415
|
+
sim_q = sim_q[warmup_idx:]
|
|
416
|
+
obs_q = obs_q[warmup_idx:]
|
|
417
|
+
|
|
418
|
+
# Remove NaN values
|
|
419
|
+
valid_idx = ~np.isnan(obs_q)
|
|
420
|
+
if np.sum(valid_idx) == 0:
|
|
421
|
+
print("No valid observed discharge values found after applying warmup period.")
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
sim_q_valid = sim_q[valid_idx]
|
|
425
|
+
obs_q_valid = obs_q[valid_idx]
|
|
426
|
+
|
|
427
|
+
# Calculate Nash-Sutcliffe Efficiency (NSE)
|
|
428
|
+
mean_obs = np.mean(obs_q_valid)
|
|
429
|
+
nse_numerator = np.sum((obs_q_valid - sim_q_valid) ** 2)
|
|
430
|
+
nse_denominator = np.sum((obs_q_valid - mean_obs) ** 2)
|
|
431
|
+
nse = 1 - (nse_numerator / nse_denominator)
|
|
432
|
+
|
|
433
|
+
# Calculate Kling-Gupta Efficiency (KGE)
|
|
434
|
+
mean_sim = np.mean(sim_q_valid)
|
|
435
|
+
std_obs = np.std(obs_q_valid)
|
|
436
|
+
std_sim = np.std(sim_q_valid)
|
|
437
|
+
|
|
438
|
+
r = np.corrcoef(obs_q_valid, sim_q_valid)[0, 1] # Correlation coefficient
|
|
439
|
+
alpha = (std_sim/mean_sim) / (std_obs/mean_obs) # Relative variability
|
|
440
|
+
beta = mean_sim / mean_obs # Bias
|
|
441
|
+
|
|
442
|
+
kge = 1 - np.sqrt((r - 1) ** 2 + (alpha - 1) ** 2 + (beta - 1) ** 2)
|
|
443
|
+
|
|
444
|
+
# Calculate percent bias
|
|
445
|
+
pbias = 100 * (np.sum(sim_q_valid - obs_q_valid) / np.sum(obs_q_valid))
|
|
446
|
+
|
|
447
|
+
# Calculate RMSE and MAE
|
|
448
|
+
rmse = np.sqrt(np.mean((sim_q_valid - obs_q_valid) ** 2))
|
|
449
|
+
mae = np.mean(np.abs(sim_q_valid - obs_q_valid))
|
|
450
|
+
|
|
451
|
+
# Store metrics
|
|
452
|
+
self.performance_metrics = {
|
|
453
|
+
'NSE': nse,
|
|
454
|
+
'KGE': kge,
|
|
455
|
+
'PBIAS': pbias,
|
|
456
|
+
'RMSE': rmse,
|
|
457
|
+
'MAE': mae,
|
|
458
|
+
'r': r,
|
|
459
|
+
# 'warmup_end': self.warmup_end if hasattr(self, 'warmup_end') else None,
|
|
460
|
+
# 'warmup_timesteps': warmup_idx
|
|
461
|
+
}
|
|
462
|
+
if verbose:
|
|
463
|
+
print(f"Performance metrics calculated:")
|
|
464
|
+
print(f"NSE: {nse:.3f}")
|
|
465
|
+
print(f"KGE: {kge:.3f}")
|
|
466
|
+
print(f"PBIAS: {pbias:.1f}%")
|
|
467
|
+
print(f"Correlation: {r:.3f}")
|
|
468
|
+
|
|
469
|
+
def plot_results(self, output_file=None, show_plots=True, exclude_warmup=True):
|
|
470
|
+
"""
|
|
471
|
+
Plot model results with customized layout and additional figures.
|
|
472
|
+
|
|
473
|
+
Parameters:
|
|
474
|
+
-----------
|
|
475
|
+
output_file : str, optional
|
|
476
|
+
If provided, save the plot to this file
|
|
477
|
+
show_plots : bool, default True
|
|
478
|
+
Whether to display the plots
|
|
479
|
+
exclude_warmup : bool, default True
|
|
480
|
+
Whether to exclude the warmup period from the plots
|
|
481
|
+
"""
|
|
482
|
+
# --- Create directory if it doesn't exist ---
|
|
483
|
+
if output_file is not None:
|
|
484
|
+
output_dir = os.path.dirname(output_file)
|
|
485
|
+
if output_dir: # only attempt if there's a directory part
|
|
486
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
487
|
+
if self.results is None:
|
|
488
|
+
raise ValueError("No results to plot. Run the model first.")
|
|
489
|
+
|
|
490
|
+
# Determine the warmup period index
|
|
491
|
+
warmup_idx = 0
|
|
492
|
+
|
|
493
|
+
if exclude_warmup:
|
|
494
|
+
if hasattr(self, 'warmup_end') and self.warmup_end is not None:
|
|
495
|
+
# If warmup_end is stored in the model and dates are available
|
|
496
|
+
if 'dates' in self.results:
|
|
497
|
+
dates = self.results['dates']
|
|
498
|
+
# Find the index of the first date after warmup_end
|
|
499
|
+
if isinstance(dates[0], (datetime.datetime, np.datetime64)):
|
|
500
|
+
warmup_idx = np.sum(dates <= self.warmup_end)
|
|
501
|
+
print(f"Excluding data up to {self.warmup_end} ({warmup_idx} timesteps) as warmup period.")
|
|
502
|
+
else:
|
|
503
|
+
# Default to 10% if dates are not datetime objects
|
|
504
|
+
warmup_idx = int(len(dates) * 0.1)
|
|
505
|
+
print(f"Dates are not datetime objects. Defaulting to exclude first {warmup_idx} timesteps (10% of data) as warmup period.")
|
|
506
|
+
else:
|
|
507
|
+
# Default to 10% if no dates available
|
|
508
|
+
warmup_idx = int(len(self.results['precipitation']) * 0.1)
|
|
509
|
+
print(f"No dates found in results. Defaulting to exclude first {warmup_idx} timesteps (10% of data) as warmup period.")
|
|
510
|
+
else:
|
|
511
|
+
# Default: exclude first 10% of the data
|
|
512
|
+
warmup_idx = int(len(self.results['precipitation']) * 0.1)
|
|
513
|
+
print(f"No warmup_end specified. Excluding first {warmup_idx} timesteps (10% of data) as warmup period.")
|
|
514
|
+
else:
|
|
515
|
+
print("Including entire simulation period (including warmup period)")
|
|
516
|
+
|
|
517
|
+
# Create a figure with multiple subplots
|
|
518
|
+
fig = plt.figure(figsize=(12, 28)) # Increased height for more subplots
|
|
519
|
+
|
|
520
|
+
# Define subplots layout (now 9 subplots)
|
|
521
|
+
axs = []
|
|
522
|
+
axs.append(fig.add_subplot(11, 1, 1)) # Precipitation
|
|
523
|
+
axs.append(fig.add_subplot(11, 1, 2)) # Temperature
|
|
524
|
+
axs.append(fig.add_subplot(11, 1, 3)) # Snow pack and liquid water
|
|
525
|
+
axs.append(fig.add_subplot(11, 1, 4)) # Runoff from snow
|
|
526
|
+
axs.append(fig.add_subplot(11, 1, 5)) # Potential and actual ET
|
|
527
|
+
axs.append(fig.add_subplot(11, 1, 6)) # Soil moisture
|
|
528
|
+
axs.append(fig.add_subplot(11, 1, 7)) # Recharge (output from soil to response)
|
|
529
|
+
axs.append(fig.add_subplot(11, 1, 8)) # Groundwater storages
|
|
530
|
+
axs.append(fig.add_subplot(11, 1, 9)) # Discharge components and total
|
|
531
|
+
axs.append(fig.add_subplot(11, 1, 10)) # Discharge components and total
|
|
532
|
+
axs.append(fig.add_subplot(11, 1, 11)) # Discharge components and total
|
|
533
|
+
|
|
534
|
+
# Get dates for x-axis
|
|
535
|
+
dates = self.results['dates'][warmup_idx:] if warmup_idx > 0 else self.results['dates']
|
|
536
|
+
if isinstance(dates[0], (datetime.datetime, np.datetime64)):
|
|
537
|
+
date_formatter = DateFormatter('%Y-%m-%d')
|
|
538
|
+
is_datetime = True
|
|
539
|
+
else:
|
|
540
|
+
is_datetime = False
|
|
541
|
+
|
|
542
|
+
# Function to get data with warmup period excluded
|
|
543
|
+
def get_data(key):
|
|
544
|
+
return self.results[key][warmup_idx:] if warmup_idx > 0 else self.results[key]
|
|
545
|
+
|
|
546
|
+
# 1. Precipitation
|
|
547
|
+
ax1 = axs[0]
|
|
548
|
+
ax1.bar(dates, get_data('precipitation'), color='skyblue', label='Precipitation')
|
|
549
|
+
ax1.set_ylabel('Precipitation (mm)')
|
|
550
|
+
ax1.set_title('Precipitation')
|
|
551
|
+
ax1.legend(loc='upper right')
|
|
552
|
+
|
|
553
|
+
# 2. Temperature with TT threshold
|
|
554
|
+
ax2 = axs[1]
|
|
555
|
+
ax2.plot(dates, get_data('temperature'), color='red', label='Temperature')
|
|
556
|
+
ax2.axhline(y=self.params['snow']['TT']['default'], color='gray', linestyle='--',
|
|
557
|
+
label=f"TT Threshold ({self.params['snow']['TT']['default']}°C)")
|
|
558
|
+
ax2.set_ylabel('Temperature (°C)')
|
|
559
|
+
ax2.set_title('Temperature')
|
|
560
|
+
ax2.legend(loc='upper right')
|
|
561
|
+
|
|
562
|
+
# 3. Snow pack and liquid water
|
|
563
|
+
ax3 = axs[2]
|
|
564
|
+
ax3.plot(dates, get_data('snowpack'), color='blue', label='Snow Pack')
|
|
565
|
+
ax3.plot(dates, get_data('liquid_water'), color='lightblue', label='Liquid Water')
|
|
566
|
+
ax3.set_ylabel('Water (mm)')
|
|
567
|
+
ax3.set_title('Snow Pack and Liquid Water Content')
|
|
568
|
+
ax3.legend(loc='upper right')
|
|
569
|
+
|
|
570
|
+
# 4. Runoff from snow
|
|
571
|
+
ax4 = axs[3]
|
|
572
|
+
ax4.plot(dates, get_data('runoff_from_snow'), color='skyblue', label='Runoff from Snow')
|
|
573
|
+
ax4.set_ylabel('Runoff (mm/day)')
|
|
574
|
+
ax4.set_title('Runoff from the Snow Routine')
|
|
575
|
+
ax4.legend(loc='upper right')
|
|
576
|
+
|
|
577
|
+
# 5. Potential and actual ET
|
|
578
|
+
ax5 = axs[4]
|
|
579
|
+
ax5.plot(dates, get_data('potential_et'), color='orange', label='Potential ET')
|
|
580
|
+
ax5.plot(dates, get_data('actual_et'), color='green', label='Actual ET')
|
|
581
|
+
ax5.set_ylabel('ET (mm/day)')
|
|
582
|
+
ax5.set_title('Potential and Actual Evapotranspiration')
|
|
583
|
+
ax5.legend(loc='upper right')
|
|
584
|
+
|
|
585
|
+
# 6. Soil moisture
|
|
586
|
+
ax6 = axs[5]
|
|
587
|
+
ax6.plot(dates, get_data('soil_moisture'), color='brown', label='Soil Moisture')
|
|
588
|
+
ax6.axhline(y=self.params['soil']['FC']['default'], color='gray', linestyle='--',
|
|
589
|
+
label=f"Field Capacity ({self.params['soil']['FC']['default']:.2f} mm)")
|
|
590
|
+
ax6.set_ylabel('Soil Moisture (mm)')
|
|
591
|
+
ax6.set_title('Soil Moisture')
|
|
592
|
+
ax6.legend(loc='upper right')
|
|
593
|
+
|
|
594
|
+
# 7. Recharge (output from soil to response routine)
|
|
595
|
+
ax7 = axs[6]
|
|
596
|
+
ax7.plot(dates, get_data('recharge'), color='purple',linewidth=0.5, label='Recharge')
|
|
597
|
+
ax7.plot(dates, get_data('runoff_soil'), color='red',linewidth=0.5, label='Runoff (overflow from soil)')
|
|
598
|
+
ax7.set_ylabel('Water (mm/day)')
|
|
599
|
+
ax7.set_title('Soil Output to the Response Routine')
|
|
600
|
+
ax7.legend(loc='upper right')
|
|
601
|
+
|
|
602
|
+
# 8. upper storages
|
|
603
|
+
ax8 = axs[7]
|
|
604
|
+
ax8.plot(dates, get_data('upper_storage'), color='lightcoral', label='Upper Storage')
|
|
605
|
+
ax8.axhline(y=self.params['response']['UZL']['default'], color='gray', linestyle='--', label='Fast Response Threshold (ULZ)')
|
|
606
|
+
ax8.set_ylabel('Storage (mm)')
|
|
607
|
+
ax8.set_title('Upper Tank Storages')
|
|
608
|
+
ax8.legend(loc='upper right')
|
|
609
|
+
|
|
610
|
+
# 9. Groundwater storages
|
|
611
|
+
ax9 = axs[8]
|
|
612
|
+
ax9.plot(dates, get_data('lower_storage'), color='darkblue', label='Lower Storage')
|
|
613
|
+
ax9.set_ylabel('Storage (mm)')
|
|
614
|
+
ax9.set_title('Lower Tank Storages')
|
|
615
|
+
ax9.legend(loc='upper right')
|
|
616
|
+
|
|
617
|
+
# 10. Discharge Components (Stacked)
|
|
618
|
+
ax10 = axs[9]
|
|
619
|
+
|
|
620
|
+
# Stackplot with components only
|
|
621
|
+
ax10.stackplot(dates,
|
|
622
|
+
get_data('baseflow'),
|
|
623
|
+
get_data('intermediate_flow'),
|
|
624
|
+
get_data('quick_flow'),
|
|
625
|
+
labels=['Baseflow', 'Intermediate Flow', 'Quick Flow'],
|
|
626
|
+
colors=['royalblue', 'darkorange', 'tomato'])
|
|
627
|
+
|
|
628
|
+
# Add total discharge line
|
|
629
|
+
ax10.plot(dates, get_data('discharge'),
|
|
630
|
+
color='black', linestyle=':', linewidth=0.5,
|
|
631
|
+
label='Total Discharge (sum)')
|
|
632
|
+
|
|
633
|
+
ax10.set_ylabel('Flow (mm/day)')
|
|
634
|
+
ax10.set_title('Runoff Components (Stacked)')
|
|
635
|
+
ax10.legend(loc='upper right')
|
|
636
|
+
|
|
637
|
+
# 11. Discharge Comparison (Total vs Observed)
|
|
638
|
+
ax11 = axs[10]
|
|
639
|
+
|
|
640
|
+
# Plot simulated total
|
|
641
|
+
ax11.plot(dates, get_data('discharge'),
|
|
642
|
+
color='darkgreen', linewidth=2,
|
|
643
|
+
label='Simulated Discharge')
|
|
644
|
+
|
|
645
|
+
# Plot observed if available
|
|
646
|
+
if 'observed_q' in self.results:
|
|
647
|
+
ax11.plot(dates, get_data('observed_q'),
|
|
648
|
+
color='black', linestyle='--', linewidth=1.5,
|
|
649
|
+
label='Observed Discharge')
|
|
650
|
+
|
|
651
|
+
# Add performance metrics to title
|
|
652
|
+
if hasattr(self, 'performance_metrics'):
|
|
653
|
+
metrics = self.performance_metrics
|
|
654
|
+
ax11.set_title(
|
|
655
|
+
f"Discharge Comparison (NSE: {metrics['NSE']:.2f}, KGE: {metrics['KGE']:.2f}, PBIAS: {metrics['PBIAS']:.2f}) "
|
|
656
|
+
|
|
657
|
+
)
|
|
658
|
+
else:
|
|
659
|
+
ax11.set_title('Discharge Comparison')
|
|
660
|
+
|
|
661
|
+
ax11.set_ylabel('Discharge (mm/day)')
|
|
662
|
+
ax11.set_xlabel('Date')
|
|
663
|
+
|
|
664
|
+
ax11.legend(loc='upper right')
|
|
665
|
+
|
|
666
|
+
# Format x-axis dates if available
|
|
667
|
+
if is_datetime:
|
|
668
|
+
for ax in axs:
|
|
669
|
+
ax.xaxis.set_major_formatter(date_formatter)
|
|
670
|
+
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45)
|
|
671
|
+
|
|
672
|
+
plt.tight_layout()
|
|
673
|
+
|
|
674
|
+
if output_file:
|
|
675
|
+
plt.savefig(output_file, dpi=300, bbox_inches='tight')
|
|
676
|
+
print(f"Figure saved to {output_file}")
|
|
677
|
+
|
|
678
|
+
if show_plots:
|
|
679
|
+
plt.show()
|
|
680
|
+
return None
|
|
681
|
+
|
|
682
|
+
def save_results(self, output_file):
|
|
683
|
+
"""
|
|
684
|
+
Save model results to a CSV file.
|
|
685
|
+
|
|
686
|
+
Parameters:
|
|
687
|
+
-----------
|
|
688
|
+
output_file : str
|
|
689
|
+
Path to output file
|
|
690
|
+
"""
|
|
691
|
+
# --- Create directory if it doesn't exist ---
|
|
692
|
+
if output_file is not None:
|
|
693
|
+
output_dir = os.path.dirname(output_file)
|
|
694
|
+
if output_dir: # only attempt if there's a directory part
|
|
695
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
696
|
+
if self.results is None:
|
|
697
|
+
raise ValueError("No results to save. Run the model first.")
|
|
698
|
+
|
|
699
|
+
# Create a DataFrame from results
|
|
700
|
+
results_df = pd.DataFrame()
|
|
701
|
+
|
|
702
|
+
# Add date column if dates are available
|
|
703
|
+
if isinstance(self.results['dates'][0], (datetime.datetime, np.datetime64)):
|
|
704
|
+
results_df['Date'] = self.results['dates']
|
|
705
|
+
else:
|
|
706
|
+
results_df['TimeStep'] = self.results['dates']
|
|
707
|
+
|
|
708
|
+
# Add input data
|
|
709
|
+
results_df['Precipitation'] = self.results['precipitation']
|
|
710
|
+
results_df['Temperature'] = self.results['temperature']
|
|
711
|
+
results_df['PotentialET'] = self.results['potential_et']
|
|
712
|
+
|
|
713
|
+
# Add observed discharge if available
|
|
714
|
+
if 'observed_q' in self.results:
|
|
715
|
+
results_df['ObservedQ'] = self.results['observed_q']
|
|
716
|
+
|
|
717
|
+
# Add model results
|
|
718
|
+
results_df['SnowPack'] = self.results['snowpack']
|
|
719
|
+
results_df['LiquidWater'] = self.results['liquid_water']
|
|
720
|
+
results_df['RunoffFromSnow'] = self.results['runoff_from_snow']
|
|
721
|
+
results_df['SoilMoisture'] = self.results['soil_moisture']
|
|
722
|
+
results_df['Recharge'] = self.results['recharge']
|
|
723
|
+
results_df['RunoffSoil'] = self.results['runoff_soil']
|
|
724
|
+
results_df['ActualET'] = self.results['actual_et']
|
|
725
|
+
results_df['UpperStorage'] = self.results['upper_storage']
|
|
726
|
+
results_df['LowerStorage'] = self.results['lower_storage']
|
|
727
|
+
results_df['QuickFlow'] = self.results['quick_flow']
|
|
728
|
+
results_df['IntermediateFlow'] = self.results['intermediate_flow']
|
|
729
|
+
results_df['Baseflow'] = self.results['baseflow']
|
|
730
|
+
results_df['Discharge'] = self.results['discharge']
|
|
731
|
+
|
|
732
|
+
# Save to file
|
|
733
|
+
results_df.to_csv(output_file, index=False)
|
|
734
|
+
print(f"Results saved to {output_file}")
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def save_model(self, output_path):
|
|
738
|
+
"""
|
|
739
|
+
Save the entire model instance to a file using pickle.
|
|
740
|
+
|
|
741
|
+
This saves all model components including:
|
|
742
|
+
- Parameters
|
|
743
|
+
- Current states
|
|
744
|
+
- Input data
|
|
745
|
+
- Results (if model has been run)
|
|
746
|
+
- Performance metrics (if calculated)
|
|
747
|
+
|
|
748
|
+
Parameters:
|
|
749
|
+
-----------
|
|
750
|
+
output_path : str
|
|
751
|
+
Path to save the model file. Will create directories if they don't exist.
|
|
752
|
+
"""
|
|
753
|
+
import pickle
|
|
754
|
+
import os
|
|
755
|
+
|
|
756
|
+
# Create directory if it doesn't exist
|
|
757
|
+
output_dir = os.path.dirname(output_path)
|
|
758
|
+
if output_dir:
|
|
759
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
760
|
+
|
|
761
|
+
# Save the model using pickle
|
|
762
|
+
with open(output_path, 'wb') as f:
|
|
763
|
+
pickle.dump(self, f)
|
|
764
|
+
|
|
765
|
+
print(f"Model saved to {output_path}")
|
|
766
|
+
|
|
767
|
+
return None
|
|
768
|
+
|
|
769
|
+
def load_model(model_path):
|
|
770
|
+
|
|
771
|
+
"""
|
|
772
|
+
Load a saved HBV model from a file.
|
|
773
|
+
|
|
774
|
+
Parameters:
|
|
775
|
+
-----------
|
|
776
|
+
model_path : str
|
|
777
|
+
Path to the saved model file.
|
|
778
|
+
|
|
779
|
+
Returns:
|
|
780
|
+
--------
|
|
781
|
+
HBVModel
|
|
782
|
+
The loaded model instance.
|
|
783
|
+
"""
|
|
784
|
+
import pickle
|
|
785
|
+
|
|
786
|
+
with open(model_path, 'rb') as f:
|
|
787
|
+
model = pickle.load(f)
|
|
788
|
+
|
|
789
|
+
print(f"Model loaded from {model_path}")
|
|
790
|
+
|
|
791
|
+
return model
|
|
792
|
+
|