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