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 +10 -0
- tank_core/arima.py +99 -0
- tank_core/bias_correction.py +59 -0
- tank_core/channel_routing.py +44 -0
- tank_core/computation_helpers.py +318 -0
- tank_core/cost_functions.py +116 -0
- tank_core/evapotranspiration.py +134 -0
- tank_core/global_config.py +169 -0
- tank_core/io_helpers.py +144 -0
- tank_core/project_helpers.py +103 -0
- tank_core/tank_basin.py +185 -0
- tank_core/utils.py +162 -0
- tank_model-3.0.0b3.data/scripts/cpc_et.py +127 -0
- tank_model-3.0.0b3.data/scripts/gefs_download.py +148 -0
- tank_model-3.0.0b3.data/scripts/tank_cmd.py +263 -0
- tank_model-3.0.0b3.dist-info/METADATA +101 -0
- tank_model-3.0.0b3.dist-info/RECORD +19 -0
- tank_model-3.0.0b3.dist-info/WHEEL +4 -0
- tank_model-3.0.0b3.dist-info/licenses/LICENSE +21 -0
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)
|