tank-model 3.0.0b3__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.
tank_core/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ # -*- coding: utf-8 -*-
2
+ '''
3
+ Tank Hydrologic Model
4
+ =====================
5
+ Python implementation of Tank Hydrologic Model.
6
+ Tank-Model is a conceptual rainfall-runoff model
7
+ proposed by Sugawara and Funiyuki (1956).
8
+ '''
9
+ __version__ = '3.0.0b3'
10
+ __author__ = 'Nazmul Ahasan <nzahasan@gmail.com>'
tank_core/arima.py ADDED
@@ -0,0 +1,99 @@
1
+ # -*- coding: utf-8 -*-
2
+ '''
3
+ Time series error Correction Module for model
4
+ ---------------------------------------------
5
+ ARIMA(p,d,q)
6
+ p - auto-regressive part
7
+ d - integrated part / differentiation part
8
+ q - moving average part
9
+
10
+ '''
11
+ import pickle
12
+ import warnings
13
+ import numpy as np
14
+ from statsmodels.tsa.arima_model import ARIMA
15
+ from statsmodels.tsa.stattools import adfuller
16
+ warnings.filterwarnings("ignore")
17
+
18
+
19
+ class autoARIMA(object):
20
+
21
+ '''
22
+ A wrapper of statsmodels, ARIMA for easier model fitting and generating forecast
23
+ this fits ARIMA model using brute force with lowest BIC(Bayesian Information Criteria) value.
24
+ - Not the best way but its the easiest
25
+
26
+ Possible alternative to look at - pyramid-arima
27
+ '''
28
+
29
+ def __init__(self,endog,max_p=5,max_d=5,max_q=5,helpText=True):
30
+
31
+ self.endog = endog
32
+ self.max_p = max_p
33
+ self.max_d = max_d
34
+ self.max_q = max_q
35
+ self.helpText = helpText
36
+ self.fitted_model = None
37
+
38
+
39
+ def getOrder(self):
40
+
41
+ fittedOrder = {'order':[],'bic':[]}
42
+
43
+ # iterate through (p,d,q) values
44
+ for p in range(self.max_p):
45
+ for d in range(self.max_d):
46
+ for q in range(self.max_q):
47
+ try:
48
+ model = ARIMA(self.endog, order=(p,d,q)).fit(disp=0)
49
+ fittedOrder['bic'].append( model.bic )
50
+ fittedOrder['order'].append( (p,d,q) )
51
+ except:
52
+ continue
53
+
54
+ # find order with lowest bic value
55
+ bestOrder = fittedOrder['order'][ fittedOrder['bic'].index( min(fittedOrder['bic']) ) ]
56
+
57
+ if self.helpText == True:
58
+ print('Lowest BIC value with order ',bestOrder)
59
+
60
+ return bestOrder
61
+
62
+ def fit(self):
63
+
64
+ # return a fitted ARIMA model with lowest bic value
65
+
66
+ self.fitted_model = ARIMA(self.endog,order=self.getOrder()).fit(disp=0)
67
+
68
+ return self
69
+
70
+
71
+ def forecast(self,num_step):
72
+
73
+ # returns forecasted values and confidence limit of the forecast
74
+
75
+ if self.fitted_model == None:
76
+ print('ERROR: Fit the model first')
77
+ return None
78
+
79
+ forecast,_,confLimit = self.fitted_model.forecast(steps=num_step)
80
+ return (forecast,confLimit)
81
+
82
+ def inSamplePlot(self):
83
+
84
+ self.fitted_model.plot_predict()
85
+ return self
86
+
87
+ def saveModel(self,fileName):
88
+ with open(fileName,'wb') as outModelFile:
89
+ pickle.dump(self,outModelFile,pickle.HIGHEST_PROTOCOL)
90
+
91
+ def loadModel(self,fileName):
92
+ with open(fileName,'rb') as inModelFile:
93
+ self = pickle.load(inModelFile)
94
+
95
+
96
+
97
+
98
+ # N.B. predict() returns differentiated value by default
99
+ # use typ='levels' for prediction in endog level/scale
@@ -0,0 +1,59 @@
1
+ # -*- coding: utf-8 -*-
2
+ import numpy as np
3
+ from scipy.stats import gamma
4
+
5
+ class gqm():
6
+
7
+ '''
8
+ Gamma Quantile Mapping (Piani et.el. 2009)
9
+ ------------------------------------------
10
+ Parametric bias correction using gamma distribution
11
+
12
+ cdf(obs) + C_obs = cdf(sim) + C_sim
13
+ cdf(obs) = cdf(sim) + C_sim - C_obs
14
+ cdf(obs) = cdf(sim) + delC
15
+
16
+ >> obs = cdf^-1 [ cdf(sim) + delC ]
17
+
18
+ here, C_sim/C_obs = fraction of dry days / days with 0 rainfall
19
+
20
+ :: in gamma fit force location parameter to 0
21
+ '''
22
+ def __init__(self):
23
+
24
+ self.obs_param = dict(a = None, b = None, c = None)
25
+ self.sim_param = dict(a = None, b = None, c = None)
26
+
27
+ def fit(self,obs_data,sim_data):
28
+
29
+ #: estimates parameters from provided data
30
+ #: dry day fraction
31
+ self.obs_param['c'] = (obs_data[obs_data==0].shape[0]) / (obs_data.shape[0])
32
+ self.sim_param['c'] = (sim_data[sim_data==0].shape[0]) / (sim_data.shape[0])
33
+
34
+ #: fit gamma with non zero values with floc=0
35
+ self.obs_param['a'], _, self.obs_param['b'] = gamma.fit(obs_data[obs_data>0], floc=0)
36
+ self.sim_param['a'], _, self.sim_param['b'] = gamma.fit(sim_data[sim_data>0], floc=0)
37
+
38
+
39
+ return self
40
+
41
+ def set_params(self,obs_param_arr:np.ndarray, sim_param_arr:np.ndarray)->object:
42
+
43
+ self.obs_param['a'] = obs_param_arr[0]
44
+ self.obs_param['b'] = obs_param_arr[1]
45
+ self.obs_param['c'] = obs_param_arr[2]
46
+
47
+ self.sim_param['a'] = sim_param_arr[0]
48
+ self.sim_param['b'] = sim_param_arr[1]
49
+ self.sim_param['c'] = sim_param_arr[2]
50
+
51
+ return self
52
+
53
+ def correct(self,simVal:float)->float:
54
+
55
+ delC = self.sim_param['c']-self.obs_param['c']
56
+ simValCDF = gamma.cdf(simVal,a=self.sim_param['a'],scale=self.sim_param['b'],loc=0)
57
+
58
+ #: return the bias corrected value
59
+ return gamma.ppf( (simValCDF + delC),a=self.obs_param['a'], scale=self.obs_param['b'], loc=0)
@@ -0,0 +1,44 @@
1
+ # -*- coding: utf-8 -*-
2
+ '''
3
+ Channel routing methods
4
+ '''
5
+
6
+ import numpy as np
7
+
8
+
9
+ def muskingum(in_flow:np.ndarray, del_t:float, k:float, x:float) -> np.ndarray:
10
+
11
+ '''
12
+ Returns routed flow using Muskingum routing method
13
+ (Lumped channel routing)
14
+
15
+ Units:
16
+ ------
17
+ in_flow - m^3/s
18
+ del_t - hr
19
+ x - hr
20
+ '''
21
+
22
+ # calculate time-step
23
+ n_step:int = in_flow.shape[0]
24
+
25
+ # create a zero array of out_flow
26
+ out_flow:np.ndarray = np.zeros(n_step, dtype=np.float64)
27
+
28
+ C0:float = (-k*x+0.5*del_t) / (k*(1-x)+0.5*del_t)
29
+ C1:float = (k*x+0.5 *del_t) / (k*(1-x)+0.5*del_t)
30
+ C2:float = (k*(1-x) - 0.5*del_t) / (k*(1-x)+0.5*del_t)
31
+
32
+ # constraints check
33
+ if (C0+C1+C2) > 1 or x >0.5 or (del_t/k + x) > 1:
34
+ print("WARNING-MUSKINGUM-01: violates k, x constraints")
35
+
36
+ # initial condition
37
+ out_flow[0] = in_flow[0]
38
+
39
+ for t in np.arange(1,n_step):
40
+
41
+ out_flow[t] = C0 * in_flow[t] + C1 * in_flow[t-1] + C2 * out_flow[t-1]
42
+
43
+ return out_flow
44
+
@@ -0,0 +1,318 @@
1
+ # -*- coding: utf-8 -*-
2
+ '''
3
+ Helper functions for computation
4
+ '''
5
+ import numpy as np
6
+ import pandas as pd
7
+ from queue import Queue
8
+ from scipy.optimize import minimize
9
+ from . import utils
10
+ from .tank_basin import tank_discharge
11
+ from .channel_routing import muskingum
12
+ from .cost_functions import (PBIAS, R2, RMSE, NSE,)
13
+ from . import global_config as gc
14
+
15
+
16
+
17
+ def build_computation_stack(project:dict) -> list:
18
+
19
+ '''
20
+ Traverse project tree and build computation stack
21
+ First traverse the tree with queue(fifo) ass all the
22
+ upstream node needs to be computed before computing
23
+ a specific node, while traversing node put them in stack
24
+
25
+ N.B. Upstream is child node.
26
+ '''
27
+ computation_stack = []
28
+
29
+ node_queue:Queue[str] = Queue()
30
+
31
+ # en-queue root note
32
+ for root_node in project['root_node']:
33
+ node_queue.put(root_node)
34
+
35
+ while not node_queue.empty():
36
+
37
+ # deque node
38
+ node = node_queue.get()
39
+
40
+ # add node to the top of computation stack
41
+ computation_stack.append(node)
42
+
43
+ # get child nodes and en-queue child nodes
44
+ if project['basin_def'][node].get('upstream',False):
45
+ child_nodes = project['basin_def'][node]['upstream']
46
+
47
+ for child in child_nodes:
48
+ node_queue.put(child)
49
+
50
+ return computation_stack
51
+
52
+
53
+ def compute_project(
54
+ basin:dict,
55
+ precipitation:pd.DataFrame,
56
+ evapotranspiration:pd.DataFrame,
57
+ del_t:float
58
+ )->tuple:
59
+
60
+ '''
61
+ Computes project for provided precipitation and evapotranspiration data
62
+
63
+ Note:
64
+ - assumes all kind of time-step check has been completed
65
+ - and contains no null / missing data
66
+ '''
67
+ computation_stack = build_computation_stack(basin)
68
+
69
+ n_step = len(precipitation.index)
70
+
71
+ computation_result = pd.DataFrame(index=precipitation.index)
72
+
73
+ model_states = dict(
74
+ time = precipitation.index.to_numpy()
75
+ )
76
+
77
+ while len(computation_stack) > 0:
78
+
79
+ # pop node from top of the node
80
+ curr_node_name = computation_stack.pop()
81
+
82
+ curr_node_def = basin['basin_def'][curr_node_name]
83
+
84
+ # if node is subbasin return tank discharge
85
+ if curr_node_def['type'] == 'Subbasin':
86
+
87
+ computation_result[curr_node_name], basin_states = tank_discharge(
88
+ precipitation = precipitation[curr_node_name].to_numpy(),
89
+ evapotranspiration = evapotranspiration[curr_node_name].to_numpy(),
90
+ del_t = del_t,
91
+ area = curr_node_def['area'],
92
+ ** curr_node_def['parameters']
93
+ )
94
+ # store basin states in model states for dumping later
95
+ model_states[curr_node_name] = basin_states
96
+
97
+ # if node is reach return sum of routed flow for each upstream node
98
+ elif curr_node_def['type'] == 'Reach':
99
+
100
+ sum_node = np.zeros(n_step, dtype=np.float64)
101
+
102
+ for us_node_name in curr_node_def['upstream']:
103
+ sum_node += muskingum(
104
+ in_flow= computation_result[us_node_name].to_numpy(),
105
+ del_t=del_t,
106
+ ** curr_node_def['parameters']
107
+ )
108
+
109
+ computation_result[curr_node_name] = sum_node
110
+
111
+ # if node is sink/junction return sum each upstream nodes
112
+ elif curr_node_def['type'] in ['Sink','Junction']:
113
+
114
+ sum_node = np.zeros(n_step, dtype=np.float64)
115
+
116
+ for us_node_name in curr_node_def['upstream']:
117
+ sum_node += computation_result[us_node_name].to_numpy()
118
+
119
+ computation_result[curr_node_name] = sum_node
120
+
121
+ return computation_result, model_states
122
+
123
+
124
+ def compute_statistics(basin:dict, result:pd.DataFrame, discharge:pd.DataFrame)->dict:
125
+
126
+ merged = merge_obs_sim(observed=discharge, simulated=result)
127
+
128
+ merged_keys = merged.keys()
129
+
130
+ statistics = dict()
131
+
132
+ for node in basin['basin_def'].keys():
133
+
134
+ obs_key, sim_key = f'{node}_obs', f'{node}_sim'
135
+
136
+ if obs_key in merged_keys and sim_key in merged_keys:
137
+ statistics[node]={
138
+ "RMSE": RMSE(merged[obs_key].to_numpy(), merged[sim_key].to_numpy()),
139
+ "NSE" : NSE(sim=merged[sim_key].to_numpy(), obs=merged[obs_key].to_numpy() ),
140
+ "R2" : R2(merged[sim_key].to_numpy(), merged[obs_key].to_numpy() ),
141
+ "PBIAS" : PBIAS(sim=merged[sim_key].to_numpy(), obs=merged[obs_key].to_numpy() )
142
+ }
143
+
144
+ return statistics
145
+
146
+
147
+
148
+ # creates a single list of parameter stacking each nodes parameter
149
+ def parameter_stack(basin:dict) -> tuple:
150
+ '''
151
+ Stacks all parameters into a list of a given basin
152
+ '''
153
+ node_order_type = []
154
+ stacked_parameter = []
155
+ basin_def = basin['basin_def']
156
+
157
+ for node in basin_def.keys():
158
+
159
+ node_type = basin_def[node]['type']
160
+
161
+ if node_type in ['Subbasin', 'Reach']:
162
+
163
+ if node_type == 'Subbasin':
164
+ stacked_parameter.extend(
165
+ utils.tank_param_dict2list(basin_def[node]['parameters'])
166
+ )
167
+
168
+ elif node_type == 'Reach':
169
+ stacked_parameter.extend(
170
+ utils.muskingum_param_dict2list(basin_def[node]['parameters'])
171
+ )
172
+ # append to node order
173
+ node_order_type.append((node, node_type ))
174
+
175
+
176
+ # node order, stacked_parameter
177
+ return (node_order_type, stacked_parameter)
178
+
179
+ # Unstacks parameters stacked by parameter_stack function
180
+ def parameter_unstack(node_order_type:list, stacked_parameter:list) -> dict:
181
+ '''
182
+ returns unstacked parameters of a basin for provided unstacked parameters
183
+ '''
184
+ unstacked_parameter = dict()
185
+
186
+ # later have to change this if other routing method is added
187
+ conv_fn = {
188
+ 'Subbasin': utils.tank_param_list2dict,
189
+ 'Reach': utils.muskingum_param_list2dict
190
+ }
191
+ offset = 0
192
+ for node, node_type in node_order_type:
193
+
194
+ num_parameter = gc.NUM_PARAMETER[node_type]
195
+ unstacked_parameter[node] = conv_fn[node_type](stacked_parameter[offset:offset+num_parameter])
196
+ offset += num_parameter
197
+
198
+
199
+ return unstacked_parameter
200
+
201
+ def update_basin_with_unstacked_parameter(basin:dict, unstacked_parameter:dict)->dict:
202
+ '''
203
+ returns updated basin for provided unstacked parameters
204
+ '''
205
+ for node in unstacked_parameter.keys():
206
+
207
+ basin['basin_def'][node]['parameters'] = unstacked_parameter[node]
208
+
209
+
210
+ return basin
211
+
212
+ def update_basin_with_stacked_parameter(basin:dict, node_order_type:list, stacked_parameter:list)->dict:
213
+ '''
214
+ returns updated basin for provided stacked parameters
215
+ '''
216
+ # check if stacked parameter length is okay
217
+ conv_fn = {
218
+ 'Subbasin': utils.tank_param_list2dict,
219
+ 'Reach': utils.muskingum_param_list2dict
220
+ }
221
+ offset = 0
222
+ for node, node_type in node_order_type:
223
+
224
+ num_parameter = gc.NUM_PARAMETER[node_type]
225
+ basin['basin_def'][node]['parameters'] = conv_fn[node_type](stacked_parameter[offset:offset+num_parameter])
226
+ offset += num_parameter
227
+
228
+
229
+ return basin
230
+
231
+ def merge_obs_sim(observed:pd.DataFrame, simulated:pd.DataFrame) -> pd.DataFrame:
232
+ '''
233
+ Inner joins observed and simulated output with their index (time)
234
+ ref: https://pandas.pydata.org/docs/user_guide/merging.html#database-style-dataframe-or-named-series-joining-merging
235
+ '''
236
+ return pd.merge(
237
+ simulated, observed,
238
+ how='inner',
239
+ left_index=True,
240
+ right_index=True,
241
+ suffixes=('_sim', '_obs')
242
+ )
243
+
244
+
245
+ def stat_by_stacked_parameter(
246
+ stacked_parameter:list,
247
+ node_order_type:list,
248
+ basin:dict,
249
+ rainfall:pd.DataFrame,
250
+ evapotranspiration:pd.DataFrame,
251
+ discharge:pd.DataFrame,
252
+ del_t:float
253
+ )->float:
254
+ '''
255
+ Returns model performance statistics for stacked parameters
256
+ (right now set to nse only)
257
+ '''
258
+ updated_basin = update_basin_with_stacked_parameter(basin, node_order_type, stacked_parameter)
259
+
260
+ result, _ = compute_project(updated_basin, rainfall, evapotranspiration, del_t)
261
+
262
+ merged = merge_obs_sim(observed=discharge, simulated=result)
263
+
264
+ #@need-fixing : fix for multiple root node
265
+ root_node = updated_basin['root_node'][0]
266
+
267
+ sim_key, obs_key = f'{root_node}_sim', f'{root_node}_obs'
268
+
269
+ _nse = NSE(sim=merged[sim_key].to_numpy(), obs=merged[obs_key].to_numpy())
270
+
271
+
272
+ return 1 - _nse
273
+
274
+
275
+
276
+ def optimize_project(
277
+ basin:dict,
278
+ precipitation:pd.DataFrame,
279
+ evapotranspiration:pd.DataFrame,
280
+ discharge:pd.DataFrame,
281
+ del_t:float
282
+ )->dict:
283
+ '''
284
+ Optimizes parameters of a basin and returns updated basin file
285
+ '''
286
+
287
+ node_order_type, stacked_parameter = parameter_stack(basin)
288
+
289
+ upper_bound_stacked = list()
290
+ lower_bound_stacked = list()
291
+
292
+ for _, node_type in node_order_type:
293
+
294
+ if node_type == 'Subbasin':
295
+
296
+ upper_bound_stacked.extend(gc.tank_ub.tolist())
297
+ lower_bound_stacked.extend(gc.tank_lb.tolist())
298
+
299
+ if node_type == 'Reach':
300
+ upper_bound_stacked.extend(gc.muskingum_ub.tolist())
301
+ lower_bound_stacked.extend(gc.muskingum_lb.tolist())
302
+
303
+ initial_guess = np.array(stacked_parameter)
304
+
305
+ param_bounds = np.column_stack((lower_bound_stacked,upper_bound_stacked))
306
+
307
+ optim_func_static_args = (node_order_type, basin,precipitation,evapotranspiration, discharge, del_t)
308
+
309
+ optimizer = minimize(
310
+ fun = stat_by_stacked_parameter,
311
+ x0 = initial_guess,
312
+ args = optim_func_static_args,
313
+ method = 'L-BFGS-B',
314
+ bounds = param_bounds
315
+ )
316
+
317
+ return update_basin_with_stacked_parameter(basin, node_order_type, optimizer.x)
318
+
@@ -0,0 +1,116 @@
1
+ # -*- coding: utf-8 -*-
2
+ '''
3
+ Necessary cost function for model calibration and validation
4
+ R2:
5
+ Pearson’s correlation coefficient(R) squared
6
+ Range [0 to 1]
7
+ Higher is better maximum possible value =1
8
+
9
+ NSE:
10
+ Nash-Sutcliffe Efficiency coefficient
11
+ Range [-inf to 1]
12
+ NSE value 1 means a perfect model
13
+ NSE > 0.6 is considered as a good model.
14
+
15
+ MSE:
16
+ Mean Squared Error
17
+ Lower is better
18
+
19
+ RMSE:
20
+ Root Mean Squared Error
21
+ squared root of MSE
22
+ Lower is better
23
+
24
+ PBIAS:
25
+ Percent bias
26
+ Lower is better
27
+ +ve values indicate underestimation
28
+ -ve values indicate model overestimation
29
+ KGE:
30
+ Kling-Gupta efficiency
31
+ KGE = 1 indicates perfect agreement
32
+ KGE -ve is bad model
33
+ '''
34
+
35
+ import numpy as np
36
+ from scipy.stats import pearsonr
37
+ from .utils import shape_alike
38
+
39
+ def get_clean_pairs(sim: np.ndarray, obs: np.ndarray, min_valid: int = 2):
40
+
41
+ mask = ~(np.isnan(sim) | np.isnan(obs))
42
+
43
+ if mask.sum() < min_valid:
44
+ raise ValueError(f'only {mask.sum()} valid (non-NaN) pairs, need >= {min_valid}')
45
+
46
+ return sim[mask], obs[mask]
47
+
48
+ def R2(x:np.ndarray, y:np.ndarray)->float:
49
+ '''
50
+ Pearson correlation coefficient (R^2)
51
+ '''
52
+ # check & calculate sample shape
53
+ if not shape_alike(x,y):
54
+ raise Exception('shape mismatch between x and y')
55
+
56
+ n = x.shape[0]
57
+
58
+ NU = (n * ((x*y).sum()) - (x.sum()) * (y.sum()))**2
59
+ DE = (n * ((x**2).sum()) - (x.sum())**2 ) * ( n * ((y**2).sum()) - (y.sum())**2 )
60
+
61
+ return NU/DE
62
+
63
+
64
+ def NSE(sim:np.ndarray, obs:np.ndarray)->float:
65
+ '''
66
+ Nash Schutliff Efficiency (NSE) coefficient
67
+ '''
68
+ # N.B. sim and obs is not interchangeable for NSE
69
+
70
+ if not shape_alike(sim,obs):
71
+ raise Exception('shape mismatch between sim and obs')
72
+
73
+ obs_mean = obs.mean()
74
+
75
+ return 1 - ( np.square( obs - sim).sum() / np.square(obs-obs_mean).sum() )
76
+
77
+ def MSE(x:np.ndarray, y:np.ndarray)->float:
78
+ '''
79
+ Mean squared error
80
+ '''
81
+ if not shape_alike(x,y):
82
+ raise Exception('shape mismatch between x and y')
83
+
84
+ return ((x - y)**2).sum() / x.shape[0]
85
+
86
+ def RMSE(x:np.ndarray, y:np.ndarray)->float:
87
+ '''
88
+ Root mean squared error = sqrt(mse)
89
+ '''
90
+ if not shape_alike(x,y):
91
+ raise Exception('shape mismatch between x and y')
92
+
93
+ return np.sqrt(MSE(x,y))
94
+
95
+ def PBIAS(sim:np.ndarray, obs:np.ndarray)->float:
96
+ '''
97
+ Percentage Bias
98
+ '''
99
+ if not shape_alike(sim,obs):
100
+ raise Exception('shape mismatch between x and y')
101
+
102
+ return (obs-sim).sum() * 100 / obs.sum()
103
+
104
+ def KGE(sim:np.ndarray, obs:np.ndarray)->float:
105
+ '''
106
+ Kling-Gupta efficiency
107
+ '''
108
+
109
+ if not shape_alike(sim,obs):
110
+ raise Exception('shape mismatch between sim and obs')
111
+
112
+ eMean = (np.mean(sim) / np.mean(obs)) - 1
113
+ eVar = (np.std(sim) / np.std(obs)) - 1
114
+ eCor = pearsonr(sim, obs).statistic - 1
115
+
116
+ return 1 - np.sqrt(eMean**2 + eVar**2 + eCor**2)