├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── nmc_met_base ├── __init__.py ├── arr.py ├── calculate.py ├── constants.py ├── css.py ├── cyclone.py ├── dynamic.py ├── ensemble.py ├── fast_barnes.py ├── feature │ ├── cyclone.py │ ├── cyclone_tracking.py │ └── strom_tracking.py ├── geographical.py ├── geometry.py ├── grid.py ├── invert.py ├── mathfunc.py ├── moisture.py ├── numeric.py ├── oban.py ├── pressure.py ├── psi_phi.py ├── qpf_cal.py ├── regridding.py ├── sompy.py ├── spatial_interp.py ├── spectra.py ├── stats.py ├── tc_track.py ├── thermal.py ├── thermofeel.py ├── time.py ├── topo.py ├── utilities.py ├── verify.py └── wind.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "/home/kan-dai/miniconda3/envs/MET/bin/python" 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 气象应用开发基本程序库 2 | 3 | 提供一些关于气象科学计算的基础功能函数,包括数组处理、数学函数、物理常数、时间处理、客观分析等模块; 4 | 以及气象诊断分析程序,包括动力, 热力, 水汽和天气特征分析等。 5 | 6 | Only Python 3 is supported. 7 | 建议安装[Anaconda](https://www.anaconda.com/products/individual)数据科学工具库, 8 | 已包括scipy, numpy, matplotlib等大多数常用科学程序库. 9 | 10 | ## Install 11 | 12 | Using the fellowing command to install packages: 13 | 14 | * 使用pypi安装源安装(https://pypi.org/project/nmc-met-base/) 15 | ``` 16 | pip install nmc-met-base 17 | ``` 18 | * 若要安装Github上的开发版(请先安装[Git软件](https://git-scm.com/)): 19 | ``` 20 | pip install git+git://github.com/nmcdev/nmc_met_base.git 21 | ``` 22 | * 或者下载软件包进行安装: 23 | ``` 24 | git clone --recursive https://github.com/nmcdev/nmc_met_base.git 25 | cd nmc_met_base 26 | python setup.py install 27 | ``` 28 | 29 | ### 可选支持库: 30 | * [pyinterp](https://github.com/CNES/pangeo-pyinterp), `conda install pyinterp -c conda-forge` 31 | -------------------------------------------------------------------------------- /nmc_met_base/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Collection of basic python function for meteorological library. 3 | """ 4 | 5 | __author__ = "The R & D Center for Weather Forecasting Technology in NMC, CMA" 6 | __version__ = '0.1.5.3' 7 | -------------------------------------------------------------------------------- /nmc_met_base/arr.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2019 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | """ 7 | Array manipulating functions. 8 | """ 9 | 10 | import numpy as np 11 | import xarray as xr 12 | from typing import Union 13 | 14 | 15 | def conform_dims(dims, r, ndim): 16 | """ 17 | Expands an array or scalar so that it conforms to the shape of 18 | the given dimension sizes. 19 | 20 | :param dims: An array of dimension sizes of which r will be conformed to. 21 | :param r: An numpy array whose dimensions must be a subset of dims. 22 | :param ndim: An array of dimension indexes to indicate which dimension 23 | sizes indicated by dims match the dimensions in r. 24 | :return: This function will create a new variable that has dimensions dims 25 | and the same type as r. 26 | The values of r will be copied to all of the other dimensions. 27 | 28 | :Example: 29 | >>> x = np.arange(12).reshape(3,4) 30 | >>> x = conform_dims([2,3,5,4,2],x,[1,3]) 31 | >>> print(x.shape) 32 | (2, 3, 5, 4, 2) 33 | """ 34 | 35 | # reshape r to conform the number of dimension 36 | sz = r.shape 37 | cdim = np.ones(len(dims), dtype=np.int) 38 | cdim[ndim] = sz 39 | rr = np.reshape(r, cdim) 40 | 41 | # repeat r to conform the dimension 42 | for i, item in enumerate(dims): 43 | if cdim[i] == 1: 44 | rr = np.repeat(rr, item, axis=i) 45 | 46 | # return 47 | return rr 48 | 49 | 50 | def unshape(a): 51 | """ 52 | Convert multiple dimension array to 2d array, keep 1st dim unchanged. 53 | 54 | :param a: array_like, > 2D. 55 | :return: 2d array, old shape 56 | 57 | >>> a = np.arange(40).reshape(2,4,5) 58 | >>> a.shape 59 | (2, 4, 5) 60 | >>> b, oldshape = tools.unshape(a) 61 | >>> b.shape 62 | (2, 20) 63 | >>> c = tools.deunshape(b, oldshape) 64 | >>> c.shape 65 | (2, 4, 5) 66 | 67 | """ 68 | 69 | if np.ndim(a) < 2: 70 | raise ValueError("a must be at least 2 dimension") 71 | 72 | oldshape = a.shape 73 | array2d = a.reshape(oldshape[0], -1) 74 | return array2d, oldshape 75 | 76 | 77 | def deunshape(a, oldshape): 78 | """ 79 | restore a to old shape. 80 | 81 | :param a: array_like 82 | :param oldshape: return array shape 83 | :return: ndarray 84 | 85 | :example: 86 | >>> a = np.arange(40).reshape(2,4,5) 87 | >>> a.shape 88 | (2, 4, 5) 89 | >>> b, oldshape = unshape(a) 90 | >>> b.shape 91 | (2, 20) 92 | >>> c = deunshape(b, oldshape) 93 | >>> c.shape 94 | (2, 4, 5) 95 | """ 96 | 97 | arraynd = a.reshape(oldshape) 98 | return arraynd 99 | 100 | 101 | def expand(a, ndim, axis=0): 102 | """ 103 | expand 1D array to ndim array. 104 | 105 | :param a: 1D array_like 106 | :param ndim: number of dimensions 107 | :param axis: position of 1D array 108 | :return: narray. 109 | 110 | :Example: 111 | >>> x = np.array([1, 2, 3]) 112 | >>> y = expand(x, 3, axis=1) 113 | >>> y.shape 114 | (1, 3, 1) 115 | >>> y 116 | array([[[1], 117 | [2], 118 | [3]]]) 119 | """ 120 | 121 | if axis < 0: 122 | axis = ndim + axis 123 | res = np.asarray(a) 124 | if res.ndim != 1: 125 | raise ValueError("input array must be one dimensional array") 126 | idx = [x for x in range(ndim)] 127 | idx.remove(axis) 128 | for i in idx: 129 | res = np.expand_dims(res, axis=i) 130 | return res 131 | 132 | 133 | def mrollaxis(a, axis, start=0): 134 | """ 135 | numpy.rollaxis 's MaskedArray version. 136 | 137 | :param a: array_like 138 | :param axis: moved axis 139 | :param start: moved start position. 140 | :return: ndarray 141 | """ 142 | 143 | if not hasattr(a, 'mask'): 144 | return np.rollaxis(a, axis, start=start) 145 | else: 146 | mask = np.ma.getmaskarray(a) 147 | data = np.ma.getdata(a) 148 | mask = np.rollaxis(mask, axis, start=start) 149 | data = np.rollaxis(data, axis, start=start) 150 | out = np.ma.asarray(data) 151 | out.mask = mask 152 | return out 153 | 154 | 155 | def scale_vector(in_vector, min_range, max_range, 156 | vector_min=None, vector_max=None): 157 | """ 158 | This is a utility routine to scale the elements of 159 | a vector or an array into a given data range. nan values is not changed. 160 | 161 | :param in_vector: The input vector or array to be scaled. 162 | :param min_range: The minimum output value of the scaled vector. 163 | :param max_range: The maximum output value of the scaled vector. 164 | :param vector_min: Set this value to the minimum value of the vector, 165 | before scaling (vector_min < vector). 166 | The default value is Min(vector). 167 | :param vector_max: Set this value to the maximum value of the vector, 168 | before scaling (vector_max < maxvalue). 169 | The default value is Max(vector). 170 | :return: A vector or array of the same size as the input, 171 | scaled into the data range given by `min_range` and 172 | `max_range'. The input vector is confined to the data 173 | range set by `vector_min` and `vector_max` before 174 | scaling occurs. 175 | """ 176 | 177 | # make sure numpy array 178 | vector = np.array(in_vector) 179 | 180 | # check keyword parameters 181 | if vector_min is None: 182 | vector_min = np.nanmin(vector) 183 | if vector_max is None: 184 | vector_max = np.nanmax(vector) 185 | 186 | # Calculate the scaling factors 187 | scale_factor = [( 188 | (min_range * vector_max) - 189 | (max_range * vector_min)) / (vector_max - vector_min), 190 | (max_range - min_range) / (vector_max - vector_min)] 191 | 192 | # return the scaled vector 193 | return vector * scale_factor[1] + scale_factor[0] 194 | 195 | 196 | def matching(in_a, in_b, nan=True): 197 | """ 198 | Keeping array a's values with b's sort. 199 | 200 | :param in_a: nd array. 201 | :param in_b: nd array. 202 | :param nan: do not involve nan values. 203 | :return: the same length a array. 204 | 205 | :Examples: 206 | >>> aa = np.array([3, 4, 2, 10, 7, 3, 6]) 207 | >>> bb = np.array([ 5, 7, 3, 9, 6, np.nan, 11]) 208 | >>> print(matching(aa, bb)) 209 | """ 210 | a = in_a.flatten() 211 | b = in_b.flatten() 212 | if nan: 213 | index = np.logical_and(np.isfinite(a), np.isfinite(b)) 214 | a[index][np.argsort(b[index])] = np.sort(a[index]) 215 | else: 216 | a[np.argsort(b)] = np.sort(a) 217 | a.shape = in_a.shape 218 | return a 219 | 220 | 221 | def plug_array(small, small_lat, small_lon, large, large_lat, large_lon): 222 | """ 223 | Plug a small array into a large array, assuming they have the same lat/lon 224 | resolution. 225 | 226 | Args: 227 | small ([type]): 2D array to be inserted into "large" 228 | small_lat ([type]): 1D array of lats 229 | small_lon ([type]): 1D array of lons 230 | large ([type]): 2D array for "small" to be inserted into 231 | large_lat ([type]): 1D array of lats 232 | large_lon ([type]): 1D array of lons 233 | """ 234 | 235 | small_minlat = min(small_lat) 236 | small_maxlat = max(small_lat) 237 | small_minlon = min(small_lon) 238 | small_maxlon = max(small_lon) 239 | 240 | if small_minlat in large_lat: 241 | minlat = np.where(large_lat == small_minlat)[0][0] 242 | else: 243 | minlat = min(large_lat) 244 | if small_maxlat in large_lat: 245 | maxlat = np.where(large_lat == small_maxlat)[0][0] 246 | else: 247 | maxlat = max(large_lat) 248 | if small_minlon in large_lon: 249 | minlon = np.where(large_lon == small_minlon)[0][0] 250 | else: 251 | minlon = min(large_lon) 252 | if small_maxlon in large_lon: 253 | maxlon = np.where(large_lon == small_maxlon)[0][0] 254 | else: 255 | maxlon = max(large_lon) 256 | 257 | large[minlat:maxlat+1, minlon:maxlon+1] = small 258 | 259 | return large 260 | 261 | 262 | def filter_numeric_nans(data, thresh, repl_val, high_or_low): 263 | """ 264 | Filter numerical nans above or below a specified value'' 265 | 266 | Args: 267 | data ([type]): array to filter ''' 268 | thresh ([type]): threshold value to filter above or below ''' 269 | repl_val ([type]): replacement value''' 270 | high_or_low ([type]): [description] 271 | """ 272 | 273 | dimens = np.shape(data) 274 | temp = np.reshape(data, np.prod(np.size(data)), 1) 275 | if high_or_low == 'high': 276 | inds = np.argwhere(temp > thresh) 277 | temp[inds] = repl_val 278 | elif high_or_low == 'low': 279 | inds = np.argwhere(temp < thresh) 280 | temp[inds] = repl_val 281 | elif high_or_low == 'both': 282 | inds = np.argwhere(temp > thresh) 283 | temp[inds] = repl_val 284 | del inds 285 | inds = np.argwhere(temp < -thresh) 286 | temp[inds] = -repl_val 287 | else: 288 | inds = np.argwhere(temp > thresh) 289 | temp[inds] = repl_val 290 | 291 | # Turn vector back into array 292 | data = np.reshape(temp, dimens, order='F').copy() 293 | 294 | return data 295 | 296 | 297 | def find_nearest_index(array, val): 298 | # Return the index of the value closest to the one passed in the array 299 | return np.abs(array - val).argmin() 300 | 301 | 302 | def find_nearest_value(array, val): 303 | # Return the value closest to the one passed in the array 304 | return array[np.abs(array - val).argmin()] 305 | 306 | 307 | def check_xarray(arr): 308 | """ 309 | Check if the passed array is an xarray dataaray by a simple try & except block. 310 | https://github.com/tomerburg/metlib/blob/master/diagnostics/met_functions.py 311 | 312 | Returns: 313 | Returns 0 if false, 1 if true. 314 | """ 315 | try: 316 | temp_val = arr.values 317 | return 1 318 | except: 319 | return 0 320 | 321 | 322 | def data_array_or_dataset_var(X: Union[xr.DataArray, xr.Dataset], var=None) -> xr.DataArray: 323 | """ 324 | refer to https://github.com/bgroenks96/pyclimdex/blob/master/climdex/utils.py 325 | 326 | If X is a Dataset, selects variable 'var' from X and returns the corresponding 327 | DataArray. If X is already a DataArray, returns X unchanged. 328 | """ 329 | if isinstance(X, xr.Dataset): 330 | assert var is not None, 'var name must be supplied for Dataset input' 331 | return X[var] 332 | elif isinstance(X, xr.DataArray): 333 | return X 334 | else: 335 | raise Exception('unrecognized data type: {}'.format(type(X))) 336 | 337 | -------------------------------------------------------------------------------- /nmc_met_base/calculate.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2019 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | """ 7 | Calculating functions. 8 | """ 9 | 10 | import numpy as np 11 | from nmc_met_base import arr, constants 12 | 13 | 14 | def center_finite_diff_n(grid, dim=1, r=None, map_scale=None, 15 | cyclic=False, second=False): 16 | """ 17 | Performs a centered finite difference operation on the given dimension. 18 | 19 | using: 20 | Central finite difference scheme second order for first derivatives 21 | (u[i+1]-u[i-1])/(2dx) 22 | Central finite difference scheme second order for second derivatives 23 | (u[i+1]+u[i-1]-2*u[i])/(dx*dx) 24 | 25 | reference: 26 | http://www.cfm.brown.edu/people/jansh/resources/APMA1180/fd.pdf 27 | 28 | notice: for second derivatives, ensure equal interval. 29 | 30 | :param grid: a multi-dimensional numpy array. 31 | :param r: A scalar, one-dimensional, or multi-dimensional array containing 32 | the coordinates along which grid is to be difference. Does need 33 | not be equally spaced from a computational point of view. 34 | >scalar: r assumed to be the (constant) distance between 35 | adjacent points. 36 | >one-dimensional (and the same size as the dimension of 37 | grid): applied to all dimensions of grid. 38 | >multi-dimensional: then it must be the same size as grid. 39 | :param dim: A scalar integer indicating which dimension of grid to 40 | calculate the center finite difference on. 41 | Dimension numbering starts at 1, default=1. 42 | :param map_scale: map scale coefficient, a scalar, one-dimensional, 43 | or multi-dimensional array like r. 44 | :param cyclic: cyclic or periodic boundary. 45 | :param second: calculate second derivatives, default is first derivatives. 46 | :return: finite difference array. 47 | """ 48 | 49 | # move specified dimension to the first 50 | p = np.arange(grid.ndim) 51 | p[-1] = dim - 1 52 | p[dim-1] = -1 53 | grid = np.transpose(grid, p) 54 | 55 | # construct shift vector 56 | sf = np.arange(grid.ndim) 57 | sf[0] = -1 58 | sb = np.arange(grid.ndim) 59 | sb[0] = 1 60 | 61 | # check coordinates 62 | if r is not None: 63 | if len(r) == 1: 64 | rr = np.arange(grid.shape[0], dtype=np.float) * r 65 | else: 66 | rr = r 67 | if np.ndim(rr) == 1: 68 | rr = arr.conform_dims(grid.shape, rr, [0]) 69 | else: 70 | rr = np.transpose(rr, p) 71 | 72 | if map_scale is not None: # check map scale 73 | mps = map_scale 74 | if np.ndim(mps) == 1: 75 | mps = arr.conform_dims(grid.shape, mps, [0]) 76 | if np.ndim(mps) > 1: 77 | mps = np.transpose(mps, p) 78 | rr *= mps 79 | 80 | # 81 | # Compute center finite difference 82 | # 83 | 84 | # first derivative 85 | if not second: 86 | # value difference 87 | dgrid = np.roll(grid, -1, -1) - np.roll(grid, 1, -1) 88 | 89 | # grid space 90 | if r is not None: 91 | drr = np.roll(rr, -1, -1) - np.roll(rr, 1, -1) 92 | 93 | # deal boundary 94 | if cyclic: 95 | dgrid[..., 0] = grid[..., 1] - grid[..., -1] 96 | dgrid[..., -1] = grid[..., 0] - grid[..., -2] 97 | if r is not None: 98 | drr[..., 0] = 2*(rr[..., 1] - rr[..., 0]) 99 | drr[..., -1] = 2*(rr[..., -1] - rr[..., -2]) 100 | else: 101 | dgrid[..., 0] = grid[..., 1] - grid[..., 0] 102 | dgrid[..., -1] = grid[..., -1] - grid[..., -2] 103 | if r is not None: 104 | drr[..., 0] = rr[..., 1] - rr[..., 0] 105 | drr[..., -1] = rr[..., -1] - rr[..., -2] 106 | else: 107 | # value difference 108 | dgrid = np.roll(grid, -1, -1) - 2*grid + np.roll(grid, 1, -1) 109 | 110 | # grid space 111 | if r is not None: 112 | drr = (np.roll(rr, -1, -1) - rr) * (rr - np.roll(rr, 1, -1)) 113 | 114 | # deal boundary 115 | if cyclic: 116 | dgrid[..., 0] = grid[..., 1] + grid[..., -1] - 2*grid[..., 0] 117 | dgrid[..., -1] = grid[..., 0] + grid[..., -2] - 2*grid[..., -1] 118 | if r is not None: 119 | drr[..., 0] = (rr[..., 1] - rr[..., 0]) * \ 120 | (rr[..., -1] - rr[..., -2]) 121 | drr[..., -1] = drr[..., 0] 122 | else: 123 | dgrid[..., 0] = grid[..., 0] + grid[..., -2] - 2 * grid[..., 1] 124 | dgrid[..., -1] = grid[..., -1] + grid[..., -3] - 2 * grid[..., -2] 125 | if r is not None: 126 | drr[..., 0] = (rr[..., 1] - rr[..., 0]) * \ 127 | (rr[..., 2] - rr[..., 1]) 128 | drr[..., -1] = (rr[..., -1] - rr[..., -2]) * \ 129 | (rr[..., -2] - rr[..., -3]) 130 | 131 | # compute derivatives 132 | if r is not None: 133 | dgrid /= drr 134 | 135 | # restore grid array 136 | grid = np.transpose(grid, p) 137 | dgrid = np.transpose(dgrid, p) 138 | 139 | # return 140 | return dgrid 141 | 142 | 143 | def calculate_distance_2d(lat1,lat2,lon1,lon2): 144 | # Calculates dx and dy for 2D arrays 145 | #=ACOS(COS(RADIANS(90-Lat1)) *COS(RADIANS(90-Lat2)) +SIN(RADIANS(90-Lat1)) *SIN(RADIANS(90-Lat2)) *COS(RADIANS(Long1-Long2))) *6371 146 | step1 = np.cos(np.radians(90.0-lat1)) 147 | step2 = np.cos(np.radians(90.0-lat2)) 148 | step3 = np.sin(np.radians(90.0-lat1)) 149 | step4 = np.sin(np.radians(90.0-lat2)) 150 | step5 = np.cos(np.radians(lon1-lon2)) 151 | dist = np.arccos(step1 * step2 + step3 * step4 * step5) * constants.Re 152 | 153 | return dist 154 | 155 | 156 | def compute_gradient(var,lats,lons): 157 | """ 158 | Computes the horizontal gradient of a 2D scalar variable 159 | 160 | Returns: 161 | Returns ddx, ddy (x and y components of gradient) in units of (unit)/m 162 | """ 163 | 164 | #Pull in lat & lon resolution 165 | latres = abs(lats[1]-lats[0]) 166 | lonres = abs(lons[1]-lons[0]) 167 | 168 | #compute the length scale for each gridpoint as a 2D array 169 | lons2,lats2 = np.meshgrid(lons,lats) 170 | dx = calculate_distance_2d(lats2,lats2,lons2-(lonres),lons2+(lonres)) 171 | dy = calculate_distance_2d(lats2-(latres),lats2+(latres),lons2,lons2) 172 | 173 | #Compute the gradient of the variable 174 | dvardy,dvardx = np.gradient(var) 175 | ddy = np.multiply(2,np.divide(dvardy,dy)) 176 | ddx = np.multiply(2,np.divide(dvardx,dx)) 177 | 178 | return ddx,ddy 179 | 180 | 181 | def spatial_anomaly(varin,slice_option): 182 | """ 183 | Computes the spatial anomaly of varin 184 | 185 | Input: 186 | varin: 3D array of variable to compute anomaly of 187 | slice_option: 1 to compute anomaly of second dimension 188 | 2 to compute anomaly of third dimension 189 | Output: 190 | varanom: Anomaly of varin 191 | varanom_std = Standardized anomaly of varin 192 | 193 | Steven Cavallo 194 | March 2014 195 | University of Oklahoma 196 | """ 197 | iz, iy, ix = varin.shape 198 | 199 | mvar = np.ma.masked_array(varin,np.isnan(varin)) 200 | 201 | tmp = np.zeros_like(varin).astype('f') 202 | tmp_std = np.zeros_like(varin).astype('f') 203 | 204 | if slice_option == 1: 205 | var_mean = np.mean(mvar,2) 206 | var_std = np.std(mvar,2) 207 | for kk in range(0,iz): 208 | for jj in range(0,iy): 209 | tmp[kk,jj,:] = varin[kk,jj,:] - var_mean[kk,jj] 210 | tmp_std[kk,jj,:] = var_std[kk,jj] 211 | else: 212 | var_mean = np.mean(mvar,1) 213 | var_std = np.std(mvar,1) 214 | for kk in range(0,iz): 215 | for ii in range(0,ix): 216 | tmp[kk,:,ii] = varin[kk,:,ii] - var_mean[kk,ii] 217 | tmp_std[kk,:,ii] = var_std[kk,ii] 218 | 219 | varanom = tmp 220 | varanom_std = tmp/tmp_std 221 | 222 | return varanom, varanom_std 223 | -------------------------------------------------------------------------------- /nmc_met_base/constants.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2019 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | """ 7 | Physical constants. 8 | 9 | refer to: 10 | http://carina.fcaglp.unlp.edu.ar/ida/archivos/tabla_constantes.pdf 11 | """ 12 | 13 | import sys 14 | import datetime as _dt 15 | import math as _math 16 | import numpy as _np 17 | 18 | # math constants 19 | pi = _math.pi 20 | d2r = pi / 180. # degree => radian 21 | r2d = 180. / pi # radian => degree 22 | eps = 1.e-7 # small machine float 23 | n_degree = 360. # Number of degrees in a circle 24 | epsilon = sys.float_info.epsilon 25 | 26 | # unit conversions 27 | kt2ms = 0.515 # convert kts to m/s 28 | dam2m = 10. # convert dam to m 29 | ms2kt = 1./0.515 # convert m/s to kts 30 | m2dam = 0.1 # convert m to dam 31 | km2nm = 0.54 # convert km to NM 32 | mb2pa = 100. # convert mb to Pa 33 | pa2mb = 1./100. # convert Pa to mb (*) 34 | 35 | # Earth 36 | Re = 6.371e+6 # earth's mean radius (Weast and Astle 1980, m) 37 | Ae = 5.1e14 # Area of the surface of the Earth m^2 38 | daysec = 86400.0 # day seconds 39 | omega = 7.2921159e-5 # earth angular velocity (radians/second) 40 | g0 = 9.81 # Acceleration due to gravity at sea level (N/kg) 41 | f0 = 1.e-4 # f-plane parameter (s**-1) 42 | mass_e = 5.972e24 # Mass of Earth, From Serway 1992, In kg 43 | mass_a = 5.3e18 # Mass of the Earth’s atmosphere, kg 44 | mass_ac = 1.017e4 # Mass of an atmospheric column kg/m^2 45 | 46 | # Thermodynamic constants 47 | rho0 = 1.25 # Typical density of air at sea level (kg/m**3) 48 | md = 28.97 # Effective molecular mass for dry air (kg/kmol) 49 | rd = 287.04 # dry gas constant (J/K/kg) 50 | rv = 461.6 # gas constant of water vapour (J/K/kg) 51 | cp = 1004.5 # Specific heat of dry air, constant pressure 52 | cv = 717.5 # Specific heat of dry air, constant volume 53 | gcp = 9.8e-3 # Dry adiabatic lapse rate K/m 54 | K = 2.4e-2 # Thermal conductivity at 0 J m−1 s−1 K−1 55 | kappa = rd/cp # Poisson's constant 56 | gamma = cp/cv 57 | epsil = rd/rv 58 | Talt = 288.15 # temperature at standard sea level 59 | Tfrez = 273.15 # zero degree K 60 | T0 = 300 61 | P0 = 101325 # standard atmosphere pressure Pa 62 | Pr = 1000.0 63 | lapsesta = 6.5e-3 # lower atmosphere averaged T lapse rate (degree/m) 64 | 65 | # water constants 66 | rhow = 1000. # Density of liquid water at 0C, In kg / m^3 67 | rhoi = 9.17e+2 # Density of ice at 0C kg / m^3 68 | mw = 18.016 # Molecular mass for H2O kg / kmol 69 | meps = 0.622 # Molecular weight ratio of H2O to dry air 70 | cpw = 1952. # Specific heat of water vapor at constant pressure J/deg/kg 71 | cvw = 1463. # Specific heat of water vapor at constant volume J/deg/kg 72 | cw = 4218. # Specific heat of liquid water at 0C J/K/kg 73 | ci = 2106. # Specific heat of ice at 0C J/K/kg 74 | Lv = 2.5e+6 # Latent heat of vaporization at 0 degree (J/kg) 75 | Ls = 2.85e+6 # Latent heat of sublimation (H2O) (J/kg) 76 | Lf = 3.34e+5 # Latent heat of fusion (H2O) (J/kg) 77 | eo = 6.11 78 | 79 | # time constants 80 | base_time = _dt.datetime(1900, 1, 1, 0, 0, 0) 81 | base_time_units = 'days since 1900-01-01 00 UTC' 82 | 83 | # map region limit 84 | limit_china = (73.6667, 135.042, 3.86667, 53.5500) 85 | limit_china_continents = (73., 136., 18., 54.) 86 | 87 | 88 | # functions for geophysical constants 89 | # 90 | 91 | def earth_f(lat): 92 | """ 93 | Compute f parameters. 94 | f = 2 * earth_omega*sin(lat) 95 | 96 | :param lat: array_like, latitude (degrees) to be converted 97 | :return: 98 | 99 | >>> earth_f(_np.array([-30., 0., 30.)) 100 | """ 101 | return 2.*omega*_np.sin(pi/180.*lat) 102 | 103 | 104 | def earth_beta(lat): 105 | """ 106 | Compute beta parameter. 107 | beta = df/dy = 2*earth_omega/earth_radius*cos(lat) 108 | 109 | :param lat: array_like, latitude (degrees) to be converted 110 | :return: float or array of floats, beta parameter 111 | 112 | >>> earth_beta(__np.array([-30., 0., 30.)) 113 | """ 114 | return 2.*omega/Re*_np.cos(pi/180.*lat) 115 | 116 | 117 | def dlon2dx(dlon, clat): 118 | """ 119 | 经度差在局地直角坐标系中的实际距离. 120 | """ 121 | return _np.deg2rad(dlon)*Re*_np.cos(_np.deg2rad(clat)) 122 | 123 | 124 | def dx2dlon(dx, clat): 125 | """ 126 | 实际距离转换为经度差。 127 | """ 128 | return _np.rad2deg(dx/Re/_np.cos(_np.deg2rad(clat))) 129 | 130 | 131 | def dlat2dy(dlat): 132 | """ 133 | 纬度差在局地直角坐标系中的实际距离. 134 | """ 135 | return _np.deg2rad(dlat)*Re 136 | 137 | 138 | def dy2dlat(dy): 139 | """ 140 | 实际距离转换为纬度差。 141 | """ 142 | return _np.rad2deg(dy/Re) 143 | -------------------------------------------------------------------------------- /nmc_met_base/css.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2020 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | """ 7 | Perform curvature scale space in python. The method can be used to 8 | smooth the contour lines. 9 | refer to https://github.com/makokal/pycss 10 | """ 11 | 12 | import numpy as np 13 | 14 | 15 | def _gaussian_kernel(sigma, order, t): 16 | """ _gaussian_kernel(sigma, order, t) 17 | Calculate a Gaussian kernel of the given sigma and with the given 18 | order, using the given t-values. 19 | """ 20 | 21 | # if sigma 0, kernel is a single 1. 22 | if sigma == 0: 23 | return np.array([1.0]) 24 | 25 | # pre-calculate some stuff 26 | sigma2 = sigma ** 2 27 | sqrt2 = np.sqrt(2) 28 | 29 | # Calculate the gaussian, it is unnormalized. We'll normalize at the end. 30 | basegauss = np.exp(- t ** 2 / (2 * sigma2)) 31 | 32 | # Scale the t-vector, what we actually do is H( t/(sigma*sqrt2) ), 33 | # where H() is the Hermite polynomial. 34 | x = t / (sigma * sqrt2) 35 | 36 | # Depending on the order, calculate the Hermite polynomial already generated 37 | # from mathematica 38 | if order < 0: 39 | raise Exception("The order should not be negative!") 40 | elif order == 0: 41 | part = 1 42 | elif order == 1: 43 | part = 2 * x 44 | elif order == 2: 45 | part = -2 + 4 * x ** 2 46 | else: 47 | raise Exception("Order above 2 is not implemented!") 48 | 49 | # Apply Hermite polynomial to gauss 50 | k = (-1) ** order * part * basegauss 51 | 52 | # By calculating the normalization factor by integrating the gauss, rather 53 | # than using the expression 1/(sigma*sqrt(2pi)), we know that the KERNEL 54 | # volume is 1 when the order is 0. 55 | norm_default = 1 / basegauss.sum() 56 | # == 1 / ( sigma * sqrt(2*pi) ) 57 | 58 | # Here's another normalization term that we need because we use the 59 | # Hermite polynomials. 60 | norm_hermite = 1 / (sigma * sqrt2) ** order 61 | 62 | # Normalize and return 63 | return k * (norm_default * norm_hermite) 64 | 65 | 66 | def gaussian_kernel(sigma, order=0, N=None, returnt=False): 67 | """ gaussian_kernel(sigma, order, N, returnt) 68 | Compute the gaussian kernel given a width and derivative order and optionally 69 | the length. 70 | Parameters 71 | ------------- 72 | sigma : float 73 | Width of the Gaussian kernel 74 | order : int 75 | Derivative order of the kernel 76 | N : int, optional 77 | Number of samples to return 78 | returnt : Bool 79 | Whether or not to return the abscissa 80 | Returns 81 | ----------- 82 | k : float 83 | The samples 84 | t : float 85 | Sample indices 86 | """ 87 | 88 | # checking inputs 89 | if not N: 90 | # Calculate ratio that is small, but large enough to prevent errors 91 | ratio = 3 + 0.25 * order - 2.5 / ((order - 6) ** 2 + (order - 9) ** 2) 92 | # Calculate N 93 | N = int(np.ceil(ratio * sigma)) * 2 + 1 94 | 95 | elif N > 0: 96 | if not isinstance(N, int): 97 | N = int(np.ceil(N)) 98 | 99 | elif N < 0: 100 | N = -N 101 | if not isinstance(N, int): 102 | N = int(np.ceil(N)) 103 | N = N * 2 + 1 104 | 105 | # Check whether given sigma is large enough 106 | sigmaMin = 0.5 + order ** (0.62) / 5 107 | if sigma < sigmaMin: 108 | print('WARNING: The scale (sigma) is very small for the given order, ' 109 | 'better use a larger scale!') 110 | 111 | # Create t vector which indicates the x-position 112 | t = np.arange(-N / 2.0 + 0.5, N / 2.0, 1.0, dtype=np.float64) 113 | 114 | # Get kernel 115 | k = _gaussian_kernel(sigma, order, t) 116 | 117 | # Done 118 | if returnt: 119 | return k, t 120 | else: 121 | return k 122 | 123 | 124 | def smooth_signal(signal, kernel): 125 | """ smooth_signal(signal, kernel) 126 | Smooth the given 1D signal by convolution with a specified kernel 127 | """ 128 | return np.convolve(signal, kernel, mode='same') 129 | 130 | 131 | def compute_curvature(curve, sigma): 132 | """ compute_curvature(curve, sigma) 133 | Compute the curvature of a 2D curve as given in Mohkatarian et. al. 134 | and return the curvature signal at the given sigma 135 | Components of the 2D curve are: 136 | curve[0,:] and curve[1,:] 137 | Parameters 138 | ------------- 139 | curve : numpy matrix 140 | Two row matrix representing 2D curve 141 | sigma : float 142 | Kernel width 143 | """ 144 | 145 | if curve[0, :].size < 2: 146 | raise Exception("Curve must have at least 2 points") 147 | 148 | sigx = curve[0, :] 149 | sigy = curve[1, :] 150 | g = gaussian_kernel(sigma, 0, sigx.size, False) 151 | g_s = gaussian_kernel(sigma, 1, sigx.size, False) 152 | g_ss = gaussian_kernel(sigma, 2, sigx.size, False) 153 | 154 | X_s = smooth_signal(sigx, g_s) 155 | Y_s = smooth_signal(sigy, g_s) 156 | X_ss = smooth_signal(sigx, g_ss) 157 | Y_ss = smooth_signal(sigy, g_ss) 158 | 159 | kappa = ((X_s * Y_ss) - (X_ss * Y_s)) / (X_s**2 + Y_s**2)**(1.5) 160 | 161 | return kappa, smooth_signal(sigx, g), smooth_signal(sigy, g) 162 | 163 | 164 | class CurvatureScaleSpace(object): 165 | """ Curvature Scale Space 166 | A simple curvature scale space implementation based on 167 | Mohkatarian et. al. paper. Full algorithm detailed in 168 | Okal msc thesis 169 | 170 | :Examples: 171 | curve = simple_signal(np_points=400) 172 | c = CurvatureScaleSpace() 173 | cs = c.generate_css(curve, curve.shape[1], 0.01) 174 | """ 175 | 176 | def __init__(self): 177 | pass 178 | 179 | def find_zero_crossings(self, kappa): 180 | """ find_zero_crossings(kappa) 181 | Locate the zero crossing points of the curvature signal kappa(t) 182 | """ 183 | 184 | crossings = [] 185 | 186 | for i in range(0, kappa.size - 2): 187 | if (kappa[i] < 0.0 and kappa[i + 1] > 0.0) or (kappa[i] > 0.0 and kappa[i + 1] < 0.0): 188 | crossings.append(i) 189 | 190 | return crossings 191 | 192 | def generate_css(self, curve, max_sigma, step_sigma): 193 | """ generate_css(curve, max_sigma, step_sigma) 194 | Generates a CSS image representation by repetatively smoothing the initial curve L_0 with increasing sigma 195 | """ 196 | 197 | cols = curve[0, :].size 198 | rows = max_sigma // step_sigma 199 | css = np.zeros(shape=(rows, cols)) 200 | 201 | srange = np.linspace(1, max_sigma - 1, rows) 202 | for i, sigma in enumerate(srange): 203 | kappa, sx, sy = compute_curvature(curve, sigma) 204 | 205 | # find interest points 206 | xs = self.find_zero_crossings(kappa) 207 | 208 | # save the interest points 209 | if len(xs) > 0 and sigma < max_sigma - 1: 210 | for c in xs: 211 | css[i, c] = sigma # change to any positive 212 | 213 | else: 214 | return css 215 | 216 | def generate_visual_css(self, rawcss, closeness, return_all=False): 217 | """ generate_visual_css(rawcss, closeness) 218 | Generate a 1D signal that can be plotted to depict the CSS by taking 219 | column maximums. Further checks for close interest points and nicely 220 | smoothes them with weighted moving average 221 | """ 222 | 223 | flat_signal = np.amax(rawcss, axis=0) 224 | 225 | # minor smoothing via moving averages 226 | window = closeness 227 | weights = gaussian_kernel(window, 0, window, False) # gaussian weights 228 | sig = np.convolve(flat_signal, weights)[window - 1:-(window - 1)] 229 | 230 | maxs = [] 231 | 232 | # get maximas 233 | w = sig.size 234 | 235 | for i in range(1, w - 1): 236 | if sig[i - 1] < sig[i] and sig[i] > sig[i + 1]: 237 | maxs.append([i, sig[i]]) 238 | 239 | if return_all: 240 | return sig, maxs 241 | else: 242 | return sig 243 | 244 | def generate_eigen_css(self, rawcss, return_all=False): 245 | """ generate_eigen_css(rawcss, return_all) 246 | Generates Eigen-CSS features 247 | """ 248 | rowsum = np.sum(rawcss, axis=0) 249 | csum = np.sum(rawcss, axis=1) 250 | 251 | # hack to trim c 252 | colsum = csum[0:rowsum.size] 253 | 254 | freq = np.fft.fft(rowsum) 255 | mag = abs(freq) 256 | 257 | tilde_rowsum = np.fft.ifft(mag) 258 | 259 | feature = np.concatenate([tilde_rowsum, colsum], axis=0) 260 | 261 | if not return_all: 262 | return feature 263 | else: 264 | return feature, rowsum, tilde_rowsum, colsum 265 | 266 | 267 | class SlicedCurvatureScaleSpace(CurvatureScaleSpace): 268 | """ Sliced Curvature Scale Space 269 | A implementation of the SCSS algorithm as detailed in Okal thesis 270 | """ 271 | def __init__(self): 272 | pass 273 | 274 | def generate_scss(self, curves, resample_size, max_sigma, step_sigma): 275 | """ generate_scss 276 | Generate the SCSS image 277 | """ 278 | 279 | scss = np.zeros(shape=(len(curves), resample_size)) # TODO - fix this hack 280 | # maxs = np.zeros(shape=(len(curves), resample_size)) 281 | 282 | for i, curve in enumerate(curves): 283 | scss[i, :] = self.generate_css(curve, max_sigma, step_sigma) 284 | 285 | return scss 286 | 287 | -------------------------------------------------------------------------------- /nmc_met_base/cyclone.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2019 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | """ 7 | Cyclone identification and track methods. 8 | """ 9 | 10 | import numpy as np 11 | from nmc_met_base.geographical import haversine_np 12 | from nmc_met_base.mathfunc import extreme_2d 13 | 14 | 15 | def _elim_mult_centers(in_press, in_lon, in_lat, search_rad=800e3, type=-1): 16 | """ 17 | ; Given a vector of pressures, and corresponding vectors of lon. and lat. 18 | ; where those pressures are at, looks to see if any two points are "too" 19 | ; close to each other. If they are, then the one with the lower (or 20 | ; higher, as set by the Type keyword) pressure is retained. The 1-D 21 | ; vector returned is of the locations (in terms of subscripting of the 22 | ; original pressure vector) that have been retained. 23 | ; 24 | ; This function is typically used for eliminating multiple high or low 25 | ; centers that has been identified by an automated pressure center 26 | ; finding algorithm. 27 | 28 | :param in_press: Pressure (in hPa) at locations defined by in_lon and 29 | in_lat. Floating or double array of any dimension. 30 | Unchanged by procedure. 31 | :param in_lon: Longitude of points given by in_press (in decimal deg). 32 | Floating or double array. Same dimensions as in_press. 33 | :param in_lat: Latitude of points given by in_press (in decimal deg). 34 | Floating or double array. Same dimensions as in_press. 35 | :param search_rad: Radius defining the region from a point the procedure 36 | searches to try and determine whether a given location 37 | is too close to other locations. In meters. Not 38 | changed by function. This can either be a scalar 39 | (which is applied to all locations) or a vector of the 40 | same size as in_press that gives Search_Rad to use 41 | for each location. Default is 800e3 meters. 42 | :param type: Required. If set to 1, then the function retains the 43 | higher of the pressures; if set to -1, then the function 44 | retains the lower of the pressures. 45 | :return: Vector of the locations of retained locations, as 46 | described above. Created. 1-D integer vector of 47 | array indices, in terms of the input array in_press. 48 | If none of the pressures are "too close" to each other, 49 | out_loc will end up being just a vector of the indices 50 | of all the elements in in_press. 51 | """ 52 | 53 | # protect input 54 | press = in_press 55 | lon = in_lon 56 | lat = in_lat 57 | npress = press.size 58 | 59 | ''' 60 | ; --------------------- Find Multiple Center Situations ------------------- 61 | ; 62 | ; Method: All permutations of the values of press are tested pairwise 63 | ; against to see each other to see if they are less than Search_Rad apart. 64 | ; If so, it is assumed that they are not actually separate systems, and 65 | ; the value with the lowest (highest) value is retained as describing the 66 | ; true low (high) center. 67 | ; 68 | ; NB: If a case exists where the min. (or max.) of the points that are 69 | ; within Search_Rad of each other applies to more than one point, it is 70 | ; assumed that both are centers, and a warning message is printed out. 71 | ; This should be an extremely rare situation, since press is floating pt. 72 | ''' 73 | out_loc = np.array([], dtype=np.int64) 74 | for i in range(npress): 75 | dist_from_i = haversine_np( 76 | np.full(npress, lon[i]), np.full(npress, lat[i]), lon, lat) 77 | same_loc = np.flatnonzero(dist_from_i <= search_rad) 78 | 79 | if same_loc.size == 1: 80 | out_loc = np.append(out_loc, same_loc) 81 | 82 | if same_loc.size > 1: 83 | same_press = press[same_loc] 84 | if type > 0: 85 | keep_pts = np.argmax(same_press) 86 | else: 87 | keep_pts = np.argmin(same_press) 88 | out_loc = np.append(out_loc, same_loc[keep_pts]) 89 | 90 | # ---------------------------- Clean-Up and Output ------------------------ 91 | if out_loc.size == 0: 92 | out_loc = np.arange(npress) 93 | else: 94 | out_loc = np.unique(out_loc) 95 | 96 | return out_loc 97 | 98 | 99 | def loc(in_press, in_lon, in_lat, edge_distance=800e3, 100 | lr_periodic=False, tb_periodic=False, 101 | search_rad_max=1200e3, search_rad_min=400e3, 102 | search_rad_ndiv=3, slp_diff_test=2, limit=None, 103 | ref_point=None, relax=1.0): 104 | """ 105 | ; Given a lat.-lon. grid of sea level pressure, fctn. finds where the 106 | ; centers of cyclones are (using a form of the Serreze (1995) and Serreze 107 | ; et al. (1997) algorithms) and returns a vector of the locations of 108 | ; centers, in 1-D array index form. This function supports "pseudo- 109 | ; arbitrary" spacing: For the purposes of calculating local maximum, it 110 | ; is assumed that the grid is a 2-D grid where each internal point is 111 | ; surrounded by 8 points). The boundaries of the 2-D array are also 112 | ; assumed to be the boundaries of the domain. However, no other 113 | ; assumptions, including in terms of grid size and spacing, are made. 114 | ; 115 | ; If either your top/bottom or left/right boundaries are periodic, see 116 | ; keyword list discussion of Lr_Periodic and Tb_Periodic below. Note 117 | ; although these keywords are included, I have not tested whether 118 | ; specifying those keywords will properly detect cyclones at periodic 119 | ; boundaries; I have only tested whether the specification of those 120 | ; keywords will turn on or off the edge effect filter. 121 | http://www.johnny-lin.com/idl_code/cyclone_loc.pro 122 | 123 | - References: 124 | * Serreze, M. C. (1995), Climatological aspects of cyclone development 125 | and decay in the Arctic, Atmos.-Oc., v. 33, pp. 1-23; 126 | * Serreze, M. C., F. Carse, R. G. Barry, and J. C. Rogers (1997), 127 | Icelandic low cyclone activity: Climatological features, linkages 128 | with the NAO, and relationships with recent changes in the Northern 129 | Hemisphere circulation, J. Clim., v. 10, pp. 453-464. 130 | 131 | Notices: 132 | 1 参数的选择对于最后的结果非常重要, 最好将典型的气旋显示出来, 133 | 主观测量要识别气旋的大小, 获得参数的值. 134 | 2 search_rad_min和slp_diff_test的设置经验上更为重要一些. 135 | 3 典型气旋中心常有多个低极值点, 因此search_rad_min设置太小会造成 136 | 同一个气旋多个气旋被识别出来, search_rad_min最好能够覆盖 137 | 气旋的中心部位. 138 | 4 slp_diff_test要根据search_rad_min的距离来设置, 不能设置太大, 139 | 会造成很难满足条件而无法识别出气旋, 也不能设置太小而把太多的 140 | 弱气旋包含进来, 一般考虑0.25/100km. 141 | 5 search_rad_max最好包含气旋的最外围, 但其主要作用是保证有4以上的点 142 | 高于中心气压, 这个一般很好满足, 因此不太重要. 143 | 6 search_rad_ndiv就用默认的3就行, 一般第一个圆环就能满足条件. 144 | 145 | :param in_press: Sea level pressure (in hPa) at grid defined by in_lon and 146 | in_lat. 2-D floating or double array. 147 | :param in_lon: Longitude of grid given by in_press (in decimal deg), 148 | 1D array. 149 | :param in_lat: Latitude of grid given by in_press (in decimal deg), 150 | 1D array. 151 | :param edge_distance: Distance defining how from the edge is the "good" 152 | domain to be considered; if you're within this 153 | distance of the edge (and you're boundary is 154 | non-periodic), it's assumed that cyclone centers 155 | cannot be detected there. In meters. Not changed 156 | by function. Scalar. Default is 800 kilometers. 157 | :param lr_periodic: If LR_PERIODIC is true, the left and right (i.e. col. 158 | at rows 0 and NX_P-1) bound. are assumed periodic, 159 | and the edge effect for IDing cyclones (i.e. that 160 | cyclones found near the edge are not valid) is assumed 161 | not to apply. 162 | :param tb_periodic: If TB_PERIODIC is true, the top and bottom bound. 163 | (rows at col. 0 and NY_P-1) are assumed periodic. If 164 | neither are true (default), none of the bound. are 165 | assumed periodic. 166 | :param search_rad_max: Max. radius defining the region from a point the 167 | procedure searches to try and determine whether a 168 | given location is a low pressure center. In meters. 169 | Not changed by function. This can either be a scalar 170 | (which is applied to all locations) or a vector of 171 | the same size as in_press of Search_Rad_Max to use 172 | for each location. Default is 1200e3 meters. 173 | :param search_rad_min: Min. radius defining the region from a point the 174 | procedure searches to determine whether a given 175 | location is a low pressure center. In meters. Not 176 | changed by function. This can either be a scalar 177 | (which is applied to all locations) or a vector of 178 | the same size as in_press that gives Search_Rad_Min 179 | to use for each location. Default is 400e3 meters. 180 | This value is also used to test for multiple lows 181 | (see commenting below). 182 | :param search_rad_ndiv: Integer number of shells between Search_Rad_Min and 183 | Search_Rad_Max to search. Scalar. Default is 3. 184 | :param slp_diff_test: A low pressure center is identified if it is entirely 185 | surrounded by grid points in the region between 186 | Search_Rad_Min and Search_Rad_Max that are all higher 187 | in SLP than the point in question by a min. of 188 | Slp_Diff_Test. In hPa. Not changed by function. This 189 | can either be a scalar (which is applied to all 190 | locations) or a vector of the same size as in_press 191 | of slp_diff_test to use for each location. 192 | Default is 2 hPa. 193 | :param limit: give a region limit where cyclones can be identified, 194 | format is [lonmin, lonmax, latmin, latmax]. 195 | if None, do not think limit region. 196 | :param ref_point: if is not None, will return the nearest cyclone to the 197 | reference point. 198 | :param relax: value 0~1.0, the proportion of shell grid points which meet 199 | the pressure slp_diff_test. 200 | 201 | :return: [ncyclones, 3] array, each cyclone 202 | [cent_lon, cent_lat,cent_pressure] 203 | 204 | """ 205 | 206 | # protect input 207 | press = in_press.ravel() 208 | lons, lats = np.meshgrid(in_lon, in_lat) 209 | npress = press.size 210 | 211 | # 212 | # Start cycling through each point in Entire Domain 213 | tmp_loc = [] 214 | for i in range(npress): 215 | # check limit region 216 | if limit is not None: 217 | if (lons.ravel()[i] < limit[0]) or (lons.ravel()[i] > limit[1]) \ 218 | or (lats.ravel()[i] < limit[2]) or \ 219 | (lats.ravel()[i] > limit[3]): 220 | continue 221 | 222 | ''' 223 | ; ------ What Array Indices Surround Each Index for a Shell of Points - 224 | ; 225 | ; shell_loc_for_i is a vector of the subscripts of the points that 226 | ; are within the region defined by search_rad_min and search_rad_top of 227 | ; the element i, and are not i itself. 228 | ; 229 | ; For each point in the spatial domain, we search through a number of 230 | ; shells (where search_rad_top expands outwards by search_rad_ndiv 231 | ; steps until it reaches search_rad_max). This enables more 232 | ; flexibility in finding centers of various sizes. 233 | ''' 234 | 235 | # distance of each point from i 236 | dist_from_i = haversine_np( 237 | np.full(npress, lons.ravel()[i]), np.full(npress, lats.ravel()[i]), 238 | lons.ravel(), lats.ravel()) 239 | 240 | # make array of the lower limit of of the search shell 241 | incr = (search_rad_max - search_rad_min) / search_rad_ndiv 242 | search_rad_top = (np.arange(search_rad_ndiv) + 1.0) * incr + \ 243 | search_rad_min 244 | 245 | # Cycle through each search_rad division 246 | for ndiv in range(search_rad_ndiv): 247 | shell_loc_for_i = np.flatnonzero( 248 | (dist_from_i <= search_rad_top[ndiv]) & 249 | (dist_from_i >= search_rad_min)) 250 | npts_shell = shell_loc_for_i.size 251 | 252 | if npts_shell == 0: 253 | print("*** warning--domain may be too spread out ***") 254 | 255 | ''' 256 | ; --------------- Find Locations That Pass the Low Pressure Test -- 257 | ; 258 | ; Method: For each location, check that the pressure of all the 259 | ; points in the shell around i, defined by search_rad_top and 260 | ; search_rad_min, is slp_diff_test higher. If so, and the shell 261 | ; of points around that location is >= 4 (which is a test to help 262 | ; make sure the location isn't being examined on the basis of just 263 | ; a few points), then that location is labeled as passing the low 264 | ; pressure test. 265 | ; 266 | ; Note that since the shell is based upon distance which is based 267 | ; on lat/lon, this low pressure test automatically accommodates for 268 | ; periodic bound., if the bounds are periodic. For non-periodic 269 | ; bounds, some edge points may pass this test, and thus must be 270 | ; removed later on in the edge effects removal section. 271 | ''' 272 | if npts_shell > 0: 273 | slp_diff = press[shell_loc_for_i] - press[i] 274 | tmp = np.flatnonzero(slp_diff >= slp_diff_test) 275 | if (tmp.size >= npts_shell*relax) and (npts_shell >= 4): 276 | tmp_loc.append(i) 277 | break # pass the low pressure test 278 | 279 | ''' 280 | ; ----------------- Identify Low Pressure Centers Candidates -------------- 281 | ; 282 | ; Method: From the locations that pass the SLP difference test, we find 283 | ; which ones could be low pressure centers by finding the locations that 284 | ; are local minimums in SLP. Note low_loc values are in units of indices 285 | ; of the orig. pressure array. 286 | ''' 287 | if len(tmp_loc) == 0: 288 | return None 289 | 290 | tmp_loc = np.array(tmp_loc) 291 | test_slp = np.full(in_press.shape, 100000.0) 292 | test_slp.ravel()[tmp_loc] = press.ravel()[tmp_loc] 293 | 294 | # 会去掉几个相邻的低压中心候选点,找一个最低气压的低压中心. 295 | low_loc = extreme_2d(test_slp, -1, edge=True) 296 | 297 | ''' 298 | ; ----- Test For Multiple Systems In a Region Defined By Search_Rad_Min -- 299 | ; 300 | ; Method: If two low centers identified in low_loc are less than 301 | ; Search_Rad_Min apart, it is assumed that they are not actually 302 | ; separate systems, and the value with the lowest SLP value is 303 | ; retained as describing the true low center. 304 | ''' 305 | if low_loc is not None: 306 | test_slp_ll = test_slp.ravel()[low_loc] 307 | lon_ll = lons.ravel()[low_loc] 308 | lat_ll = lats.ravel()[low_loc] 309 | emc_loc = _elim_mult_centers( 310 | test_slp_ll, lon_ll, lat_ll, type=-1, search_rad=search_rad_min) 311 | out_loc = low_loc[emc_loc] 312 | else: 313 | return None 314 | 315 | ''' 316 | ; --------------------------- Eliminate Edge Points ----------------------- 317 | ; 318 | ; Method: Eliminate all out_loc candidate points that are a distance 319 | ; Edge_Distance away from the edge, for the boundaries that are non- 320 | ; periodic. 321 | ''' 322 | # Flag to elim. edge: default is on (=1) 323 | ielim_flag = True 324 | 325 | if not lr_periodic and not tb_periodic: 326 | edge_lon = np.concatenate( 327 | (lons[0, :], lons[-1, :], lons[:, 0], lons[:, -1])) 328 | edge_lat = np.concatenate( 329 | (lats[0, :], lats[-1, :], lats[:, 0], lats[:, -1])) 330 | elif lr_periodic and not tb_periodic: 331 | edge_lon = np.concatenate((lons[:, 0], lons[:, -1])) 332 | edge_lat = np.concatenate((lats[:, 0], lats[:, -1])) 333 | elif not lr_periodic and tb_periodic: 334 | edge_lon = np.concatenate((lons[0, :], lons[-1, :])) 335 | edge_lat = np.concatenate((lats[0, :], lats[-1, :])) 336 | elif lr_periodic and tb_periodic: 337 | # set flag to elim. edge to off 338 | ielim_flag = False 339 | else: 340 | print('error--bad periodic keywords') 341 | 342 | # Case elim. at least some edges 343 | if ielim_flag: 344 | for i, iloc in np.ndenumerate(out_loc): 345 | dist_from_ol_i = haversine_np( 346 | np.full(edge_lon.size, lons.ravel()[iloc]), 347 | np.full(edge_lat.size, lats.ravel()[iloc]), 348 | edge_lon, edge_lat) 349 | 350 | tmp = np.flatnonzero(dist_from_ol_i <= edge_distance) 351 | if tmp.size > 0: 352 | out_loc[i] = -1 353 | 354 | # keep only those points not near edge: 355 | good_pts = np.flatnonzero(out_loc >= 0) 356 | if good_pts.size > 0: 357 | out_loc = out_loc[good_pts] 358 | else: 359 | return None 360 | 361 | # clean up and sort 362 | cent_lon = lons.ravel()[out_loc] 363 | cent_lat = lats.ravel()[out_loc] 364 | cent_press = press[out_loc] 365 | sort_idx = np.argsort(cent_press) 366 | cent_press = cent_press[sort_idx] 367 | cent_lon = cent_lon[sort_idx] 368 | cent_lat = cent_lat[sort_idx] 369 | if ref_point is None: 370 | return np.stack((cent_lon, cent_lat, cent_press), axis=1) 371 | else: 372 | dist_from_refer = haversine_np( 373 | np.full(cent_press.size, ref_point[0]), 374 | np.full(cent_press.size, ref_point[1]), cent_lon, cent_lat) 375 | idx = np.argmin(dist_from_refer) 376 | return np.array( 377 | [cent_lon[idx], cent_lat[idx], cent_press[idx]]).reshape([1, 3]) 378 | -------------------------------------------------------------------------------- /nmc_met_base/ensemble.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2021 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | import numpy as np 7 | import numba as nb 8 | from scipy.stats import gumbel_r 9 | 10 | 11 | def prob_matched_ens_mean(values): 12 | """ 13 | Perform probability-matched ensemble mean (PM). 14 | 15 | The QPFs rarely predict the rain pattern in exactly the same place. 16 | When combining multiple rain fields to produce a deterministic rain 17 | forecast, the ensemble mean is likely to predict the best location 18 | of the rain center, but the averaging process "smears" the rain rates 19 | so that the maximum rainfall is reduced and area of light rain is 20 | artificially enhanced (see plot below). However, the rain rate frequency 21 | distribution in the original ensemble (collating all the rain rates from 22 | all the individual members) is usually closer to the observed rain rate 23 | frequency distribution. Probability matching transforms the rain rate 24 | distribution in the ensemble mean rain field to look like that from the 25 | complete ensemble 26 | 27 | refer to: 28 | Ebert, E. E. (2001). "Ability of a poor man's ensemble to predict the probability 29 | and distribution of precipitation." monthly weather review 129(10): 2461-2480. 30 | 31 | Args: 32 | values (np.array): ensemble forecasts, shape=(ensemble_members, lat, lon) 33 | 34 | Returns: 35 | np.array: probability-matched ensemble mean array, shape=(lat, lon) 36 | """ 37 | 38 | # get dimensions 39 | nmem, nlat, nlon = values.shape 40 | 41 | # calculate ensemble mean 42 | ens_mean = values.mean(axis=0).flatten() 43 | 44 | # construct result variables 45 | ens_pm_mean = np.zeros(nlat * nlon) 46 | 47 | # calculate probability-matched ensemble mean 48 | values = values.flatten() 49 | ens_pm_mean[np.argsort(ens_mean)] = values[(np.argsort(values))[round(nmem/2.0-1)::nmem]] 50 | 51 | # restore shape 52 | values.shape = (nmem, nlat, nlon) 53 | ens_pm_mean.shape = (nlat, nlon) 54 | 55 | # return 56 | return ens_pm_mean 57 | 58 | 59 | def prob_matched_ens_mean_local(values, half_width=10): 60 | """Calculate local probability-matched ensemble mean field. 61 | 62 | Args: 63 | values (np.array): ensemble forecasts, shape=(ensemble_members, lat, lon) 64 | half_width (scaler, optional): the half width of local square. 65 | 66 | Returns: 67 | np.array: local probability-matched ensemble mean array, shape=(lat, lon) 68 | """ 69 | 70 | # get dimensions 71 | _, nlat, nlon = values.shape 72 | 73 | # construct result variables 74 | ens_pm_mean = np.zeros((nlat, nlon)) 75 | 76 | # compuate ensemble means 77 | ens_mean = values.mean(axis=0) 78 | 79 | # loop every grid point 80 | for j in range(nlat): 81 | for i in range(nlon): 82 | # subset the local square 83 | i0 = max(0, i - half_width) 84 | i1 = min(nlon, i + half_width + 1) 85 | j0 = max(0, j - half_width) 86 | j1 = min(nlat, j + half_width + 1) 87 | sub_values = values[:, j0:j1, i0:i1] 88 | 89 | # perform probability matching 90 | nmem, ny, nx = sub_values.shape 91 | sub_ens_pm_mean = np.zeros(ny * nx) 92 | sub_ens_mean = np.ravel(ens_mean[j0:j1, i0:i1]) 93 | sub_values = np.ravel(sub_values) 94 | sub_ens_pm_mean[np.argsort(sub_ens_mean)] = \ 95 | sub_values[(np.argsort(sub_values))[round(nmem/2.0-1)::nmem]] 96 | index = int(min(j,half_width)) * nx + int(min(i,half_width)) 97 | ens_pm_mean[j,i] = sub_ens_pm_mean[index] 98 | 99 | # return PM ensemble mean 100 | return ens_pm_mean 101 | 102 | 103 | def optimal_quantiles_cal(values, thresholds, optimal_quant): 104 | """ 105 | Calculate optimal quantiles for ensemble forecasts. 106 | 107 | Args: 108 | values (np.array): ensemble forecasts, shape=(ensemble_members, lat, lon) 109 | thresholds (np.array): 1D array, precipiation thresholds, increase order, like [0.0, 10, 25, 50, 100] 110 | optimal_quant (np.array): 1D array, optimal quantiles which corresponding to each threshold. 111 | 112 | Returns: 113 | np.array: probability-matched ensemble mean array, shape=(lat, lon) 114 | """ 115 | 116 | # get dimensions 117 | _, nlat, nlon = values.shape 118 | 119 | # reverse order 120 | thresholds = list(reversed(thresholds)) 121 | optimal_quant = list(reversed(optimal_quant)) 122 | values_cal = np.full((nlat, nlon), np.nan) 123 | 124 | # loop each threshold 125 | for ithreshold, threshold in enumerate(thresholds): 126 | tmp = np.nanquantile(values, optimal_quant[ithreshold], axis=0) 127 | tmp_mask = np.logical_and(tmp >= threshold, np.isnan(values_cal)) 128 | if np.count_nonzero(tmp_mask) > 0: 129 | values_cal[tmp_mask] = tmp[tmp_mask] 130 | 131 | # set nan to zero 132 | values_cal[np.isnan(values_cal)] = 0.0 133 | 134 | return values_cal 135 | 136 | 137 | @nb.njit() 138 | def schaake_shuffle(fcst, traj): 139 | """ 140 | Perform the schaake shuffle method with history observation. 141 | 142 | Clark, M., Gangopadhyay, S., Hay, L., Rajagopalan, B. and Wilby, R., 2004. 143 | The Schaake shuffle: A method for reconstructing space–time variability in forecasted 144 | precipitation and temperature fields. Journal of Hydrometeorology, 5(1), pp.243-262. 145 | 146 | refer to: 147 | https://github.com/yingkaisha/fcstpp/blob/main/fcstpp/gridpp.py 148 | 149 | 150 | Args: 151 | fcst (np.array): ensemble forecasts, shape=(ensemble_members, lead_time, grid_points) 152 | traj (np.array): trajectories, shape=(history_time, lead_time, grid_points) 153 | number of trajectories and ensemble memebers must match: `history_time == ensemb_members`. 154 | number of forecast lead time must match: `fcst.shape == traj.shape`. 155 | 这里traj是从与预报时效lead_time同期的历史观测数据中(如[lead_time-7, lead_time+7]), 随机地选择 156 | 与ensemble_members数量相当的历史观测数据, 该历史观测携带了空间分布信息. 157 | 158 | Return: 159 | output: shuffled ensemble forecast, shape=(ensemb_members, lead_time, grid_points) 160 | """ 161 | 162 | num_traj, N_lead, N_grids = traj.shape 163 | 164 | output = np.empty((num_traj, N_lead, N_grids)) 165 | 166 | for l in range(N_lead): 167 | for n in range(N_grids): 168 | 169 | temp_traj = traj[:, l, n] 170 | temp_fcst = fcst[:, l, n] 171 | 172 | reverse_b_func = np.searchsorted(np.sort(temp_traj), temp_traj) 173 | 174 | output[:, l, n] = np.sort(temp_fcst)[reverse_b_func] 175 | return output 176 | 177 | 178 | @nb.njit() 179 | def schaake_shuffle_var(fcst, traj): 180 | """ 181 | Perform the schaake shuffle method with history observation. 182 | 183 | Clark, M., Gangopadhyay, S., Hay, L., Rajagopalan, B. and Wilby, R., 2004. 184 | The Schaake shuffle: A method for reconstructing space–time variability in forecasted 185 | precipitation and temperature fields. Journal of Hydrometeorology, 5(1), pp.243-262. 186 | 187 | refer to: 188 | https://github.com/yingkaisha/fcstpp/blob/main/fcstpp/gridpp.py 189 | 190 | 191 | Args: 192 | fcst (np.array): ensemble forecasts, shape=(ensemb_members, grid_points, variables) 193 | a three-dimensional matrix of ensemble forecasts 194 | traj (np.array): trajectories, shape=(history_time, grid_points, variables) 195 | number of trajectories and ensemble memebers must match: `history_time == ensemb_members`. 196 | number of forecast lead time must match: `fcst.shape == traj.shape`. 197 | To correspond to the matrix fcst, we construct an identicallysized 198 | three-dimensional matrix traj derived from historical station 199 | observations of the respective variables, The dates used to populate 200 | the matrix traj are selected so as to lie within seven days before and 201 | after the forecast date (dates can be pulled from all years in the 202 | historical record, except for the year of the forecast). Populating 203 | the traj matrix in this way means that data from the same date is 204 | used for all grid points (j) and variables (k). 205 | 206 | Return: 207 | output: shuffled ensemble forecast, shape=(ensemb_members, grid_points, variables) 208 | """ 209 | 210 | num_traj, N_grids, N_vars = traj.shape 211 | 212 | output = np.empty((num_traj, N_grids, N_vars)) 213 | 214 | for l in range(N_grids): 215 | for n in range(N_vars): 216 | 217 | temp_traj = traj[:, l, n] 218 | temp_fcst = fcst[:, l, n] 219 | 220 | reverse_b_func = np.searchsorted(np.sort(temp_traj), temp_traj) 221 | 222 | output[:, l, n] = np.sort(temp_fcst)[reverse_b_func] 223 | return output 224 | 225 | 226 | @nb.njit() 227 | def bootstrap_fill(data, expand_dim, land_mask, fillval=np.nan): 228 | """ 229 | Fill values with bootstrapped aggregation. 230 | 该函数将集合预报成员维度采用bootstrap方法进行扩充. 231 | 232 | Args: 233 | data (np.array): a four dimensional array. `shape=(time, ensemble_members, gridx, gridy)`. 234 | expand_dim (integer): dimensions of `ensemble_members` that need to be filled. 235 | If `expand_dim` == `ensemble_members` then the bootstraping is not applied. 236 | land_mask (boolean): boolean arrays with True for focused grid points (i.e., True for land grid point). 237 | `shape=(gridx, gridy)`. 238 | fillval (np.type, optional): fill values of the out-of-mask grid points. Defaults to np.nan. 239 | 240 | Return: 241 | out: bootstrapped data. `shape=(time, expand_dim, gridx, gridy)` 242 | """ 243 | 244 | N_days, _, Nx, Ny = data.shape 245 | out = np.empty((N_days, expand_dim, Nx, Ny)) 246 | out[...] = np.nan 247 | 248 | for day in range(N_days): 249 | for ix in range(Nx): 250 | for iy in range(Ny): 251 | if land_mask[ix, iy]: 252 | data_sub = data[day, :, ix, iy] 253 | flag_nonnan = np.logical_not(np.isnan(data_sub)) 254 | temp_ = data_sub[flag_nonnan] 255 | L = len(temp_) 256 | 257 | if L == 0: 258 | out[day, :, ix, iy] = fillval 259 | elif L == expand_dim: 260 | out[day, :, ix, iy] = temp_ 261 | else: 262 | ind_bagging = np.random.choice(L, size=expand_dim, replace=True) 263 | out[day, :, ix, iy] = temp_[ind_bagging] 264 | return out 265 | 266 | 267 | def rank_histogram_cal(X, R, Thresh=None, gumbel_params=None): 268 | """Calculation of "Corrected" Forecast Probability Distribution Using Rank Histogram. 269 | 270 | refer to: 271 | Hamill, T. M. and S. J. Colucci (1998). "Evaluation of Eta–RSM Ensemble Probabilistic 272 | Precipitation Forecasts." monthly weather review 126(3): 711-724. 273 | 274 | The compute scheme as following: 275 | R0, R1, R2, R3, R4,...,Rn, R{n+1} 276 | X0, X1, X2, X3, ,..., Xn 277 | if [0, Ta) , (Ta/X0)*R0 278 | if [0, Ta) , R0+R1+(Ta-X1)/(X2-X1)*R2 279 | if [0, Ta) , R0+R1+...+R{n-1} + (F(Ta)-F(Xn))/(1-F(Xn))*R{n+1} 280 | if [Ta, Tb) , ((Tb-Ta)/X0)*R0 281 | if [Ta, Tb), (F(Tb)-F(Ta))/(1-F(Xn))*R{n+1} 282 | if [Ta, Tb) , (X1-Ta)/(X1-X0)*R1+R2+...+R{n-1} + (F(Tb)-F(Xn))/(1-F(Xn))*R{n+1} 283 | if [Ta, Tb) , (X1-Ta)/(X1-X0)*R1+R2+(Tb-X2)/(X3-X2)*R3 284 | if [Ta, inf) , ((X0-Ta)/X0)*R0+R1+...+R{n+1} 285 | if [Ta, inf) , ((X3-Ta)/(X3-X2))*R3+R4+...+R{n+1} 286 | if [Ta,inf), (1-F(Ta))/(1-F(Xn))*R{n+1} 287 | 288 | Args: 289 | X (np.array): 1d array, ensemble forecast, N member 290 | R (np.array): 1d array, corresponding rank histogram, N+1 values, 291 | compuate form history forecasts and observations. 292 | Thresh (np.array): 1d array, precipiation category thresholds. 293 | [T1, T2, ..., Tn], T1 should larger than 0. 294 | gumbel_params (list): Gumbel parameters using the method of moments (Wilks 1995) 295 | [location, scale]. We assume that the probability beyond 296 | the highest ensemble member has the shape of Gumbel distribution. 297 | 298 | Return: 299 | np.array, the probability for each categories, 300 | [P(0 <= V < T1), P(T1 <= V < T2), ..., P(Tn <= V)]. 301 | 302 | Examples: 303 | X = [0, 0, 0, 0, 0, 0, 0.02, 0.04, 0.05, 0.07, 0.10, 0.11, 0.23, 0.26, 0.35] 304 | R = [0.25, 0.13, 0.09, 0.07, 0.05, 0.05, 0.04, 0.04,0.03, 0.03, 0.03, 0.02, 0.02, 0.03, 0.05, 0.07] 305 | Thresh = [0.01, 0.1, 0.25, 0.5, 1.0, 2.0] 306 | print(rank_histogram_cal(X, R, Thresh=Thresh)) 307 | # the answer should be [0.66, 0.15, 0.06, 0.11, 0.01, 0.0, 0.0] 308 | """ 309 | 310 | # sort ensemble forecast 311 | X = np.sort(X) 312 | nX = X.size 313 | 314 | # set precipiation category thresholds. 315 | if Thresh is None: 316 | Thresh = [0.1, 10, 25, 50, 100, 250] 317 | Thresh = np.sort(Thresh) 318 | 319 | # set gumbel params 320 | # default parameters from Hamill(1998) paper. 321 | if gumbel_params is None: 322 | gumbel_params = [0.03, 0.0898] 323 | gumbel = lambda x: gumbel_r.cdf(x, loc=gumbel_params[0], scale=gumbel_params[1]) 324 | 325 | # the probabilities each categories 326 | nt = Thresh.size 327 | P = np.zeros(nt + 1) 328 | 329 | # calculate P(0 <= V < T1) 330 | ind = np.searchsorted(X, Thresh[0]) 331 | if ind == 0: 332 | P[0] = (Thresh[0]/X[0])*R[0] 333 | elif ind == nX: 334 | P[0] = np.sum(R[0:nX]) + (gumbel(Thresh[0])-gumbel(X[nX-1]))/(1.0-gumbel(X[nX-1]))*R[nX] 335 | else: 336 | P[0] = np.sum(R[0:ind]) + (Thresh[0]-X[ind-1])/(X[ind]-X[ind-1])*R[ind] 337 | 338 | # calculate P(T1 <= V < T2), ..., P(Tn-1 <= V < Tn) 339 | for it, _ in enumerate(Thresh[0:-1]): 340 | # get threshold range 341 | Ta = Thresh[it] 342 | Tb = Thresh[it+1] 343 | 344 | if Tb < X[0]: 345 | P[it+1] = ((Tb-Ta)/X[0])*R[0] 346 | elif Ta >= X[-1]: 347 | P[it+1] = (gumbel(Tb)-gumbel(Ta))/(1.0-gumbel(X[-1]))*R[nX] 348 | else: 349 | inda = np.searchsorted(X, Ta) 350 | indb = np.searchsorted(X, Tb) 351 | if indb == nX: 352 | P[it+1] = (X[inda] - Ta)/(X[inda]-X[inda-1])*R[inda] + \ 353 | np.sum(R[(inda+1):(indb)]) + (gumbel(Tb)-gumbel(X[-1]))/(1.0-gumbel(X[-1]))*R[nX] 354 | else: 355 | P[it+1] = (X[inda] - Ta)/(X[inda]-X[inda-1])*R[inda] + \ 356 | np.sum(R[(inda+1):(indb)]) + (Tb-X[indb-1])/(X[indb]-X[indb-1])*R[indb] 357 | 358 | # calculate P(Tn <= V) 359 | ind = np.searchsorted(X, Thresh[-1]) 360 | if ind == 0: 361 | P[nt] = ((X[0]-Thresh[-1])/X[0])*R[0] + np.sum(R[1:]) 362 | elif ind == nX: 363 | P[nt] = (1.0-gumbel(Thresh[-1]))/(1.0-gumbel(X[-1]))*R[nX] 364 | else: 365 | P[nt] = (X[ind]-Thresh[-1])/(X[ind]-X[ind-1])*R[ind] + np.sum(R[(ind+1):]) 366 | 367 | return P 368 | 369 | 370 | def prob(data, thresholds, axis=0, reverse=False): 371 | """ 372 | Calculate probability forecast from ensemble forecast. 373 | 374 | Args: 375 | data (np.array): numpy array. 376 | thresholds (list): thresholds for probabilities. 377 | axis (int, optional): [description]. Defaults to 0. 378 | reverse (bool, optional): [description]. Defaults to False. 379 | """ 380 | 381 | # get the ensemble number 382 | dims = list(data.shape) 383 | nmem = dims[axis] 384 | dims.pop(axis) 385 | 386 | # create probability array 387 | thresholds = np.asarray(thresholds) 388 | prob_data = np.full((thresholds.size, *dims), np.nan) 389 | 390 | # loop every threshold for computing probabilities 391 | idx = np.where(~np.isnan(data)) 392 | for it, threshold in enumerate(thresholds): 393 | if not reverse: 394 | temp = np.where(data[idx] >= threshold, 1, 0) 395 | else: 396 | temp = np.where(data[idx] <= threshold, 1, 0) 397 | temp2 = np.full(data.shape, np.nan) 398 | temp2[idx] = temp 399 | prob_data[it,...] = temp2.sum(axis=axis) * 100.0 / nmem 400 | 401 | # return 402 | return prob_data 403 | 404 | -------------------------------------------------------------------------------- /nmc_met_base/feature/cyclone.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2019 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | """ 7 | Cyclone identification and track methods. 8 | """ 9 | 10 | import numpy as np 11 | from nmc_met_base.geographical import haversine_np 12 | from nmc_met_base.mathfunc import extreme_2d 13 | 14 | 15 | def _elim_mult_centers(in_press, in_lon, in_lat, search_rad=800e3, type=-1): 16 | """ 17 | ; Given a vector of pressures, and corresponding vectors of lon. and lat. 18 | ; where those pressures are at, looks to see if any two points are "too" 19 | ; close to each other. If they are, then the one with the lower (or 20 | ; higher, as set by the Type keyword) pressure is retained. The 1-D 21 | ; vector returned is of the locations (in terms of subscripting of the 22 | ; original pressure vector) that have been retained. 23 | ; 24 | ; This function is typically used for eliminating multiple high or low 25 | ; centers that has been identified by an automated pressure center 26 | ; finding algorithm. 27 | 28 | :param in_press: Pressure (in hPa) at locations defined by in_lon and 29 | in_lat. Floating or double array of any dimension. 30 | Unchanged by procedure. 31 | :param in_lon: Longitude of points given by in_press (in decimal deg). 32 | Floating or double array. Same dimensions as in_press. 33 | :param in_lat: Latitude of points given by in_press (in decimal deg). 34 | Floating or double array. Same dimensions as in_press. 35 | :param search_rad: Radius defining the region from a point the procedure 36 | searches to try and determine whether a given location 37 | is too close to other locations. In meters. Not 38 | changed by function. This can either be a scalar 39 | (which is applied to all locations) or a vector of the 40 | same size as in_press that gives Search_Rad to use 41 | for each location. Default is 800e3 meters. 42 | :param type: Required. If set to 1, then the function retains the 43 | higher of the pressures; if set to -1, then the function 44 | retains the lower of the pressures. 45 | :return: Vector of the locations of retained locations, as 46 | described above. Created. 1-D integer vector of 47 | array indices, in terms of the input array in_press. 48 | If none of the pressures are "too close" to each other, 49 | out_loc will end up being just a vector of the indices 50 | of all the elements in in_press. 51 | """ 52 | 53 | # protect input 54 | press = in_press 55 | lon = in_lon 56 | lat = in_lat 57 | npress = press.size 58 | 59 | ''' 60 | ; --------------------- Find Multiple Center Situations ------------------- 61 | ; 62 | ; Method: All permutations of the values of press are tested pairwise 63 | ; against to see each other to see if they are less than Search_Rad apart. 64 | ; If so, it is assumed that they are not actually separate systems, and 65 | ; the value with the lowest (highest) value is retained as describing the 66 | ; true low (high) center. 67 | ; 68 | ; NB: If a case exists where the min. (or max.) of the points that are 69 | ; within Search_Rad of each other applies to more than one point, it is 70 | ; assumed that both are centers, and a warning message is printed out. 71 | ; This should be an extremely rare situation, since press is floating pt. 72 | ''' 73 | out_loc = np.array([], dtype=np.int64) 74 | for i in range(npress): 75 | dist_from_i = haversine_np( 76 | np.full(npress, lon[i]), np.full(npress, lat[i]), lon, lat) 77 | same_loc = np.flatnonzero(dist_from_i <= search_rad) 78 | 79 | if same_loc.size == 1: 80 | out_loc = np.append(out_loc, same_loc) 81 | 82 | if same_loc.size > 1: 83 | same_press = press[same_loc] 84 | if type > 0: 85 | keep_pts = np.argmax(same_press) 86 | else: 87 | keep_pts = np.argmin(same_press) 88 | out_loc = np.append(out_loc, same_loc[keep_pts]) 89 | 90 | # ---------------------------- Clean-Up and Output ------------------------ 91 | if out_loc.size == 0: 92 | out_loc = np.arange(npress) 93 | else: 94 | out_loc = np.unique(out_loc) 95 | 96 | return out_loc 97 | 98 | 99 | def loc(in_press, in_lon, in_lat, edge_distance=800e3, 100 | lr_periodic=False, tb_periodic=False, 101 | search_rad_max=1200e3, search_rad_min=400e3, 102 | search_rad_ndiv=3, slp_diff_test=2, limit=None, 103 | ref_point=None, relax=1.0): 104 | """ 105 | ; Given a lat.-lon. grid of sea level pressure, fctn. finds where the 106 | ; centers of cyclones are (using a form of the Serreze (1995) and Serreze 107 | ; et al. (1997) algorithms) and returns a vector of the locations of 108 | ; centers, in 1-D array index form. This function supports "pseudo- 109 | ; arbitrary" spacing: For the purposes of calculating local maximum, it 110 | ; is assumed that the grid is a 2-D grid where each internal point is 111 | ; surrounded by 8 points). The boundaries of the 2-D array are also 112 | ; assumed to be the boundaries of the domain. However, no other 113 | ; assumptions, including in terms of grid size and spacing, are made. 114 | ; 115 | ; If either your top/bottom or left/right boundaries are periodic, see 116 | ; keyword list discussion of Lr_Periodic and Tb_Periodic below. Note 117 | ; although these keywords are included, I have not tested whether 118 | ; specifying those keywords will properly detect cyclones at periodic 119 | ; boundaries; I have only tested whether the specification of those 120 | ; keywords will turn on or off the edge effect filter. 121 | http://www.johnny-lin.com/idl_code/cyclone_loc.pro 122 | 123 | - References: 124 | * Serreze, M. C. (1995), Climatological aspects of cyclone development 125 | and decay in the Arctic, Atmos.-Oc., v. 33, pp. 1-23; 126 | * Serreze, M. C., F. Carse, R. G. Barry, and J. C. Rogers (1997), 127 | Icelandic low cyclone activity: Climatological features, linkages 128 | with the NAO, and relationships with recent changes in the Northern 129 | Hemisphere circulation, J. Clim., v. 10, pp. 453-464. 130 | 131 | Notices: 132 | 1 参数的选择对于最后的结果非常重要, 最好将典型的气旋显示出来, 133 | 主观测量要识别气旋的大小, 获得参数的值. 134 | 2 search_rad_min和slp_diff_test的设置经验上更为重要一些. 135 | 3 典型气旋中心常有多个低极值点, 因此search_rad_min设置太小会造成 136 | 同一个气旋多个气旋被识别出来, search_rad_min最好能够覆盖 137 | 气旋的中心部位. 138 | 4 slp_diff_test要根据search_rad_min的距离来设置, 不能设置太大, 139 | 会造成很难满足条件而无法识别出气旋, 也不能设置太小而把太多的 140 | 弱气旋包含进来, 一般考虑0.25/100km. 141 | 5 search_rad_max最好包含气旋的最外围, 但其主要作用是保证有4以上的点 142 | 高于中心气压, 这个一般很好满足, 因此不太重要. 143 | 6 search_rad_ndiv就用默认的3就行, 一般第一个圆环就能满足条件. 144 | 145 | :param in_press: Sea level pressure (in hPa) at grid defined by in_lon and 146 | in_lat. 2-D floating or double array. 147 | :param in_lon: Longitude of grid given by in_press (in decimal deg), 148 | 1D array. 149 | :param in_lat: Latitude of grid given by in_press (in decimal deg), 150 | 1D array. 151 | :param edge_distance: Distance defining how from the edge is the "good" 152 | domain to be considered; if you're within this 153 | distance of the edge (and you're boundary is 154 | non-periodic), it's assumed that cyclone centers 155 | cannot be detected there. In meters. Not changed 156 | by function. Scalar. Default is 800 kilometers. 157 | :param lr_periodic: If LR_PERIODIC is true, the left and right (i.e. col. 158 | at rows 0 and NX_P-1) bound. are assumed periodic, 159 | and the edge effect for IDing cyclones (i.e. that 160 | cyclones found near the edge are not valid) is assumed 161 | not to apply. 162 | :param tb_periodic: If TB_PERIODIC is true, the top and bottom bound. 163 | (rows at col. 0 and NY_P-1) are assumed periodic. If 164 | neither are true (default), none of the bound. are 165 | assumed periodic. 166 | :param search_rad_max: Max. radius defining the region from a point the 167 | procedure searches to try and determine whether a 168 | given location is a low pressure center. In meters. 169 | Not changed by function. This can either be a scalar 170 | (which is applied to all locations) or a vector of 171 | the same size as in_press of Search_Rad_Max to use 172 | for each location. Default is 1200e3 meters. 173 | :param search_rad_min: Min. radius defining the region from a point the 174 | procedure searches to determine whether a given 175 | location is a low pressure center. In meters. Not 176 | changed by function. This can either be a scalar 177 | (which is applied to all locations) or a vector of 178 | the same size as in_press that gives Search_Rad_Min 179 | to use for each location. Default is 400e3 meters. 180 | This value is also used to test for multiple lows 181 | (see commenting below). 182 | :param search_rad_ndiv: Integer number of shells between Search_Rad_Min and 183 | Search_Rad_Max to search. Scalar. Default is 3. 184 | :param slp_diff_test: A low pressure center is identified if it is entirely 185 | surrounded by grid points in the region between 186 | Search_Rad_Min and Search_Rad_Max that are all higher 187 | in SLP than the point in question by a min. of 188 | Slp_Diff_Test. In hPa. Not changed by function. This 189 | can either be a scalar (which is applied to all 190 | locations) or a vector of the same size as in_press 191 | of slp_diff_test to use for each location. 192 | Default is 2 hPa. 193 | :param limit: give a region limit where cyclones can be identified, 194 | format is [lonmin, lonmax, latmin, latmax]. 195 | if None, do not think limit region. 196 | :param ref_point: if is not None, will return the nearest cyclone to the 197 | reference point. 198 | :param relax: value 0~1.0, the proportion of shell grid points which meet 199 | the pressure slp_diff_test. 200 | 201 | :return: [ncyclones, 3] array, each cyclone 202 | [cent_lon, cent_lat,cent_pressure] 203 | 204 | """ 205 | 206 | # protect input 207 | press = in_press.ravel() 208 | lons, lats = np.meshgrid(in_lon, in_lat) 209 | npress = press.size 210 | 211 | # 212 | # Start cycling through each point in Entire Domain 213 | tmp_loc = [] 214 | for i in range(npress): 215 | # check limit region 216 | if limit is not None: 217 | if (lons.ravel()[i] < limit[0]) or (lons.ravel()[i] > limit[1]) \ 218 | or (lats.ravel()[i] < limit[2]) or \ 219 | (lats.ravel()[i] > limit[3]): 220 | continue 221 | 222 | ''' 223 | ; ------ What Array Indices Surround Each Index for a Shell of Points - 224 | ; 225 | ; shell_loc_for_i is a vector of the subscripts of the points that 226 | ; are within the region defined by search_rad_min and search_rad_top of 227 | ; the element i, and are not i itself. 228 | ; 229 | ; For each point in the spatial domain, we search through a number of 230 | ; shells (where search_rad_top expands outwards by search_rad_ndiv 231 | ; steps until it reaches search_rad_max). This enables more 232 | ; flexibility in finding centers of various sizes. 233 | ''' 234 | 235 | # distance of each point from i 236 | dist_from_i = haversine_np( 237 | np.full(npress, lons.ravel()[i]), np.full(npress, lats.ravel()[i]), 238 | lons.ravel(), lats.ravel()) 239 | 240 | # make array of the lower limit of of the search shell 241 | incr = (search_rad_max - search_rad_min) / search_rad_ndiv 242 | search_rad_top = (np.arange(search_rad_ndiv) + 1.0) * incr + \ 243 | search_rad_min 244 | 245 | # Cycle through each search_rad division 246 | for ndiv in range(search_rad_ndiv): 247 | shell_loc_for_i = np.flatnonzero( 248 | (dist_from_i <= search_rad_top[ndiv]) & 249 | (dist_from_i >= search_rad_min)) 250 | npts_shell = shell_loc_for_i.size 251 | 252 | if npts_shell == 0: 253 | print("*** warning--domain may be too spread out ***") 254 | 255 | ''' 256 | ; --------------- Find Locations That Pass the Low Pressure Test -- 257 | ; 258 | ; Method: For each location, check that the pressure of all the 259 | ; points in the shell around i, defined by search_rad_top and 260 | ; search_rad_min, is slp_diff_test higher. If so, and the shell 261 | ; of points around that location is >= 4 (which is a test to help 262 | ; make sure the location isn't being examined on the basis of just 263 | ; a few points), then that location is labeled as passing the low 264 | ; pressure test. 265 | ; 266 | ; Note that since the shell is based upon distance which is based 267 | ; on lat/lon, this low pressure test automatically accommodates for 268 | ; periodic bound., if the bounds are periodic. For non-periodic 269 | ; bounds, some edge points may pass this test, and thus must be 270 | ; removed later on in the edge effects removal section. 271 | ''' 272 | if npts_shell > 0: 273 | slp_diff = press[shell_loc_for_i] - press[i] 274 | tmp = np.flatnonzero(slp_diff >= slp_diff_test) 275 | if (tmp.size >= npts_shell*relax) and (npts_shell >= 4): 276 | tmp_loc.append(i) 277 | break # pass the low pressure test 278 | 279 | ''' 280 | ; ----------------- Identify Low Pressure Centers Candidates -------------- 281 | ; 282 | ; Method: From the locations that pass the SLP difference test, we find 283 | ; which ones could be low pressure centers by finding the locations that 284 | ; are local minimums in SLP. Note low_loc values are in units of indices 285 | ; of the orig. pressure array. 286 | ''' 287 | if len(tmp_loc) == 0: 288 | return None 289 | 290 | tmp_loc = np.array(tmp_loc) 291 | test_slp = np.full(in_press.shape, 100000.0) 292 | test_slp.ravel()[tmp_loc] = press.ravel()[tmp_loc] 293 | 294 | # 会去掉几个相邻的低压中心候选点,找一个最低气压的低压中心. 295 | low_loc = extreme_2d(test_slp, -1, edge=True) 296 | 297 | ''' 298 | ; ----- Test For Multiple Systems In a Region Defined By Search_Rad_Min -- 299 | ; 300 | ; Method: If two low centers identified in low_loc are less than 301 | ; Search_Rad_Min apart, it is assumed that they are not actually 302 | ; separate systems, and the value with the lowest SLP value is 303 | ; retained as describing the true low center. 304 | ''' 305 | if low_loc is not None: 306 | test_slp_ll = test_slp.ravel()[low_loc] 307 | lon_ll = lons.ravel()[low_loc] 308 | lat_ll = lats.ravel()[low_loc] 309 | emc_loc = _elim_mult_centers( 310 | test_slp_ll, lon_ll, lat_ll, type=-1, search_rad=search_rad_min) 311 | out_loc = low_loc[emc_loc] 312 | else: 313 | return None 314 | 315 | ''' 316 | ; --------------------------- Eliminate Edge Points ----------------------- 317 | ; 318 | ; Method: Eliminate all out_loc candidate points that are a distance 319 | ; Edge_Distance away from the edge, for the boundaries that are non- 320 | ; periodic. 321 | ''' 322 | # Flag to elim. edge: default is on (=1) 323 | ielim_flag = True 324 | 325 | if not lr_periodic and not tb_periodic: 326 | edge_lon = np.concatenate( 327 | (lons[0, :], lons[-1, :], lons[:, 0], lons[:, -1])) 328 | edge_lat = np.concatenate( 329 | (lats[0, :], lats[-1, :], lats[:, 0], lats[:, -1])) 330 | elif lr_periodic and not tb_periodic: 331 | edge_lon = np.concatenate((lons[:, 0], lons[:, -1])) 332 | edge_lat = np.concatenate((lats[:, 0], lats[:, -1])) 333 | elif not lr_periodic and tb_periodic: 334 | edge_lon = np.concatenate((lons[0, :], lons[-1, :])) 335 | edge_lat = np.concatenate((lats[0, :], lats[-1, :])) 336 | elif lr_periodic and tb_periodic: 337 | # set flag to elim. edge to off 338 | ielim_flag = False 339 | else: 340 | print('error--bad periodic keywords') 341 | 342 | # Case elim. at least some edges 343 | if ielim_flag: 344 | for i, iloc in np.ndenumerate(out_loc): 345 | dist_from_ol_i = haversine_np( 346 | np.full(edge_lon.size, lons.ravel()[iloc]), 347 | np.full(edge_lat.size, lats.ravel()[iloc]), 348 | edge_lon, edge_lat) 349 | 350 | tmp = np.flatnonzero(dist_from_ol_i <= edge_distance) 351 | if tmp.size > 0: 352 | out_loc[i] = -1 353 | 354 | # keep only those points not near edge: 355 | good_pts = np.flatnonzero(out_loc >= 0) 356 | if good_pts.size > 0: 357 | out_loc = out_loc[good_pts] 358 | else: 359 | return None 360 | 361 | # clean up and sort 362 | cent_lon = lons.ravel()[out_loc] 363 | cent_lat = lats.ravel()[out_loc] 364 | cent_press = press[out_loc] 365 | sort_idx = np.argsort(cent_press) 366 | cent_press = cent_press[sort_idx] 367 | cent_lon = cent_lon[sort_idx] 368 | cent_lat = cent_lat[sort_idx] 369 | if ref_point is None: 370 | return np.stack((cent_lon, cent_lat, cent_press), axis=1) 371 | else: 372 | dist_from_refer = haversine_np( 373 | np.full(cent_press.size, ref_point[0]), 374 | np.full(cent_press.size, ref_point[1]), cent_lon, cent_lat) 375 | idx = np.argmin(dist_from_refer) 376 | return np.array( 377 | [cent_lon[idx], cent_lat[idx], cent_press[idx]]).reshape([1, 3]) 378 | -------------------------------------------------------------------------------- /nmc_met_base/feature/strom_tracking.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2022 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | """ 7 | Automated detection and tracking of atmospheric storms (cyclones) 8 | and high-pressure systems (anticyclones), given a series of mean 9 | sea level pressure maps. 10 | 11 | notes: This code as been applied to 6-hourly mean sea level pressure 12 | maps from NCEP Twentieth Century Reanalysis (20CR). The code at the 13 | top of storm_detection.py will need to be modified for use with another 14 | data source, as will various other function options as necessary 15 | (e.g. time step, grid resolution, etc). 16 | 17 | Adapted from https://github.com/ecjoliver/stormTracking 18 | """ 19 | 20 | import numpy as np 21 | import scipy as sp 22 | import scipy.ndimage as ndimage 23 | from datetime import date 24 | from itertools import repeat 25 | 26 | 27 | def distance_matrix(lons,lats): 28 | '''Calculates the distances (in km) between any two cities based on the formulas 29 | c = sin(lati1)*sin(lati2)+cos(longi1-longi2)*cos(lati1)*cos(lati2) 30 | d = EARTH_RADIUS*Arccos(c) 31 | where EARTH_RADIUS is in km and the angles are in radians. 32 | Source: http://mathforum.org/library/drmath/view/54680.html 33 | This function returns the matrix.''' 34 | 35 | EARTH_RADIUS = 6378.1 36 | X = len(lons) 37 | Y = len(lats) 38 | assert X == Y, 'lons and lats must have same number of elements' 39 | 40 | d = np.zeros((X,X)) 41 | 42 | #Populate the matrix. 43 | for i2 in range(len(lons)): 44 | lati2 = lats[i2] 45 | loni2 = lons[i2] 46 | c = np.sin(np.radians(lats)) * np.sin(np.radians(lati2)) + \ 47 | np.cos(np.radians(lons-loni2)) * \ 48 | np.cos(np.radians(lats)) * np.cos(np.radians(lati2)) 49 | d[c<1,i2] = EARTH_RADIUS * np.arccos(c[c<1]) 50 | 51 | return d 52 | 53 | 54 | def detect_storms(field, lon, lat, res, Npix_min, cyc, globe=False): 55 | ''' 56 | Detect storms present in field which satisfy the criteria. 57 | Algorithm is an adaptation of an eddy detection algorithm, 58 | outlined in Chelton et al., Prog. ocean., 2011, App. B.2, 59 | with modifications needed for storm detection. 60 | field is a 2D array specified on grid defined by lat and lon. 61 | res is the horizontal grid resolution in degrees of field 62 | Npix_min is the minimum number of pixels within which an 63 | extremum of field must lie (recommended: 9). 64 | cyc = 'cyclonic' or 'anticyclonic' specifies type of system 65 | to be detected (cyclonic storm or high-pressure systems) 66 | globe is an option to detect storms on a globe, i.e. with periodic 67 | boundaries in the West/East. Note that if using this option the 68 | supplied longitudes must be positive only (i.e. 0..360 not -180..+180). 69 | Function outputs lon, lat coordinates of detected storms 70 | ''' 71 | 72 | len_deg_lat = 111.325 # length of 1 degree of latitude [km] 73 | 74 | # Need to repeat global field to the West and East to properly detect around the edge 75 | if globe: 76 | dl = 20. # Degrees longitude to repeat on East and West of edge 77 | iEast = np.where(lon >= 360. - dl)[0][0] 78 | iWest = np.where(lon <= dl)[0][-1] 79 | lon = np.append(lon[iEast:]-360, np.append(lon, lon[:iWest]+360)) 80 | field = np.append(field[:,iEast:], np.append(field, field[:,:iWest], axis=1), axis=1) 81 | 82 | llon, llat = np.meshgrid(lon, lat) 83 | 84 | lon_storms = np.array([]) 85 | lat_storms = np.array([]) 86 | amp_storms = np.array([]) 87 | 88 | # ssh_crits is an array of ssh levels over which to perform storm detection loop 89 | # ssh_crits increasing for 'cyclonic', decreasing for 'anticyclonic' 90 | ssh_crits = np.linspace(np.nanmin(field), np.nanmax(field), 200) 91 | ssh_crits.sort() 92 | if cyc == 'anticyclonic': 93 | ssh_crits = np.flipud(ssh_crits) 94 | 95 | # loop over ssh_crits and remove interior pixels of detected storms from subsequent loop steps 96 | for ssh_crit in ssh_crits: 97 | 98 | # 1. Find all regions with eta greater (less than) than ssh_crit for anticyclonic (cyclonic) storms (Chelton et al. 2011, App. B.2, criterion 1) 99 | if cyc == 'anticyclonic': 100 | regions, nregions = ndimage.label( (field>ssh_crit).astype(int) ) 101 | elif cyc == 'cyclonic': 102 | regions, nregions = ndimage.label( (field= Npix_min 107 | region = (regions==iregion+1).astype(int) 108 | region_Npix = region.sum() 109 | storm_area_within_limits = (region_Npix >= Npix_min) 110 | 111 | # 3. Detect presence of local maximum (minimum) for anticylones (cyclones), reject if non-existent 112 | interior = ndimage.binary_erosion(region) 113 | exterior = region.astype(bool) - interior 114 | if interior.sum() == 0: 115 | continue 116 | if cyc == 'anticyclonic': 117 | has_internal_ext = field[interior].max() > field[exterior].max() 118 | elif cyc == 'cyclonic': 119 | has_internal_ext = field[interior].min() < field[exterior].min() 120 | 121 | # 4. Find amplitude of region, reject if < amp_thresh 122 | if cyc == 'anticyclonic': 123 | amp_abs = field[interior].max() 124 | amp = amp_abs - field[exterior].mean() 125 | elif cyc == 'cyclonic': 126 | amp_abs = field[interior].min() 127 | amp = field[exterior].mean() - amp_abs 128 | amp_thresh = np.abs(np.diff(ssh_crits)[0]) 129 | is_tall_storm = amp >= amp_thresh 130 | 131 | # Quit loop if these are not satisfied 132 | if np.logical_not(storm_area_within_limits * has_internal_ext * is_tall_storm): 133 | continue 134 | 135 | # Detected storms: 136 | if storm_area_within_limits * has_internal_ext * is_tall_storm: 137 | # find centre of mass of storm 138 | storm_object_with_mass = field * region 139 | storm_object_with_mass[np.isnan(storm_object_with_mass)] = 0 140 | j_cen, i_cen = ndimage.center_of_mass(storm_object_with_mass) 141 | lon_cen = np.interp(i_cen, range(0,len(lon)), lon) 142 | lat_cen = np.interp(j_cen, range(0,len(lat)), lat) 143 | # Remove storms detected outside global domain (lon < 0, > 360) 144 | if globe * (lon_cen >= 0.) * (lon_cen <= 360.): 145 | # Save storm 146 | lon_storms = np.append(lon_storms, lon_cen) 147 | lat_storms = np.append(lat_storms, lat_cen) 148 | # assign (and calculated) amplitude, area, and scale of storms 149 | amp_storms = np.append(amp_storms, amp_abs) 150 | # remove its interior pixels from further storm detection 151 | storm_mask = np.ones(field.shape) 152 | storm_mask[interior.astype(int)==1] = np.nan 153 | field = field * storm_mask 154 | 155 | return lon_storms, lat_storms, amp_storms 156 | 157 | 158 | def storms_list(lon_storms_a, lat_storms_a, amp_storms_a, lon_storms_c, lat_storms_c, amp_storms_c): 159 | ''' 160 | Creates list detected storms 161 | ''' 162 | 163 | storms = [] 164 | 165 | for ed in range(len(lon_storms_c)): 166 | storm_tmp = {} 167 | storm_tmp['lon'] = np.append(lon_storms_a[ed], lon_storms_c[ed]) 168 | storm_tmp['lat'] = np.append(lat_storms_a[ed], lat_storms_c[ed]) 169 | storm_tmp['amp'] = np.append(amp_storms_a[ed], amp_storms_c[ed]) 170 | storm_tmp['type'] = list(repeat('anticyclonic',len(lon_storms_a[ed]))) + list(repeat('cyclonic',len(lon_storms_c[ed]))) 171 | storm_tmp['N'] = len(storm_tmp['lon']) 172 | storms.append(storm_tmp) 173 | 174 | return storms 175 | 176 | 177 | def storms_init(det_storms, year, month, day, hour): 178 | ''' 179 | Initializes list of storms. The ith element of output is 180 | a dictionary of the ith storm containing information about 181 | position and size as a function of time, as well as type. 182 | ''' 183 | 184 | storms = [] 185 | 186 | for ed in range(det_storms[0]['N']): 187 | storm_tmp = {} 188 | storm_tmp['lon'] = np.array([det_storms[0]['lon'][ed]]) 189 | storm_tmp['lat'] = np.array([det_storms[0]['lat'][ed]]) 190 | storm_tmp['amp'] = np.array([det_storms[0]['amp'][ed]]) 191 | storm_tmp['type'] = det_storms[0]['type'][ed] 192 | storm_tmp['year'] = np.array([year[0]]) 193 | storm_tmp['month'] = np.array([month[0]]) 194 | storm_tmp['day'] = np.array([day[0]]) 195 | storm_tmp['hour'] = np.array([hour[0]]) 196 | storm_tmp['exist_at_start'] = True 197 | storm_tmp['terminated'] = False 198 | storms.append(storm_tmp) 199 | 200 | return storms 201 | 202 | 203 | def len_deg_lon(lat): 204 | ''' 205 | Returns the length of one degree of longitude (at latitude 206 | specified) in km. 207 | ''' 208 | 209 | R = 6371. # Radius of Earth [km] 210 | 211 | return (np.pi/180.) * R * np.cos( lat * np.pi/180. ) 212 | 213 | 214 | def len_deg_lat(): 215 | ''' 216 | Returns the length of one degree of latitude in km. 217 | ''' 218 | return 111.325 # length of 1 degree of latitude [km] 219 | 220 | 221 | def latlon2km(lon1, lat1, lon2, lat2): 222 | ''' 223 | Returns the distance, in km, between (lon1, lat1) and (lon2, lat2) 224 | ''' 225 | 226 | EARTH_RADIUS = 6371. # Radius of Earth [km] 227 | c = np.sin(np.radians(lat1)) * np.sin(np.radians(lat2)) + np.cos(np.radians(lon1-lon2)) * np.cos(np.radians(lat1)) * np.cos(np.radians(lat2)) 228 | d = EARTH_RADIUS * np.arccos(c) 229 | 230 | return d 231 | 232 | 233 | def track_storms(storms, det_storms, tt, year, month, day, hour, dt, prop_speed=80.): 234 | ''' 235 | Given a set of detected storms as a function of time (det_storms) 236 | this function will update tracks of individual storms at time step 237 | tt in variable storms 238 | dt indicates the time step of the underlying data (in hours) 239 | prop_speed indicates the maximum storm propagation speed (in km/hour) 240 | ''' 241 | 242 | # List of unassigned storms at time tt 243 | 244 | unassigned = range(det_storms[tt]['N']) 245 | 246 | # For each existing storm (t 0: 271 | 272 | # Of all found storms, accept only the nearest one 273 | 274 | dist = latlon2km(x0, y0, det_storms[tt]['lon'][unassigned], det_storms[tt]['lat'][unassigned]) 275 | nearest = dist == dist[possibles].min() 276 | next_storm = unassigned[np.where(nearest * possibles)[0][0]] 277 | 278 | # Add coordinatse and properties of accepted storm to trajectory of storm ed 279 | 280 | storms[ed]['lon'] = np.append(storms[ed]['lon'], det_storms[tt]['lon'][next_storm]) 281 | storms[ed]['lat'] = np.append(storms[ed]['lat'], det_storms[tt]['lat'][next_storm]) 282 | storms[ed]['amp'] = np.append(storms[ed]['amp'], det_storms[tt]['amp'][next_storm]) 283 | storms[ed]['year'] = np.append(storms[ed]['year'], year[tt]) 284 | storms[ed]['month'] = np.append(storms[ed]['month'], month[tt]) 285 | storms[ed]['day'] = np.append(storms[ed]['day'], day[tt]) 286 | storms[ed]['hour'] = np.append(storms[ed]['hour'], hour[tt]) 287 | 288 | # Remove detected storm from list of storms available for assigment to existing trajectories 289 | 290 | unassigned.remove(next_storm) 291 | 292 | # Terminate storm otherwise 293 | 294 | else: 295 | 296 | storms[ed]['terminated'] = True 297 | 298 | # Create "new storms" from list of storms not assigned to existing trajectories 299 | 300 | if len(unassigned) > 0: 301 | 302 | for un in unassigned: 303 | 304 | storm_tmp = {} 305 | storm_tmp['lon'] = np.array([det_storms[tt]['lon'][un]]) 306 | storm_tmp['lat'] = np.array([det_storms[tt]['lat'][un]]) 307 | storm_tmp['amp'] = np.array([det_storms[tt]['amp'][un]]) 308 | storm_tmp['type'] = det_storms[tt]['type'][un] 309 | storm_tmp['year'] = year[tt] 310 | storm_tmp['month'] = month[tt] 311 | storm_tmp['day'] = day[tt] 312 | storm_tmp['hour'] = hour[tt] 313 | storm_tmp['exist_at_start'] = False 314 | storm_tmp['terminated'] = False 315 | storms.append(storm_tmp) 316 | 317 | return storms 318 | 319 | 320 | def strip_storms(tracked_storms, dt, d_tot_min=1000., d_ratio=0.6, dur_min=72): 321 | ''' 322 | Following Klotzbach et al. (MWR, 2016) strip out storms with: 323 | 1. A duration of less than dur_min (in hours). dt provides the 324 | time step of the track data (in hours). 325 | 2. A total track length <= d_tot_min (short tracks) 326 | 3. A start-to-end straight-line distance that is less than d_ratio 327 | times the total track length (meandering tracks). 328 | Use d_tot_min = 0, d_ratio = 0 and/or dur_min = 0 to avoid stripping out 329 | storms due to these criteria. It is recommended to use dur_min >= 6 or 12 330 | hours in order to remove a significant number of "storms" that appear due 331 | to high-frequency synoptic variability in the data. 332 | ''' 333 | 334 | stripped_storms = [] 335 | 336 | for ed in range(len(tracked_storms)): 337 | 338 | # 1. Remove storms which last less than dur_min hours 339 | if len(tracked_storms[ed]['lon']) <= dur_min/dt: 340 | continue 341 | 342 | # 2. Calculate total track length 343 | d_tot = 0 344 | for k in range(len(tracked_storms[ed]['lon'])-1): 345 | d_tot += latlon2km(tracked_storms[ed]['lon'][k], tracked_storms[ed]['lat'][k], tracked_storms[ed]['lon'][k+1], tracked_storms[ed]['lat'][k+1]) 346 | 347 | # 3. Calcualate start-to-end straight-line track distance 348 | d_str = latlon2km(tracked_storms[ed]['lon'][0], tracked_storms[ed]['lat'][0], tracked_storms[ed]['lon'][-1], tracked_storms[ed]['lat'][-1]) 349 | 350 | # Keep storms that satisfy the conditions 2 & 3 351 | if (d_tot >= d_tot_min) * ((d_str / d_tot) >= d_ratio): 352 | stripped_storms.append(tracked_storms[ed]) 353 | 354 | return stripped_storms 355 | 356 | 357 | def timevector(date_start, date_end): 358 | ''' 359 | Generated daily time vector, along with year, month, day, day-of-year, 360 | and full date information, given start and and date. Format is a 3-element 361 | list so that a start date of 3 May 2005 is specified date_start = [2005,5,3] 362 | Note that day-of year (doy) is [0 to 59, 61 to 366] for non-leap years and [0 to 366] 363 | for leap years. 364 | returns: t, dates, T, year, month, day, doy 365 | ''' 366 | # Time vector 367 | t = np.arange(date(date_start[0],date_start[1],date_start[2]).toordinal(),date(date_end[0],date_end[1],date_end[2]).toordinal()+1) 368 | T = len(t) 369 | # Date list 370 | dates = [date.fromordinal(tt.astype(int)) for tt in t] 371 | # Vectors for year, month, day-of-month 372 | year = np.zeros((T)) 373 | month = np.zeros((T)) 374 | day = np.zeros((T)) 375 | for tt in range(T): 376 | year[tt] = date.fromordinal(t[tt]).year 377 | month[tt] = date.fromordinal(t[tt]).month 378 | day[tt] = date.fromordinal(t[tt]).day 379 | year = year.astype(int) 380 | month = month.astype(int) 381 | day = day.astype(int) 382 | # Leap-year baseline for defining day-of-year values 383 | year_leapYear = 2012 # This year was a leap-year and therefore doy in range of 1 to 366 384 | t_leapYear = np.arange(date(year_leapYear, 1, 1).toordinal(),date(year_leapYear, 12, 31).toordinal()+1) 385 | dates_leapYear = [date.fromordinal(tt.astype(int)) for tt in t_leapYear] 386 | month_leapYear = np.zeros((len(t_leapYear))) 387 | day_leapYear = np.zeros((len(t_leapYear))) 388 | doy_leapYear = np.zeros((len(t_leapYear))) 389 | for tt in range(len(t_leapYear)): 390 | month_leapYear[tt] = date.fromordinal(t_leapYear[tt]).month 391 | day_leapYear[tt] = date.fromordinal(t_leapYear[tt]).day 392 | doy_leapYear[tt] = t_leapYear[tt] - date(date.fromordinal(t_leapYear[tt]).year,1,1).toordinal() + 1 393 | # Calculate day-of-year values 394 | doy = np.zeros((T)) 395 | for tt in range(T): 396 | doy[tt] = doy_leapYear[(month_leapYear == month[tt]) * (day_leapYear == day[tt])] 397 | doy = doy.astype(int) 398 | 399 | return t, dates, T, year, month, day, doy 400 | -------------------------------------------------------------------------------- /nmc_met_base/geographical.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2019 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | """ 7 | Geodesy calculation. 8 | """ 9 | 10 | import numpy as np 11 | from numba import jit 12 | import nmc_met_base.constants as const 13 | 14 | 15 | def haversine_np(lon1, lat1, lon2, lat2): 16 | """ 17 | Calculate the great circle distance between two points 18 | on the earth (specified in decimal degrees) 19 | All args must be of equal length. 20 | 21 | :param lon1: point 1 longitudes. 22 | :param lat1: point 1 latitudes. 23 | :param lon2: point 2 longitudes. 24 | :param lat2: point 2 latitudes. 25 | :return: great circle distance in meters. 26 | """ 27 | 28 | lon1, lat1, lon2, lat2 = map(np.radians, [lon1, lat1, lon2, lat2]) 29 | 30 | dlon = lon2 - lon1 31 | dlat = lat2 - lat1 32 | 33 | a = np.sin(dlat/2.0)**2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon/2.0)**2 34 | 35 | c = 2 * np.arcsin(np.sqrt(a)) 36 | return 6371.e3 * c 37 | 38 | 39 | def area_weighted_mean(lon, lat, data): 40 | """Calculate the mean of gridded data on a sphere. 41 | Data points on the Earth's surface are often represented as a grid. As the 42 | grid cells do not have a constant area they have to be weighted when 43 | calculating statistical properties (e.g. mean). 44 | This function returns the weighted mean assuming a perfectly spherical 45 | globe. 46 | 47 | refer to: 48 | https://github.com/atmtools/typhon/blob/master/typhon/geographical.py 49 | 50 | Parameters: 51 | lon (ndarray): Longitude (M) angles [degree]. 52 | lat (ndarray): Latitude (N) angles [degree]. 53 | data ()ndarray): Data array (N x M). 54 | Returns: 55 | float: Area weighted mean. 56 | """ 57 | # Calculate coordinates and steradian (in rad). 58 | lon = np.deg2rad(lon) 59 | lat = np.deg2rad(lat) 60 | dlon = np.diff(lon) 61 | dlat = np.diff(lat) 62 | 63 | # Longitudal mean 64 | middle_points = (data[:, 1:] + data[:, :-1]) / 2 65 | norm = np.sum(dlon) 66 | lon_integral = np.sum(middle_points * dlon, axis=1) / norm 67 | 68 | # Latitudal mean 69 | lon_integral *= np.cos(lat) # Consider varying grid area (N-S). 70 | middle_points = (lon_integral[1:] + lon_integral[:-1]) / 2 71 | norm = np.sum(np.cos((lat[1:] + lat[:-1]) / 2) * dlat) 72 | 73 | return np.sum(middle_points * dlat) / norm 74 | 75 | 76 | def stations_mean_distance(lon, lat): 77 | """ 78 | Determine the mean separation distances of the observing stations. 79 | https://www.atmos.illinois.edu/~jtrapp/Ex3.1.pdf 80 | 81 | Arguments: 82 | lon {numpy array} -- longitude array. 83 | lat {numpy array} -- latitude array. 84 | """ 85 | 86 | # check input vector 87 | if len(lon) != len(lat): 88 | raise Exception("lon length is not equal to lat length.") 89 | 90 | # compute minimu distance 91 | min_dist = np.full(len(lon), 0.0) 92 | for i in range(len(lat)): 93 | dx = const.Re * np.cos(lat) * (lon - lon[i]) * const.d2r 94 | dy = const.Re * (lat - lat[i]) * const.d2r 95 | d = np.sqrt(dx*dx + dy*dy) 96 | min_dist[i] = np.min(d[d != 0]) 97 | 98 | # return mean distance 99 | return np.mean(min_dist) 100 | 101 | -------------------------------------------------------------------------------- /nmc_met_base/mathfunc.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2019 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | """Mathematic functions. 7 | 8 | refer to: 9 | https://github.com/atmtools/typhon 10 | """ 11 | 12 | import numpy as np 13 | 14 | 15 | def extreme_2d(array2d, flag=-1, edge=False): 16 | """ 17 | Find local extremes in a 2-d array. 18 | 19 | :param array2d: 2-d array to search. 20 | :param flag: min/max flag, -1 for min, 1 for max. 21 | :param edge: Means examine 2d array edges (default ignores edges). 22 | :return: 1D indices of local extremes (None if no extreme) 23 | """ 24 | 25 | # define accumulator 26 | c = np.full(array2d.shape, 0, np.byte) 27 | dx = [1, 1, 0, -1, -1, -1, 0, 1] 28 | dy = [0, 1, 1, 1, 0, -1, -1, -1] 29 | 30 | # compare neighboring pixels 31 | for i in range(8): 32 | s = np.roll(np.roll(array2d, dx[i], 1), dy[i], 0) 33 | if flag > 0: 34 | c[array2d > s] += 1 35 | else: 36 | c[array2d < s] += 1 37 | 38 | # drop edge 39 | if not edge: 40 | c[:, [0, -1]] = 0 41 | c[[0, -1], :] = 0 42 | 43 | # extremes 44 | w = np.flatnonzero(c == 8) 45 | if np.size(w) == 0: 46 | return None 47 | else: 48 | return w 49 | 50 | 51 | def cantor_pairing(a, b): 52 | """Create an unique number from two natural numbers 53 | For more information about the Cantor pairing function, have a look at: 54 | https://en.wikipedia.org/wiki/Pairing_function 55 | This create an unique number from two natural numbers according to 56 | .. math:: 57 | \pi (a,b):={\frac{1}{2}}(a+b)(a+b+1)+b. 58 | Args: 59 | a: A numpy.array with natural numbers, i.e. unsigned integer. 60 | b: A numpy.array with natural numbers, i.e. unsigned integer. 61 | Returns: 62 | A numpy.array with the unique values. 63 | """ 64 | 65 | a_b_sum = a + b 66 | return (0.5 * a_b_sum * (a_b_sum+1) + b).astype("int") 67 | 68 | 69 | def integrate_column(y, x=None, axis=0): 70 | """Integrate array along an arbitrary axis. 71 | Note: 72 | This function is just a wrapper for :func:`numpy.trapz`. 73 | Parameters: 74 | y (ndarray): Data array. 75 | x (ndarray): Coordinate array. 76 | axis (int): Axis to integrate along for multidimensional input. 77 | Returns: 78 | float or ndarray: Column integral. 79 | Examples: 80 | >>> import numpy as np 81 | >>> x = np.linspace(0, 1, 5) 82 | >>> y = np.arange(5) 83 | >>> integrate_column(y) 84 | 8.0 85 | >>> integrate_column(y, x) 86 | 2.0 87 | """ 88 | return np.trapz(y, x, axis=axis) 89 | 90 | 91 | def interpolate_halflevels(x, axis=0): 92 | """Returns the linear inteprolated halflevels for given array. 93 | Parameters: 94 | x (ndarray): Data array. 95 | axis (int): Axis to interpolate along. 96 | Returns: 97 | ndarray: Values at halflevels. 98 | Examples: 99 | >>> interpolate_halflevels([0, 1, 2, 4]) 100 | array([ 0.5, 1.5, 3. ]) 101 | """ 102 | return (np.take(x, range(1, np.shape(x)[axis]), axis=axis) + 103 | np.take(x, range(0, np.shape(x)[axis] - 1), axis=axis)) / 2 104 | 105 | 106 | def sum_digits(n): 107 | """Calculate the sum of digits. 108 | Parameters: 109 | n (int): Number. 110 | Returns: 111 | int: Sum of digitis of n. 112 | Examples: 113 | >>> sum_digits(42) 114 | 6 115 | """ 116 | s = 0 117 | while n: 118 | s += n % 10 119 | n //= 10 120 | 121 | return s 122 | 123 | 124 | def nlogspace(start, stop, num=50): 125 | """Creates a vector with equally logarithmic spacing. 126 | Creates a vector with length num, equally logarithmically 127 | spaced between the given end values. 128 | Parameters: 129 | start (int): The starting value of the sequence. 130 | stop (int): The end value of the sequence, 131 | unless `endpoint` is set to False. 132 | num (int): Number of samples to generate. 133 | Default is 50. Must be non-negative. 134 | Returns: ndarray. 135 | Examples: 136 | >>> nlogspace(10, 1000, 3) 137 | array([ 10., 100., 1000.]) 138 | """ 139 | return np.exp(np.linspace(np.log(start), np.log(stop), num)) 140 | 141 | 142 | def squeezable_logspace(start, stop, num=50, squeeze=1., fixpoint=0.): 143 | """Create a logarithmic grid that is squeezable around a fixpoint. 144 | Parameters: 145 | start (float): The starting value of the sequence. 146 | stop (float): The end value of the sequence. 147 | num (int): Number of sample to generate (Default is 50). 148 | squeeze (float): Factor with which the first stepwidth is 149 | squeezed in logspace. Has to be between ``(0, 2)``. 150 | Values smaller than one compress the gridpoints, 151 | while values greater than 1 strecht the spacing. 152 | The default is ``1`` (do not squeeze.) 153 | fixpoint (float): Relative fixpoint for squeezing the grid. 154 | Has to be between ``[0, 1]``. The default is ``0`` (bottom). 155 | Examples: 156 | Constructing an unsqueezed grid in logspace. 157 | >>> squeezable_logspace(1, 100, num=5) 158 | array([1., 3.16227766, 10., 31.6227766, 100.]) 159 | Constructing a grid that is squeezed at the start. 160 | >>> squeezable_logspace(1, 100, num=5, squeeze=0.5) 161 | array([1., 1.77827941, 4.64158883, 17.7827941, 100.]) 162 | Constructing a grid that is squeezed in the middle. 163 | >>> squeezable_logspace(1, 100, num=5, squeeze=0.5, fixpoint=0.5) 164 | array([1., 5.62341325, 10., 17.7827941, 100.]) 165 | Visualization of different fixpoint and squeeze factor combinations. 166 | .. plot:: 167 | :include-source: 168 | import itertools 169 | from typhon.plots import profile_p_log 170 | from typhon.math import squeezable_logspace 171 | fixpoints = [0, 0.7] 172 | squeezefacotrs = [0.5, 1.5] 173 | combinations = itertools.product(fixpoints, squeezefacotrs) 174 | fig, axes = plt.subplots(len(fixpoints), len(squeezefacotrs), 175 | sharex=True, sharey=True) 176 | for ax, (fp, s) in zip(axes.flat, combinations): 177 | p = squeezable_logspace(1000e2, 0.01e2, 20, 178 | fixpoint=fp, squeeze=s) 179 | profile_p_log(p, np.ones(p.size), 180 | marker='.', linestyle='none', ax=ax) 181 | ax.set_title('fixpoint={}, squeeze={}'.format(fp, s), 182 | size='x-small') 183 | Returns: 184 | ndarray: (Squeezed) logarithmic grid. 185 | """ 186 | # The squeeze factor has to be between 0 and 2. Otherwise, the order 187 | # of groidpoints is arbitrarily swapped as the scaled stepsizes 188 | # become negative. 189 | if squeeze <= 0 or squeeze >= 2: 190 | raise ValueError( 191 | 'Squeeze factor has to be in the open interval (0, 2).' 192 | ) 193 | 194 | # The fixpoint has to be between 0 and 1. It is used as a relative index 195 | # within the grid, values exceeding the limits result in an IndexError. 196 | if fixpoint < 0 or fixpoint > 1: 197 | raise ValueError( 198 | 'The fixpoint has to be in the closed interval [0, 1].' 199 | ) 200 | 201 | # Convert the (relative) fixpoint into an index dependent on gridsize. 202 | fixpoint_index = int(fixpoint * (num - 1)) 203 | 204 | # Create a gridpoints with constant spacing in log-scale. 205 | samples = np.linspace(np.log(start), np.log(stop), num) 206 | 207 | # Select the bottom part of the grid. The gridpoint is included in the 208 | # bottom and top part to ensure right step widths. 209 | bottom = samples[:fixpoint_index + 1] 210 | 211 | # Calculate the stepsizes between each gridpoint. 212 | steps = np.diff(bottom) 213 | 214 | # Re-adjust the stepwidth according to given squeeze factor. 215 | # The squeeze factor is linearly changing in logspace. 216 | steps *= np.linspace(2 - squeeze, squeeze, steps.size) 217 | 218 | # Reconstruct the actual gridpoints by adding the first grid value and 219 | # the cumulative sum of the stepwidths. 220 | bottom = bottom[0] + np.cumsum(np.append([0], steps)) 221 | 222 | # Re-adjust the top part as stated above. 223 | # **The order of squeezing is inverted!** 224 | top = samples[fixpoint_index:] 225 | steps = np.diff(top) 226 | steps *= np.linspace(squeeze, 2 - squeeze, steps.size) 227 | top = top[0] + np.cumsum(np.append([0], steps)) 228 | 229 | # Combine the bottom and top parts to the final grid. Drop the fixpoint 230 | # in the bottom part to avoid duplicates. 231 | return np.exp(np.append(bottom[:-1], top)) 232 | -------------------------------------------------------------------------------- /nmc_met_base/moisture.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2020 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | """ 7 | Compute moisture parameters. 8 | """ 9 | 10 | import xarray as xr 11 | import numpy as np 12 | 13 | from nmc_met_base.grid import interp_3D_to_surface 14 | 15 | 16 | def cal_ivt(q, u, v, lon, lat, lev, surf_pres=None): 17 | """ 18 | Calculate integrated water vapor transport. 19 | 20 | Args: 21 | q (numpy array): Specific humidity, g/kg, [nlev, nlat, nlon] 22 | u (numpy array): u component wind, m/s, [nlev, nlat, nlon] 23 | v (numpy array): v component wind, m/s, [nlev, nlat, nlon] 24 | lon (numpy array): vertical level, hPa, [nlev] 25 | lat (numpy array): longitude, [nlon] 26 | lev (numpy array): latitude, [nlat] 27 | surf_pres (numpy array, optional): surface pressure, hPa, [nlev, nlat, nlon]. Defaults to None. 28 | """ 29 | 30 | # compute water vapor transport 31 | qu = q * u 32 | qv = q * v 33 | 34 | # set up full grid levels 35 | pCoord, _, _ = np.meshgrid(lev, lat, lon, indexing='ij') 36 | 37 | # mask the grid points under the ground 38 | if surf_pres is not None: 39 | # 将三维格点场插值到地面上, 这里地面的高度使用地面气压来指示 40 | qus = interp_3D_to_surface(qu, lon, lat, lev, surf_pres) 41 | qvs = interp_3D_to_surface(qv, lon, lat, lev, surf_pres) 42 | 43 | # 判断三维格点是否在地面之下, 如果在地面之下, 其物理量用地面替代, 并且高度也设置为地面气压 44 | # 这样在后面积分过程中, 地面以下的积分为零值 45 | for ilevel, level in enumerate(lev): 46 | qu[ilevel, ...] = np.where(surf_pres >= level, qu[ilevel, ...], qus) 47 | qv[ilevel, ...] = np.where(surf_pres >= level, qv[ilevel, ...], qvs) 48 | pCoord[ilevel, ...] = np.where(surf_pres >= level, level, surf_pres) 49 | 50 | # compute the vertical integration, using trapezoid rule 51 | # 由于垂直坐标用的是百帕, 而比湿用的是g/kg, 因此转换单位100*0.001=0.1 52 | iqu = np.zeros((lat.size, lon.size)) 53 | iqv = np.zeros((lat.size, lon.size)) 54 | for ilevel, level in enumerate(lev[0:-1]): 55 | iqu += np.abs(pCoord[ilevel,...]-pCoord[ilevel+1,...])*(qu[ilevel,...]+qu[ilevel+1,...])*0.5*0.1/9.8 56 | iqv += np.abs(pCoord[ilevel,...]-pCoord[ilevel+1,...])*(qv[ilevel,...]+qv[ilevel+1,...])*0.5*0.1/9.8 57 | 58 | return iqu, iqv 59 | 60 | -------------------------------------------------------------------------------- /nmc_met_base/numeric.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2019 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | """ 7 | Numeric number manipulation. 8 | """ 9 | 10 | import math 11 | import numpy as np 12 | 13 | 14 | def ensure_numeric(A, typecode=None): 15 | """ 16 | Ensure that sequence is a numeric array. 17 | 18 | :param A: Sequence. If A is already a numeric array it will be returned 19 | unaltered. 20 | If not, an attempt is made to convert it to a 21 | numeric array. 22 | A: Scalar. Return 0-dimensional array containing that value. Note 23 | that a 0-dim array DOES NOT HAVE A LENGTH UNDER numpy. 24 | A: String. Array of ASCII values (numpy can't handle this) 25 | :param typecode: numeric type. If specified, use this in the conversion. 26 | If not, let numeric package decide. 27 | typecode will always be one of num.float, num.int, etc. 28 | :return: 29 | """ 30 | 31 | if isinstance(A, str): 32 | msg = 'Sorry, cannot handle strings in ensure_numeric()' 33 | raise Exception(msg) 34 | 35 | if typecode is None: 36 | if isinstance(A, np.ndarray): 37 | return A 38 | else: 39 | return np.array(A) 40 | else: 41 | return np.array(A, dtype=typecode, copy=False) 42 | 43 | 44 | def roundoff(a, digit=2): 45 | """ 46 | roundoff the number with specified digits. 47 | 48 | :param a: float 49 | :param digit: 50 | :return: 51 | 52 | :Examples: 53 | >>> roundoff(3.44e10, digit=2) 54 | 3.4e10 55 | >>> roundoff(3.49e-10, digit=2) 56 | 3.5e-10 57 | """ 58 | if a > 1: 59 | return round(a, -int(math.log10(a)) + digit - 1) 60 | else: 61 | return round(a, -int(math.log10(a)) + digit) 62 | -------------------------------------------------------------------------------- /nmc_met_base/oban.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2019 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | """ 7 | Objective analysis functions. 8 | """ 9 | 10 | import numpy as np 11 | from numba import jit 12 | from scipy.interpolate import RegularGridInterpolator 13 | from nmc_met_base.arr import scale_vector 14 | from nmc_met_base.regridding import hinterp 15 | 16 | 17 | @jit 18 | def stations_avg_distance(x, y, non_uniform=False): 19 | """ 20 | calculate the station average distance, 21 | which can be used to define grid space. 22 | 23 | :param x: 24 | :param y: 25 | :param non_uniform: 26 | :return: 27 | """ 28 | 29 | # check input vector 30 | if len(x) != len(y): 31 | raise Exception("x length is not equal to y length.") 32 | 33 | # for non-uniform point distribution, use area/npoints 34 | if non_uniform: 35 | area = (np.max(x)-np.min(x)) * (np.max(y)-np.min(y)) 36 | return np.sqrt(area) * ((1.0 + np.sqrt(len(x)))/(len(x)-1.0)) 37 | 38 | # compute minimum distance 39 | min_dist = np.full(len(x), 0.0) 40 | for i in range(len(x)): 41 | d = np.sqrt((x[i]-x)**2 + (y[i]-y)**2) 42 | min_dist[i] = np.min(d[d != 0]) 43 | 44 | # return average distance 45 | return np.mean(min_dist) 46 | 47 | 48 | @jit 49 | def barnes(ix, iy, iz, gs=None, nyx=None, limit=None, radius=None, 50 | gamma=0.3, kappa=None, npasses=3, non_uniform=True, 51 | yxout=None, first_guess=None, missing=None, 52 | zrange=None, nonegative=False): 53 | """ 54 | Implement barnes objective analysis. 55 | note: 1、not consider pole area, Near the poles, 56 | an approximate calculation of the distance along 57 | a great circle arc should be used. 58 | 59 | references: 60 | Koch, S., M. desJardins,and P. Kocin, 1983: An Interactive Barnes 61 | Objective Map Analysis Scheme for Use with Satellite and 62 | Convectional Data. Journal of Appl. Meteor., 22, 1487-1503. 63 | Barnes, S.L., 1994a: Applications of the Barnes objective analysis scheme 64 | Part I: Effects of undersampling, wave position, and station randomness. 65 | J. Atmos. Oceanic Technol. 11, 1433-1448. 66 | Barnes, S.L., 1994b: Applications of the Barnes objective analysis scheme 67 | Part II: Improving derivative estimates. J. Atmos. Oceanic Technol. 11, 68 | 1449-1458. 69 | Barnes, S.L., 1994c: Applications of the Barnes objective analysis scheme 70 | Part III: Tuning for minimum error. J. Atmos. Oceanic Technol. 11, 71 | 1459-1479. 72 | Narkhedkar, S. G., S. K. Sinha and A. K. Mitra (2008): Mesoscale 73 | objective analysis of daily rainfall with satellite and conventional 74 | data over Indian summer monsoon region. Geofizika, 25, 159-178. 75 | http://www.atmos.albany.edu/GEMHELP5.2/OABSFC.html 76 | 77 | :param ix: 1D array, station longitude 78 | :param iy: 1D array, station latitude 79 | :param iz: 1D array, station observations. 80 | :param gs: the result grid spacing, [ys, xs], where xs is the 81 | horizontal spacing between grid points and ys is 82 | the vertical spacing. Default is the average 83 | station spaces. 84 | :param nyx: the result grid size, [ny, nx], where nx is the output 85 | grid size in the x direction and ny is in the y 86 | direction. if not be specified, the size will be 87 | inferred from gs and bounds. if gs and nxy both are specified, 88 | nxy will overlap gs. 89 | :param limit: If present, limit must be a four-element array 90 | containing the grid limits in x and y of the output 91 | grid: [ymin, ymax, xmin, xmax]. If not specified, the 92 | grid limits are set to the extent of x and y. 93 | :param radius: search radius, [y radius, x radius], 94 | [40, 40] is default, with 'kappa' units, where kappa is the 95 | scale length, which controls the rate of fall-off of the 96 | weighting function. Search radius is the max distance that 97 | a station may be from a grid point to be used in the analysis 98 | for that point. The search radius will be set so that 99 | stations whose weighting factor would be less than 100 | EXP (-SEARCH) will not be used. SEARCH must be in the range 101 | 1 - 50, such that stations receiving a weight less than 102 | EXP(-search) are considered negligible. Typically a value 103 | of 20 is used, which corresponds to a weight threshold of 104 | approximately 2e-9. If a very small value is used, many grid 105 | points will not have 3 stations within the search area and 106 | will be set to the missing data value. 107 | :param gamma: is a numerical covergence parameter that controls the 108 | difference between the weights on the first and second 109 | passes, and lies between 0 and 1. Typically a value between 110 | .2 and .3 is used. Gamma=0.3 is default. 111 | gamma=0.2, minimum smoothing; 112 | gamma=1.0, maximum smoothing. 113 | :param kappa: the scale length, Koch et al., 1983 114 | :param npasses: 3 passes is default. 115 | Set the number of passes for the Barnes analysis to do 116 | 4 passes recommended for analysing fields where derivative 117 | estimates are important (Ref: Barnes 1994b) 3 passes 118 | recommended for all other fields (with gain set to 1.0) 119 | (Ref: Barnes 1994c "Two pass Barnes Objective Analysis 120 | schemes now in use probably should be replaced 121 | by appropriately tuned 3 pass or 4 pass schemes") 2 passes 122 | only recommended for "quick look" type analyses. 123 | :param non_uniform: When the data spacing is severely non-uniform, 124 | Koch et al. (1983) suggested the data spacing Dn, 125 | which has the following form: 126 | sqrt(area){(1+sqrt(N))/(N-1)} 127 | :param yxout: the latitudes and longitudes on the grid where interpolated 128 | values are desired (in degrees), list [yout[:], xout[:]] 129 | :param first_guess: use a model grid as a first guess field for the 130 | analysis, which is a dictionary 131 | {'data': [ny, nx], 'x': x[nx], 'y': y[ny]} 132 | :param missing: if set, remove missing data. 133 | :param zrange: if set, z which are in zrange are used. 134 | :param nonegative: if True, negative number were set to 0.0 for return. 135 | :return: output grid, which is a dictionary 136 | {'data': [ny, nx], 'x': x[nx], 'y': y[ny]} 137 | """ 138 | 139 | # keep origin data 140 | x = ix.copy() 141 | y = iy.copy() 142 | z = iz.copy() 143 | 144 | # check z shape 145 | if (len(x) != len(z)) or (len(y) != len(z)): 146 | raise Exception('z, x, y dimension mismatch.') 147 | 148 | # remove missing values 149 | if missing is not None: 150 | index = z != missing 151 | z = z[index] 152 | x = x[index] 153 | y = y[index] 154 | 155 | # control z value range 156 | if zrange is not None: 157 | index = np.logical_and(z >= zrange[0], z <= zrange[1]) 158 | z = z[index] 159 | x = x[index] 160 | y = y[index] 161 | 162 | # check observation number 163 | if len(z) < 3: 164 | return None 165 | 166 | # domain definitions 167 | if limit is None: 168 | limit = [np.min(y), np.max(y), np.min(x), np.max(x)] 169 | 170 | # calculate data spacing 171 | deltan = stations_avg_distance(x, y, non_uniform=non_uniform) 172 | 173 | # gamma parameters 174 | if gamma < 0.2: 175 | gamma = 0.2 176 | if gamma > 1.0: 177 | gamma = 1.0 178 | 179 | # kappa parameters (the scale length, Koch et al., 1983) 180 | if kappa is None: 181 | kappa = 5.052 * (deltan * 2.0 / np.pi) * (deltan * 2.0 / np.pi) 182 | 183 | # search radius 184 | if radius is None: 185 | radius = [40., 40.] * kappa 186 | 187 | # define grid size 188 | # 189 | # Peterson and Middleton (1963) stated that a wave whose 190 | # horizontal wavelength does not exceed at least 2*deltan 191 | # cannot resolved, since five data points are required to 192 | # describe a wave. Hence deltax(i.e., gs) not be larger than 193 | # half of deltan. Since a very small grid resolution may 194 | # produce an unrealistic noisy derivative and if the 195 | # derivative fields are to represent only resolvable features, 196 | # the grid length should not be much smaller than deltan. 197 | # Thus a constraint that deltan/3 <= deltax <= deltan/2 was 198 | # imposed by Barnes in his interactive scheme. 199 | if gs is None: 200 | if nyx is not None: 201 | gs = [(limit[1] - limit[0])/nyx[0], 202 | (limit[3] - limit[2])/nyx[1]] 203 | else: 204 | gs = [deltan, deltan] * 0.4 205 | nyx = [int((limit[1] - limit[0])/gs[0]), 206 | int((limit[3] - limit[2])/gs[1])] 207 | else: 208 | nyx = [int((limit[1] - limit[0])/gs[0]), 209 | int((limit[3] - limit[2])/gs[1])] 210 | 211 | # result grid x and y coordinates 212 | if yxout is None: 213 | nyx = [len(yxout[0]), len(yxout[1])] 214 | gs = [yxout[0][1]-yxout[0][0], yxout[1][1]-yxout[1][0]] 215 | else: 216 | yxout = [ 217 | scale_vector( 218 | np.arange(nyx[0], dtype=np.float), limit[0], limit[1]), 219 | scale_vector( 220 | np.arange(nyx[1], dtype=np.float), limit[2], limit[3])] 221 | 222 | # define grid 223 | yout = yxout[0] 224 | ny = yout.size 225 | xout = yxout[1] 226 | nx = xout.size 227 | g0 = np.full((ny, nx), np.nan) 228 | 229 | # first pass 230 | indices = [] 231 | distances = [] 232 | for j in range(ny): 233 | for i in range(nx): 234 | # points in search radius 235 | rd = ( 236 | ((xout[i] - x) / radius[0]) ** 2 + 237 | ((yout[j] - y) / radius[1]) ** 2) 238 | if np.count_nonzero(rd <= 1.0) < 1: 239 | indices.append(None) 240 | distances.append(None) 241 | continue 242 | 243 | # extract points in search radius 244 | index = np.nonzero(rd <= 1.0) 245 | xx = x[index] 246 | yy = y[index] 247 | zz = z[index] 248 | 249 | # compute the square distance 250 | d = (xout[i] - xx) * (xout[i] - xx) +\ 251 | (yout[j] - yy) * (yout[j] - yy) *\ 252 | np.cos(yout[j]) * np.cos(yout[j]) 253 | 254 | # compute weights 255 | w = np.exp(-1.0 * d / kappa) 256 | 257 | # compute grid value 258 | g0[j, i] = np.sum(w * zz) / np.sum(w) 259 | 260 | # append index and w for computation efficiency 261 | indices.append(index) 262 | distances.append(d) 263 | 264 | # initializing first guess with give field 265 | if first_guess is not None: 266 | g0 = hinterp(first_guess['data'], first_guess['x'], 267 | first_guess['y'], xout, yout) 268 | 269 | # second and more pass 270 | points = np.vstack((y, x)).T 271 | for k in range(npasses-1): 272 | # initializing corrected grid 273 | g1 = g0.copy() 274 | 275 | # interpolating to points 276 | interp_func = RegularGridInterpolator((yout, xout), g0) 277 | z1 = interp_func(points) 278 | 279 | # pass 280 | num = 0 281 | for j in range(ny): 282 | for i in range(nx): 283 | if indices[num] is None: 284 | num += 1 285 | continue 286 | 287 | # compute grid value 288 | index = indices[num] 289 | zz = z[index] - z1[index] 290 | d = distances[num] 291 | w = np.exp(-d / (gamma * kappa)) 292 | g1[j, i] = g0[i, j] + np.sum(w*zz)/np.sum(w) 293 | num += 1 294 | 295 | # update g0 296 | g0 = g1.copy() 297 | 298 | # set negative value to zero 299 | if nonegative: 300 | g0[g0 < 0] = 0.0 301 | 302 | # return grid 303 | return {'data': g0, 'x': xout, 'y': yout} 304 | -------------------------------------------------------------------------------- /nmc_met_base/pressure.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2022 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | import numpy as np 7 | 8 | 9 | # --- Pressure ---------------------------------------------------------------- 10 | def pres_to_alt(p_hPa, h_m): 11 | """ 12 | Converts a station pressure and height to an altimeter reading. 13 | Follows the NOAA converstion found here: 14 | http://www.wrh.noaa.gov/slc/projects/wxcalc/formulas/altimeterSetting.pdf 15 | Input: 16 | p_hPa - station pressure in hPa 17 | h_m - station height in meters 18 | Output: 19 | calculated altimeter value in hPa 20 | """ 21 | c = 0.190284 # Some constant. Not sure what from 22 | 23 | alt_hPa = (p_hPa - 0.3) * ( 24 | 1 + ((1013.25 ** c * 0.0065 / 288) * (h_m / (p_hPa - 0.3) ** c)) 25 | ) ** (1 / c) 26 | return alt_hPa 27 | 28 | 29 | def alt_to_pres(alt_hPa, h_m): 30 | """ 31 | Converts a station altimeter and height to pressure. 32 | Follows the NOAA conversion found here: 33 | http://www.crh.noaa.gov/images/epz/wxcalc/stationPressure.pdf 34 | Input: 35 | alt_hPa - altimeter in hPa 36 | h_m - station elevation in meters 37 | Output: 38 | pres_hPa - station pressure in hPa 39 | """ 40 | # The equation altimeter must be in inHg. Convert the presure hPa to inHg 41 | alt_inHg = 0.0295300 * alt_hPa 42 | 43 | pres_inHg = alt_inHg * ((288 - 0.0065 * h_m) / 288) ** 5.2561 44 | 45 | # Convert presure back to hPa 46 | pres_hPa = 33.8639 * pres_inHg 47 | 48 | return pres_hPa -------------------------------------------------------------------------------- /nmc_met_base/psi_phi.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2020 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | """ 7 | Deal vectorial fields, like calculating stream function, velocity potential. 8 | 9 | Primary functions of the Li et al. (2006) method 10 | Li et al. (2006) minimization method 11 | 12 | refer to https://raw.githubusercontent.com/tiagobilo/vector_fields/2afc4311194340e0427829e2eb617d4036d29d2d/psi_phi.py 13 | """ 14 | 15 | import numpy as np 16 | import scipy.optimize as optimize 17 | import time 18 | 19 | 20 | # Function to be minimized: Objective functional + Tikhonov's regularization term 21 | def ja(x,y,DX,DY,M1,N1,IDATA,ZBC,MBC,ALPHA): 22 | 23 | """ 24 | Fitting function from Li et al (2006) method 25 | """ 26 | 27 | # Derive velocity from PSI and PHI 28 | Ax = derive_ax(x,DX,DY,M1,N1,IDATA) 29 | 30 | # "Error" to be minimized 31 | e = y.copy()-Ax 32 | 33 | # Matrices multiplications 34 | Mat1 = np.matmul(e.T,e) 35 | Mat2 = np.matmul(x.T,x) 36 | 37 | # Tikhionov's functional 38 | J = np.dot(0.5,Mat1) + np.dot(ALPHA*0.5,Mat2) 39 | 40 | return J 41 | 42 | 43 | # Gradient of ja (i. e., Jacobian of ja) 44 | # following Li et al method A.T(y-Ax) + alpha x. 45 | # In our case, since Ax compute the velocity 46 | # from the psi and phi, A.T(y-Ax) will be 47 | # the curl and -divergent of the velocity difference 48 | # i.e., (zeta_o - zeta_r) and (div_r - div_o) 49 | def grad_ja(x,y,DX,DY,M1,N1,IDATA,ZBC,MBC,ALPHA): 50 | 51 | """ 52 | Jacobian of the fitting function ja from Li et al (2006) method 53 | """ 54 | 55 | # Derive velocity from PSI and Qi 56 | Ax = derive_ax(x,DX,DY,M1,N1,IDATA) 57 | 58 | # "Error" to be minimized 59 | e = y-Ax 60 | 61 | # Compute adjoint term 62 | # i. e., velocity difference curl and 63 | # velocity difference divergence 64 | adj = derive_adj(e,DX,DY,M1,N1,ZBC,MBC,IDATA) 65 | 66 | # Jacobian 67 | gj = -adj+np.dot(ALPHA,x) 68 | 69 | return gj 70 | 71 | 72 | 73 | # Derive velocity components 74 | # from psi phi field 75 | def derive_ax(x,DX,DY,M1,N1,IDATA): 76 | 77 | """ 78 | Derive velocity from psi and phi fields for the Li et al (2006) method 79 | """ 80 | 81 | ## Re-organize x 82 | psi = x[:M1*N1].reshape((M1,N1)) 83 | phi = x[M1*N1:].reshape((M1,N1)) 84 | 85 | ## Derivation 86 | dpsidy = (psi[1:,:]-psi[:-1,:])/((DY[1:,:]+DY[:-1,:])/2.0) 87 | dpsidx = (psi[:,1:]-psi[:,:-1])/((DX[:,1:]+DX[:,:-1])/2.0) 88 | 89 | dphidy = (phi[1:,:]-phi[:-1,:])/((DY[1:,:]+DY[:-1,:])/2.0) 90 | dphidx = (phi[:,1:]-phi[:,:-1])/((DX[:,1:]+DX[:,:-1])/2.0) 91 | 92 | u = ((dpsidy[:,1:]+dpsidy[:,:-1])/2.0) + ((dphidx[1:,:] + dphidx[:-1,:]) /2.0) 93 | v = (-(dpsidx[1:,:]+dpsidx[:-1,:])/2.0) + ((dphidy[:,1:] + dphidy[:,:-1]) /2.0) 94 | 95 | # Organize the variables 96 | ax = np.ones(2*(M1-1)*(N1-1))*np.nan 97 | ax[:int(ax.shape[0]/2)] = u.reshape((M1-1)*(N1-1)) 98 | ax[int(ax.shape[0]/2):] = v.reshape((M1-1)*(N1-1)) 99 | 100 | # Remove NaNs 101 | ax = ax[IDATA] 102 | 103 | return ax 104 | 105 | 106 | # Derive the adjoint term 107 | # (i. e., relative vorticity) 108 | def derive_adj(e,DX,DY,M1,N1,ZBC,MBC,IDATA): 109 | 110 | """ 111 | Derive the adjoint term of the Li et al (2006) method 112 | """ 113 | 114 | ## Resized error 115 | er = np.zeros(2*(M1-1)*(N1-1)) 116 | er[IDATA] = e.copy() 117 | 118 | ## Re-organize variables 119 | # Velocity 120 | u = er[:int(er.shape[0]/2)] 121 | v = er[int(er.shape[0]/2):] 122 | 123 | u = u.reshape((M1-1,N1-1)) 124 | v = v.reshape((M1-1,N1-1)) 125 | 126 | # Spatial resolution 127 | dy = (DY[1:-1,1:]+DY[1:-1,:-1])/2.0 128 | dx = (DX[1:,1:-1]+DX[:-1,1:-1])/2.0 129 | 130 | ## Derivation of the curl and divergence 131 | # Curl terms 132 | dudy = (u[1:,:]-u[:-1,:])/dy 133 | dudy = (dudy[:,1:]+dudy[:,:-1])/2.0 134 | 135 | dvdx = (v[:,1:]-v[:,:-1])/dx 136 | dvdx = (dvdx[1:,:]+dvdx[:-1,:])/2.0 137 | 138 | # Divergent terms 139 | dvdy = (v[1:,:]-v[:-1,:])/dy 140 | dvdy = (dvdy[:,1:]+dvdy[:,:-1])/2.0 141 | 142 | dudx = (u[:,1:]-u[:,:-1])/dx 143 | dudx = (dudx[1:,:]+dudx[:-1,:])/2.0 144 | 145 | 146 | # Curl 147 | curl = dvdx-dudy 148 | curl1 = np.ones((M1,N1))*np.nan 149 | curl1[1:-1,1:-1] = curl 150 | 151 | 152 | # Divergence 153 | div = dudx+dvdy 154 | div1 = np.ones((M1,N1))*np.nan 155 | div1[1:-1,1:-1] = div 156 | 157 | 158 | ## Calculate boundary conditions for 159 | ## the curl and divergence fields 160 | if ZBC == 'periodic' or MBC == 'periodic': 161 | 162 | if ZBC == 'periodic': 163 | 164 | # Curl 165 | dudy_1 = (u[1:,0]-u[:-1,0])/dy[:,0] 166 | dudy_2 = (u[1:,-1]-u[:-1,-1])/dy[:,-1] 167 | 168 | dvdx_1 = (v[:,0]-v[:,-1])/dx[:,0] 169 | dvdx_2 = dvdx_1.copy() 170 | 171 | curl1[1:-1,0] = ((dvdx_1[1:]+dvdx_1[:-1])/2.0)-dudy_1 172 | curl1[1:-1,-1] = ((dvdx_2[1:]+dvdx_2[:-1])/2.0)-dudy_2 173 | 174 | 175 | # Divergent 176 | dvdy_1 = (v[1:,0]-v[:-1,0])/dy[:,0] 177 | dvdy_2 = (v[1:,-1]-v[:-1,-1])/dy[:,-1] 178 | 179 | dudx_1 = (u[:,0]-u[:,-1])/dx[:,0] 180 | dudx_2 = dudx_1.copy() 181 | 182 | div1[1:-1,0] = ((dudx_1[1:]+dudx_1[:-1])/2.0)+dvdy_1 183 | div1[1:-1,-1] = ((dudx_2[1:]+dudx_2[:-1])/2.0)+dvdy_2 184 | 185 | else: 186 | curl1[:,0] = curl1[:,1]; curl1[:,-1] = curl1[:,-2] 187 | div1[:,0] = div1[:,1]; div1[:,-1] = div1[:,-2] 188 | 189 | 190 | if MBC == 'periodic': 191 | 192 | # Curl 193 | dudy_1 = (u[0,:]-u[-1,:])/dy[0,:] 194 | dudy_2 = dudy_1.copy() 195 | 196 | dvdx_1 = (v[0,1:]-v[0,:-1])/dx[0,:] 197 | dvdx_2 = (v[-1,1:]-v[-1,:-1])/dx[-1,:] 198 | 199 | curl1[0,1:-1] = dvdx_1-((dudy_1[1:]+dudy_1[:-1])/2.0) 200 | curl1[-1,1:-1] = dvdx_2-((dudy_2[1:]+dudy_2[:-1])/2.0) 201 | 202 | 203 | # Divergent 204 | dvdy_1 = (v[0,:]-v[-1,:])/dy[0,:] 205 | dvdy_2 = dvdy_1.copy() 206 | 207 | dudx_1 = (u[0,1:]-u[0,:-1])/dx[0,:] 208 | dudx_2 = (u[-1,1:]-u[-1,:-1])/dx[-1,:] 209 | 210 | div1[0,1:-1] = dudx_1 + ((dvdy_1[1:]+dvdy_1[:-1])/2.0) 211 | div1[-1,1:-1] = dudx_2 + ((dvdy_2[1:]+dvdy_2[:-1])/2.0) 212 | 213 | else: 214 | 215 | curl1[0,:] = curl1[1,:]; curl1[-1,:] = curl1[-2,:] 216 | div1[0,:] = div1[1,:]; div1[-1,:] = div1[-2,:] 217 | 218 | else: 219 | 220 | # All closed edges (i.e., land edges) 221 | curl1[0,1:-1] = curl[0,:]; curl1[-1,1:-1] = curl[-1,:] 222 | curl1[:,0] = curl1[:,1]; curl1[:,-1] = curl1[:,-2] 223 | 224 | div1[0,1:-1] = div[0,:]; div1[-1,1:-1] = div[-1,:] 225 | div1[:,0] = div1[:,1]; div1[:,-1] = div1[:,-2] 226 | 227 | 228 | 229 | # Organize the variables 230 | curl = curl1.reshape(M1*N1) 231 | div = div1.reshape(M1*N1) 232 | 233 | adj = np.ones(2*M1*N1)*np.nan 234 | adj[:M1*N1] = curl 235 | adj[M1*N1:] = -div 236 | 237 | return adj 238 | 239 | 240 | ### Auxiliary Functions 241 | ## Cumulative velocity integration 242 | def v_zonal_integration(V,DX): 243 | """ 244 | Zonal cumulative integration of velocity 245 | in a rectangular grid using a trapezoidal 246 | numerical scheme. 247 | 248 | - Integration occurs from east to west 249 | 250 | - Velocity is assumed to be zero at the lateral 251 | boundaries defined by NaN. 252 | 253 | Input: 254 | V [M,N]: meridional velocity component in m s-1 255 | DX [M,N-1]: zonal distance in m 256 | 257 | Output: 258 | vi [M,N]: Integrated velocity in m2 s-1 259 | 260 | """ 261 | 262 | ## Zero velocity at the boundaries 263 | v = V.copy() 264 | 265 | ibad = np.isnan(v) 266 | v[ibad] = 0.0 267 | 268 | ## Zonal integration 269 | vi = np.zeros(v.shape) 270 | 271 | for j in range(2,vi.shape[1]+1): 272 | vi[:,-j] = np.trapz(v[:,-j:], dx=DX[:,(-j+1):]) 273 | 274 | return vi 275 | 276 | 277 | def v_meridional_integration(V,DY): 278 | """ 279 | Meridional cumulative integration of velocity 280 | in a rectangular grid using a trapezoidal 281 | numerical scheme. 282 | 283 | - Integration occurs from north to south 284 | 285 | - Velocity is assumed to be zero at the lateral 286 | boundaries defined by NaN. 287 | 288 | Input: 289 | V [M,N]: meridional velocity component in m s-1 290 | DY [M-1,N]: zonal distance in m 291 | 292 | Output: 293 | vi [M,N]: Integrated velocity in m2 s-1 294 | 295 | """ 296 | 297 | ## Zero velocity at the boundaries 298 | v = V.copy() 299 | 300 | ibad = np.isnan(v) 301 | v[ibad] = 0.0 302 | 303 | ## Zonal integration 304 | vi = np.zeros(v.shape) 305 | 306 | for i in range(2,vi.shape[0]+1): 307 | vi[-i,:] = np.trapz(v[-i:,:],dx=DY[(-i+1):,:],axis=0) 308 | 309 | return vi 310 | 311 | 312 | ## Calculate distances from a rectangular lat/lon grid 313 | def dx_from_dlon(lon,lat): 314 | """ 315 | Calculate zonal distance at the Earth's surface in m from a 316 | longitude and latitude rectangular grid 317 | 318 | Input: 319 | lon [M,N]: Longitude in degrees 320 | lat [M,N]: Latitude in degrees 321 | 322 | Output: 323 | dx [M,N-1]: Distance in meters 324 | 325 | """ 326 | 327 | # Earth Radius in [m] 328 | earth_radius = 6371.0e3 329 | 330 | 331 | # Convert angles to radians 332 | lat = np.radians(lat) 333 | lon = np.radians(lon) 334 | 335 | # Zonal distance in radians 336 | dlon = np.diff(lon,axis=1) 337 | lat = (lat[:,1:]+lat[:,:-1])/2.0 338 | 339 | 340 | # Zonal distance arc 341 | a = np.cos(lat)*np.cos(lat)*(np.sin(dlon/2.0))**2.0 342 | angles = 2.0 * np.arctan2(np.sqrt(a), np.sqrt(1 - a)) 343 | 344 | # Distance in meters 345 | dx = earth_radius * angles 346 | 347 | return dx 348 | 349 | 350 | def dy_from_dlat(lat): 351 | """ 352 | Calculate meridional distance at the Earth's surface in m from a 353 | longitude and latitude rectangular grid 354 | 355 | Input: 356 | lat [M,N]: Latitude in degrees 357 | 358 | Output: 359 | dy [M-1,N]: Distance in meters 360 | 361 | """ 362 | 363 | ## Meridional resolution (m) 364 | dy = np.diff(lat,axis=0) 365 | dy = dy*111194.928 366 | 367 | return dy 368 | 369 | 370 | def psi_lietal(IPSI,IPHI,DX,DY,U,V,ZBC='closed',MBC='closed',ALPHA=1.0e-14): 371 | 372 | """ 373 | Compute streamfunction implementing Li et al. (2006) method. Its advantages consist in 374 | extract the non-divergent and non-rotational part of the flow without explicitly applying boundary 375 | conditions and computational efficiency. 376 | 377 | This method also minimizes the difference between the reconstructed and original velocity fields. Therefore 378 | it is ideal for large and non-regular domains with complex coastlines and islands. 379 | 380 | Streamfunction and velocity potential are staggered with the velocity components. 381 | 382 | Input: 383 | IPSI [M,N] : Streamfunction initial guess 384 | IPHI [M,N] : Velocity potential initial guess 385 | DX [M,N] : Zonal distance (i.e., resolution) 386 | DY [M,N] : Meridional distance (i.e., resolution) 387 | U [M-1,N-1] : Original zonal velocity field defined between PSI and PHI grid points 388 | V [M-1,N-1] : Original meridional velocity field defined between PSI and PHI grid points 389 | 390 | Optional Input: 391 | ZBC : Zonal Boundary Condition for domain edge (closed or periodic) 392 | MBC : Meridional Boundary Condition for domain edge (closed or periodic) 393 | ALPHA : Regularization parameter 394 | 395 | Output: 396 | psi [M,N] : Streamfunction 397 | phi [M,N] : Velocity Potential 398 | 399 | 400 | Obs1: PSI and PHI over land and boundaries have to be 0.0 401 | for better performance. However U and V can be masked with 402 | NaNs 403 | 404 | Obs2: Definitions 405 | 406 | U = dPsi/dy + dPhi/dx 407 | V = -dPsi/dx + dPhi/dy 408 | 409 | Obs3: BCs are applied only to the Jacobian of the 410 | minimization function and are referred to the edges 411 | of the rectangular domain. 412 | 413 | :Examples: 414 | M = 64 415 | N = 64 416 | IPSI = np.zeros((M, N)) 417 | IPHI = np.zeros((M, N)) 418 | DX = np.zeros((M, N)) + 2.5 419 | DY = np.zeros((M, N)) + 2.5 420 | U = np.random.rand(M-1, N-1) 421 | V = np.random.rand(M-1, N-1) 422 | psi,phi = psi_lietal(IPSI,IPHI,DX,DY,U,V) 423 | """ 424 | 425 | ## Reshape/Resize variables ("Vectorization") 426 | # Velocity 427 | M,N = U.shape 428 | 429 | y = np.ones(2*M*N)*np.nan 430 | 431 | # Velocity y = (U11,U12,...,U31,U32,....,V11,V12,....) 432 | y[:int(y.shape[0]/2)] = U.reshape(M*N) 433 | y[int(y.shape[0]/2):] = V.reshape(M*N) 434 | 435 | idata = ~np.isnan(y.copy()) 436 | y = y[idata] 437 | 438 | 439 | # Stream function and velocity potential 440 | M1,N1 = IPSI.shape 441 | 442 | x = np.ones(2*M1*N1)*np.nan 443 | 444 | # PSI and PHI vector: x = (PSI11,PSI12,...,PSI31,PSI32,...,PHI11,PHI12,...) 445 | x[:int(x.shape[0]/2)] = IPSI.reshape(M1*N1) 446 | x[int(x.shape[0]/2):] = IPHI.reshape(M1*N1) 447 | 448 | 449 | print(' Optimization process') 450 | t0 = time.clock() 451 | pq = optimize.minimize(ja,x,method='L-BFGS-B',jac=grad_ja, 452 | args=(y,DX,DY,M1,N1,idata,ZBC,MBC,ALPHA),options={'gtol': 1e-16}) 453 | 454 | t1 = time.clock() 455 | 456 | print(' Time for convergence: %1.2f min'%((t1-t0)/60.0)) 457 | print(' F(x): %1.2f'%(pq.fun)) 458 | 459 | psi = pq.x[:int(x.shape[0]/2)].reshape((M1,N1)) 460 | phi = pq.x[int(x.shape[0]/2):].reshape((M1,N1)) 461 | 462 | return psi,phi 463 | 464 | -------------------------------------------------------------------------------- /nmc_met_base/qpf_cal.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2021 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | import numpy as np 7 | import numba as nb 8 | 9 | 10 | def cum_freq(data, bins=None, norm=True): 11 | """Calculate the cumulative frequency distribution. 12 | 13 | refer to: 14 | Zhu, Y. and Y. Luo, 2015: Precipitation Calibration Based on the 15 | Frequency-Matching Method. Wea. Forecasting, 30, 1109鈥?124, 16 | https://doi.org/10.1175/WAF-D-13-00049.1 17 | 18 | Arguments: 19 | data {numpy nd-array} -- numpy nd-array. The missing value is allowed. 20 | 21 | Keyword Arguments: 22 | bins {numpy array} -- the bin-edegs used to calculate CFD. 23 | norm {bool} -- normalize the distribution (default: {True}) 24 | """ 25 | 26 | # set the bin edges 27 | if bins is None: 28 | bins = np.concatenate(([0.1, 1], np.arange(2, 10, 1), 29 | np.arange(10, 152, 2))) 30 | 31 | # mask the missing values and change negative to zero 32 | data = data[np.isfinite(data)] 33 | 34 | # calculate the cumulative frequency distribution 35 | cfd_array = np.full(bins.size, np.nan) 36 | for ib, b in enumerate(bins): 37 | cfd_array[ib] = np.count_nonzero(data <= b) * 1.0 38 | 39 | # normalize the distribution 40 | if norm: 41 | cfd_array /= data.size 42 | 43 | # return the bin edges and CFD 44 | return cfd_array, bins 45 | 46 | 47 | def cfd_match(data, cfd_obs, cfd_fcst, bins): 48 | """ 49 | Perform the frequency-matching methods. 50 | 51 | refer to: 52 | Zhu, Y. and Y. Luo, 2015: Precipitation Calibration Based on the 53 | Frequency-Matching Method. Wea. Forecasting, 30, 1109鈥?124, 54 | https://doi.org/10.1175/WAF-D-13-00049.1 55 | 56 | Args: 57 | data (np.array): forecast data to be calibrated. 58 | cdf_obs (np.array): 1D array, the cumulative frequency distribution of observations. 59 | cdf_fcst (np.array): 1D array, the cumulative frequency distribution of forecasts. 60 | bins (np.array): 1D array, bin edges for CFD. 61 | 62 | Returns: 63 | np.array: calibrated forecasts. 64 | """ 65 | 66 | # construct interpolation 67 | data = np.ravel(data) 68 | data_cfd = np.interp(data, bins, cfd_fcst) 69 | data_cal = np.interp(data_cfd, cfd_obs, bins) 70 | 71 | # deal with out-range points, just keep the values. 72 | data_cal[data > np.max(bins)] = data[data > np.max(bins)] 73 | data_cal[data < np.min(bins)] = data[data < np.min(bins)] 74 | return data_cal 75 | 76 | 77 | @nb.njit() 78 | def quantile_mapping_stencil(pred, cdf_pred, cdf_true, land_mask, rad=1): 79 | """ 80 | Quantile mapping with stencil grid points. 81 | 82 | Scheuerer, M. and Hamill, T.M., 2015. Statistical postprocessing of 83 | ensemble precipitation forecasts by fitting censored, shifted gamma distributions. 84 | Monthly Weather Review, 143(11), pp.4578-4596. 85 | 86 | Hamill, T.M., Engle, E., Myrick, D., Peroutka, M., Finan, C. and Scheuerer, M., 2017. 87 | The US National Blend of Models for statistical postprocessing of probability of precipitation 88 | and deterministic precipitation amount. Monthly Weather Review, 145(9), pp.3441-3463. 89 | 90 | refer to: 91 | https://github.com/yingkaisha/fcstpp/blob/main/fcstpp/gridpp.py 92 | 93 | Args: 94 | pred (np.array): ensemble forecasts. `shape=(ensemb_members, gridx, gridy)`. 95 | cdf_pred (np.array): quantile values of the forecast. `shape=(quantile_bins, gridx, gridy)` 96 | cdf_true (np.array): the same as `cdf_pred` for the analyzed condition. 97 | land_mask (np.array): boolean arrays with True for focused grid points (i.e., True for land grid point). 98 | `shape=(gridx, gridy)`. 99 | rad (int, optional): grid point radius of the stencil. `rad=1` means 3-by-3 stencils. 100 | 101 | Return: 102 | out: quantile mapped and enlarged forecast. `shape=(ensemble_members, folds, gridx, gridy)` 103 | e.g., 3-by-3 stencil yields nine-fold more mapped outputs. 104 | """ 105 | 106 | EN, Nx, Ny = pred.shape 107 | N_fold = (2*rad+1)**2 108 | out = np.empty((EN, N_fold, Nx, Ny,)) 109 | out[...] = np.nan 110 | 111 | for i in range(Nx): 112 | for j in range(Ny): 113 | # loop over grid points 114 | if land_mask[i, j]: 115 | min_x = np.max([i-rad, 0]) 116 | max_x = np.min([i+rad, Nx-1]) 117 | min_y = np.max([j-rad, 0]) 118 | max_y = np.min([j+rad, Ny-1]) 119 | 120 | count = 0 121 | for ix in range(min_x, max_x+1): 122 | for iy in range(min_y, max_y+1): 123 | if land_mask[ix, iy]: 124 | for en in range(EN): 125 | out[en, count, i, j] = np.interp( 126 | pred[en, i, j], cdf_pred[:, ix, iy], cdf_true[:, ix, iy]) 127 | count += 1 128 | return out -------------------------------------------------------------------------------- /nmc_met_base/regridding.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2019 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | """ 7 | Regridding from one grid to another. 8 | """ 9 | 10 | import numpy as np 11 | from numpy.lib.stride_tricks import as_strided 12 | from numba import jit 13 | import scipy.interpolate 14 | import scipy.ndimage 15 | 16 | 17 | def hinterp(data, x, y, xout, yout, grid=True, **kargs): 18 | """ 19 | Regridding multiple dimensions data. Interpolation occurs 20 | in the 2 rightest most indices of grid data. 21 | 22 | :param grid: input grid, multiple array. 23 | :param x: input grid x coordinates, 1D vector, must be increase order. 24 | :param y: input grid y coordinates, 1D vector, must be increase order. 25 | :param xout: output points x coordinates, 1D vector. 26 | :param yout: output points y coordinates, 1D vector. 27 | :param grid: output points is a grid. 28 | :param kargs: keyword arguments for np.interp. 29 | :return: interpolated grid. 30 | 31 | :Example: 32 | >>> data = np.arange(40).reshape(2,5,4) 33 | >>> x = np.linspace(0,9,4) 34 | >>> y = np.linspace(0,8,5) 35 | >>> xout = np.linspace(0,9,7) 36 | >>> yout = np.linspace(0,8,9) 37 | >>> odata = hinterp(data, x, y, xout, yout) 38 | 39 | """ 40 | 41 | # check grid 42 | if grid: 43 | xxout, yyout = np.meshgrid(xout, yout) 44 | else: 45 | xxout = xout 46 | yyout = yout 47 | 48 | # interpolated location 49 | xx = np.interp(xxout, x, np.arange(len(x), dtype=np.float), **kargs) 50 | yy = np.interp(yyout, y, np.arange(len(y), dtype=np.float), **kargs) 51 | 52 | # perform bilinear interpolation 53 | xx0 = np.floor(xx).astype(int) 54 | xx1 = xx0 + 1 55 | yy0 = np.floor(yy).astype(int) 56 | yy1 = yy0 + 1 57 | 58 | ixx0 = np.clip(xx0, 0, data.shape[-1] - 1) 59 | ixx1 = np.clip(xx1, 0, data.shape[-1] - 1) 60 | iyy0 = np.clip(yy0, 0, data.shape[-2] - 1) 61 | iyy1 = np.clip(yy1, 0, data.shape[-2] - 1) 62 | 63 | Ia = data[..., iyy0, ixx0] 64 | Ib = data[..., iyy1, ixx0] 65 | Ic = data[..., iyy0, ixx1] 66 | Id = data[..., iyy1, ixx1] 67 | 68 | wa = (xx1 - xx) * (yy1 - yy) 69 | wb = (xx1 - xx) * (yy - yy0) 70 | wc = (xx - xx0) * (yy1 - yy) 71 | wd = (xx - xx0) * (yy - yy0) 72 | 73 | return wa * Ia + wb * Ib + wc * Ic + wd * Id 74 | 75 | 76 | def rebin(a, factor, func=None): 77 | """Aggregate data from the input array ``a`` into rectangular tiles. 78 | 79 | The output array results from tiling ``a`` and applying `func` to 80 | each tile. ``factor`` specifies the size of the tiles. More 81 | precisely, the returned array ``out`` is such that:: 82 | 83 | out[i0, i1, ...] = func(a[f0*i0:f0*(i0+1), f1*i1:f1*(i1+1), ...]) 84 | 85 | If ``factor`` is an integer-like scalar, then 86 | ``f0 = f1 = ... = factor`` in the above formula. If ``factor`` is a 87 | sequence of integer-like scalars, then ``f0 = factor[0]``, 88 | ``f1 = factor[1]``, ... and the length of ``factor`` must equal the 89 | number of dimensions of ``a``. 90 | 91 | The reduction function ``func`` must accept an ``axis`` argument. 92 | Examples of such function are 93 | 94 | - ``numpy.mean`` (default), 95 | - ``numpy.sum``, 96 | - ``numpy.product``, 97 | - ... 98 | 99 | The following example shows how a (4, 6) array is reduced to a 100 | (2, 2) array 101 | 102 | >>> import numpy as np 103 | >>> a = np.arange(24).reshape(4, 6) 104 | >>> rebin(a, factor=(2, 3), func=np.sum) 105 | array([[ 24, 42], 106 | [ 96, 114]]) 107 | 108 | If the elements of `factor` are not integer multiples of the 109 | dimensions of `a`, the remaining cells are discarded. 110 | 111 | >>> rebin(a, factor=(2, 2), func=np.sum) 112 | array([[16, 24, 32], 113 | [72, 80, 88]]) 114 | 115 | """ 116 | 117 | a = np.asarray(a) 118 | dim = a.ndim 119 | if np.isscalar(factor): 120 | factor = dim*(factor,) 121 | elif len(factor) != dim: 122 | raise ValueError('length of factor must be {} (was {})' 123 | .format(dim, len(factor))) 124 | if func is None: 125 | func = np.mean 126 | for f in factor: 127 | if f != int(f): 128 | raise ValueError('factor must be an int or a tuple of ints ' 129 | '(got {})'.format(f)) 130 | 131 | new_shape = [n//f for n, f in zip(a.shape, factor)]+list(factor) 132 | new_strides = [s*f for s, f in zip(a.strides, factor)]+list(a.strides) 133 | aa = as_strided(a, shape=new_shape, strides=new_strides) 134 | return func(aa, axis=tuple(range(-dim, 0))) 135 | 136 | 137 | def congrid(a, newdims, method='linear', centre=False, minusone=False): 138 | """ 139 | Arbitrary resampling of source array to new dimension sizes. 140 | Currently only supports maintaining the same number of dimensions. 141 | To use 1-D arrays, first promote them to shape (x,1). 142 | 143 | Uses the same parameters and creates the same co-ordinate lookup points 144 | as IDL''s congrid routine, which apparently originally came from a VAX/VMS 145 | routine of the same name. 146 | 147 | http://scipy-cookbook.readthedocs.io/items/Rebinning.html 148 | 149 | :param a: 150 | :param newdims: 151 | :param method: neighbour - closest value from original data 152 | nearest and linear - uses n x 1-D interpolations using 153 | scipy.interpolate.interp1d 154 | (see Numerical Recipes for validity of 155 | use of n 1-D interpolations) 156 | spline - uses ndimage.map_coordinates 157 | :param centre: True - interpolation points are at the centres of the bins 158 | False - points are at the front edge of the bin 159 | :param minusone: 160 | For example- inarray.shape = (i,j) & new dimensions = (x,y) 161 | False - inarray is resampled by factors of (i/x) * (j/y) 162 | True - inarray is resampled by(i-1)/(x-1) * (j-1)/(y-1) 163 | This prevents extrapolation one element beyond bounds of input array. 164 | :return: 165 | """ 166 | 167 | if not (a.dtype in [np.float64, np.float32]): 168 | a = np.cast[float](a) 169 | 170 | m1 = np.cast[int](minusone) 171 | ofs = np.cast[int](centre) * 0.5 172 | old = np.array(a.shape) 173 | ndims = len(a.shape) 174 | if len(newdims) != ndims: 175 | print("[congrid] dimensions error. " 176 | "This routine currently only support " 177 | "rebinning to the same number of dimensions.") 178 | return None 179 | newdims = np.asarray(newdims, dtype=float) 180 | dimlist = [] 181 | 182 | if method == 'neighbour': 183 | for i in range(ndims): 184 | base = np.indices(newdims)[i] 185 | dimlist.append( 186 | (old[i] - m1) / (newdims[i] - m1) * (base + ofs) - ofs) 187 | cd = np.array(dimlist).round().astype(int) 188 | newa = a[list(cd)] 189 | return newa 190 | 191 | elif method in ['nearest', 'linear']: 192 | # calculate new dims 193 | for i in range(ndims): 194 | base = np.arange(newdims[i]) 195 | dimlist.append( 196 | (old[i] - m1) / (newdims[i] - m1) * (base + ofs) - ofs) 197 | # specify old dims 198 | olddims = [np.arange(i, dtype=np.float) for i in list(a.shape)] 199 | 200 | # first interpolation - for ndims = any 201 | mint = scipy.interpolate.interp1d(olddims[-1], a, kind=method) 202 | newa = mint(dimlist[-1]) 203 | 204 | trorder = [ndims - 1] + np.arange(ndims - 1) 205 | for i in range(ndims - 2, -1, -1): 206 | newa = newa.transpose(trorder) 207 | 208 | mint = scipy.interpolate.interp1d(olddims[i], newa, kind=method) 209 | newa = mint(dimlist[i]) 210 | 211 | if ndims > 1: 212 | # need one more transpose to return to original dimensions 213 | newa = newa.transpose(trorder) 214 | 215 | return newa 216 | elif method in ['spline']: 217 | nslices = [slice(0, j) for j in list(newdims)] 218 | newcoords = np.mgrid[nslices] 219 | 220 | newcoords_dims = np.arange(np.rank(newcoords)) 221 | # make first index last 222 | newcoords_dims.append(newcoords_dims.pop(0)) 223 | newcoords_tr = newcoords.transpose(newcoords_dims) 224 | # makes a view that affects newcoords 225 | newcoords_tr += ofs 226 | 227 | deltas = (np.asarray(old) - m1) / (newdims - m1) 228 | newcoords_tr *= deltas 229 | 230 | newcoords_tr -= ofs 231 | 232 | newa = scipy.ndimage.map_coordinates(a, newcoords) 233 | return newa 234 | else: 235 | print("Congrid error: Unrecognized interpolation type.\n", 236 | "Currently only \'neighbour\', \'nearest\',\'linear\',", 237 | "and \'spline\' are supported.") 238 | return None 239 | 240 | 241 | @jit 242 | def box_average(field, lon, lat, olon, olat, width=None, rm_nan=False): 243 | """ 244 | Remap high resolution field to coarse with box_average. 245 | Accelerated by numba, but slower than rebin. 246 | 247 | :param field: 2D array (nlat, nlon) for input high resolution. 248 | :param lon: 1D array, field longitude coordinates. 249 | :param lat: 1D array, field latitude coordinates. 250 | :param olon: 1D array, out coarse field longitude coordinates. 251 | :param olat: 1D array, out coarse field latitude coordinates. 252 | :param width: box width. 253 | :param rm_nan: remove nan values when calculate average. 254 | :return: 2D array (nlat1, nlon1) for coarse field. 255 | """ 256 | 257 | # check box width 258 | if width is None: 259 | width = (olon[1] - olon[0]) / 2.0 260 | 261 | # define output grid 262 | out_field = np.full((olat.size, olon.size), np.nan) 263 | 264 | # loop every grid point 265 | for j in np.arange(olat.size): 266 | for i in np.arange(olon.size): 267 | # searchsorted is fast. 268 | lon_min = np.searchsorted(lon, olon[i] - width) 269 | lon_max = np.searchsorted(lon, olon[i] + width) + 1 270 | lat_min = np.searchsorted(lat, olat[j] - width) 271 | lat_max = np.searchsorted(lat, olat[j] + width) + 1 272 | temp = field[lat_min:lat_max, lon_min:lon_max] 273 | if rm_nan: 274 | if np.all(np.isnan(temp)): 275 | out_field[j, i] = np.nan 276 | else: 277 | temp = temp[~np.isnan(temp)] 278 | out_field[j, i] = np.mean(temp) 279 | else: 280 | out_field[j, i] = np.mean(temp) 281 | 282 | # return 283 | return out_field 284 | 285 | 286 | @jit 287 | def box_max_avg(field, lon, lat, olon, olat, 288 | width=None, number=1, rm_nan=False): 289 | """ 290 | Remap high resolution field to coarse with box_max_avg. 291 | Same as box_avg, but average the "number" hightest values. 292 | Accelerated by numba, but slower than rebin. 293 | 294 | :param field: 2D array (nlat, nlon) for input high resolution. 295 | :param lon: 1D array, field longitude coordinates. 296 | :param lat: 1D array, field latitude coordinates. 297 | :param olon: 1D array, out coarse field longitude coordinates. 298 | :param olat: 1D array, out coarse field latitude coordinates. 299 | :param width: box width. 300 | :param number: select the number of largest value. 301 | :param rm_nan: remove nan values when calculate average. 302 | :return: 2D array (nlat1, nlon1) for coarse field. 303 | """ 304 | 305 | # check box width 306 | if width is None: 307 | width = (olon[1] - olon[0]) / 2.0 308 | 309 | # define output grid 310 | out_field = np.full((olat.size, olon.size), np.nan) 311 | 312 | # loop every grid point 313 | for j in np.arange(olat.size): 314 | for i in np.arange(olon.size): 315 | # searchsorted is fast. 316 | lon_min = np.searchsorted(lon, olon[i] - width) 317 | lon_max = np.searchsorted(lon, olon[i] + width) 318 | lat_min = np.searchsorted(lat, olat[j] - width) 319 | lat_max = np.searchsorted(lat, olat[j] + width) 320 | temp = field[lat_min:lat_max, lon_min:lon_max] 321 | if rm_nan: 322 | if np.all(np.isnan(temp)): 323 | out_field[j, i] = np.nan 324 | else: 325 | temp = temp[~np.isnan(temp)] 326 | if temp.size < number: 327 | out_field[j, i] = np.mean(temp) 328 | else: 329 | out_field[j, i] = np.mean(np.sort(temp)[-number:]) 330 | else: 331 | if temp.size < number: 332 | out_field[j, i] = np.mean(temp) 333 | else: 334 | out_field[j, i] = np.mean(np.sort(temp)[-number:]) 335 | 336 | # return 337 | return out_field 338 | -------------------------------------------------------------------------------- /nmc_met_base/sompy.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2021 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | """ 7 | A structural self-organizing map algorithm for weather typing 8 | refer to: 9 | https://zenodo.org/record/4437954#.YofDnajP3up 10 | """ 11 | 12 | """ 13 | Created on Fri May 8 20:52:27 2020 14 | @author: doan 15 | """ 16 | 17 | import os, sys 18 | import numpy as np 19 | from numpy import random as rand 20 | import xarray as xr 21 | 22 | 23 | #============================================================================== 24 | def strsim(x,y): 25 | ''' 26 | Parameters 27 | ---------- 28 | x : input vector 1 (float) 29 | For comparison 30 | y : input vector 2 (float) 31 | For comparison 32 | Returns 33 | ------- 34 | Float 35 | Structural similarity index (-1 to 1) 36 | ''' 37 | term1 = 2*x.mean()*y.mean() / (x.mean()**2 + y.mean()**2) 38 | term2 = 2*np.cov(x.flatten(),y.flatten())[1,0] / (np.var(x)+np.var(y)) 39 | return term1*term2 40 | #============================================================================== 41 | 42 | #============================================================================== 43 | def strcnt(x,y): 44 | ''' 45 | Parameters 46 | ---------- 47 | x : input vector 1 (float) 48 | For comparison 49 | y : input vector 2 (float) 50 | For comparison 51 | Returns 52 | ------- 53 | Float 54 | Structural similarity index (-1 to 1) 55 | ''' 56 | term1 = 1. #2*x.mean()*y.mean() / (x.mean()**2 + y.mean()**2) 57 | term2 = 2*np.cov(x.flatten(),y.flatten())[1,0] / (np.var(x)+np.var(y)) 58 | return term1*term2 59 | #============================================================================== 60 | 61 | #============================================================================== 62 | def S_luminance(x,y): return 2*x.mean()*y.mean() / (x.mean()**2 + y.mean()**2) 63 | #============================================================================== 64 | #============================================================================== 65 | def S_contrast(x,y): return 2* np.std(x) * np.std(y) / (np.var(x)+np.var(y)) 66 | #============================================================================== 67 | #============================================================================== 68 | def S_structure(x,y): return np.cov(x.flatten(),y.flatten())[1,0] / (np.std(x) * np.std(y)) 69 | #============================================================================== 70 | #============================================================================== 71 | def edsim(x,y): return -np.linalg.norm(x - y) 72 | #============================================================================== 73 | 74 | sim_func = {'ssim':strsim, 75 | 'ed': edsim, 76 | 'lum':S_luminance, 77 | 'cnt':S_contrast, 78 | 'str':S_structure, 79 | 'sc':strcnt} 80 | 81 | #============================================================================== 82 | def bmu1d(sample,candidate,method='ed'): 83 | ''' 84 | Parameters 85 | ---------- 86 | sample : TYPE 87 | DESCRIPTION. 88 | candidate : TYPE 89 | DESCRIPTION. 90 | method : TYPE, optional 91 | DESCRIPTION. The default is 'ed'. 92 | 93 | Returns 94 | ------- 95 | bmu_1d : TYPE 96 | DESCRIPTION. 97 | smu_1d : TYPE 98 | DESCRIPTION. 99 | maxv : TYPE 100 | DESCRIPTION. 101 | values : TYPE 102 | DESCRIPTION. 103 | 104 | ''' 105 | if method == 'ssim': 106 | values = [] 107 | x = sample 108 | for y in candidate[:]: 109 | term1 = 2*x.mean()*y.mean() / (x.mean()**2 + y.mean()**2) 110 | term2 = 2*np.cov(x.flatten(),y.flatten())[1,0] / (np.var(x)+np.var(y)) 111 | values.append(term1*term2) 112 | values = np.array(values) 113 | 114 | #==================== 115 | if method == 'sc': 116 | values = [] 117 | x = sample 118 | for y in candidate[:]: 119 | term1 = 1. #2*x.mean()*y.mean() / (x.mean()**2 + y.mean()**2) 120 | term2 = 2*np.cov(x.flatten(),y.flatten())[1,0] / (np.var(x)+np.var(y)) 121 | values.append(term1*term2) 122 | values = np.array(values) 123 | #==================== 124 | 125 | if method == 'ed': 126 | sub = candidate - sample 127 | values = - np.linalg.norm(sub, axis=1) 128 | 129 | if method in ['lum', 'cnt', 'str']: 130 | values = [] 131 | x = sample 132 | for y in candidate[:]: 133 | values.append( sim_func[method](x.flatten(),y.flatten()) ) 134 | values = np.array(values) 135 | 136 | 137 | maxv = np.max(values) 138 | bmu_1d, smu_1d = np.argsort(values)[-1], np.argsort(values)[-2] 139 | #print(values) 140 | return bmu_1d, smu_1d, maxv, values 141 | #============================================================================== 142 | 143 | 144 | #============================================================================== 145 | def som(input2d,n=10,iterate=5000,learnrate=0.1,sim='ed'): 146 | ''' 147 | Parameters 148 | ---------- 149 | input2d : float 150 | 2-dim array, first dimensional represents input samples. 151 | n : interger 152 | number of SOM nodes, 153 | The default is 10. 154 | iterate : integer, optional 155 | Number of iterations. The default is 5000. 156 | learnrate : float, optional 157 | Initial learning rate. The default is 0.1. 158 | sim : string, optional 159 | Similarity index (two options: 'ed' is nagative ED; 'ssim' is strurural similarity index.) 160 | The default is 'ed'. 161 | 162 | Returns 163 | ------- 164 | ovar : dictionary 165 | DESCRIPTION. 166 | 167 | ''' 168 | # STEP 1: 169 | 170 | # Check input data and setup SOM output map 171 | if len(input2d.shape) !=2: 172 | print('*** Error: the input data for SOM should have 2 dimensions:\n the 1st is data piece; 2st is vector weights') 173 | sys.exit() 174 | 175 | # Set SOM arrays 176 | output2d = np.random.uniform(low=input2d.min(), high=input2d.max(), size=[n, input2d.shape[-1]] ) 177 | index_map = np.arange(n) 178 | 179 | lambda_lr, max_lr, lambda_nb = 0.25, learnrate, 0.5 180 | print('** SOM: ', 'size: ', n, 'sim: ', sim, 'iteration: ', iterate) 181 | #------------------------- 182 | life = iterate * lambda_lr 183 | initial = n*lambda_nb 184 | 185 | Lr = np.zeros(iterate) # leaning rate (each step) 186 | Nbh = np.zeros(iterate) # Neiborhood function (each step) 187 | Bmu_2a = np.zeros( (iterate) ) # best matching unit (each step) 188 | 189 | # define output ovar 190 | ovar = {var: [] for var in ['som_hist','learnrate', 'NbhF', 'ivector', 'bmu', 'bmu_proj']} 191 | 192 | # STEP 2: learning 193 | 194 | for i in range(iterate): 195 | # STEP 2-1: select input vector (randomly) 196 | ind = rand.randint(0, input2d.shape[0]) 197 | data = input2d[ind] 198 | 199 | # STEP 2-2: find best matching unit 200 | bmu_1d, _, _, _ = bmu1d(data,output2d,method=sim) 201 | 202 | Bmu_2a[i] = bmu_1d # save best matching unit 203 | 204 | # Step 2-3: Update learning rate and neighborhood function 205 | dis = index_map - bmu_1d 206 | Lr[i] = max_lr * np.exp(-i/life) 207 | Nbh[i] = initial*np.exp(-i/life) 208 | S = np.exp(-dis**2 / (2*Nbh[i]**2)) 209 | 210 | # Step 2-4: Update SOM map 211 | output2d = output2d + Lr[i]* S[:,np.newaxis] * (data - output2d) 212 | 213 | # Step 2-5: Save to output var 214 | if np.mod(i,int(iterate/100)) == 0: 215 | 216 | print('SOM with:', sim, '; step: ', i) 217 | 218 | ovar['som_hist'].append(output2d) # save results 219 | ovar['learnrate'].append(Lr[i]) # save to learning rate 220 | ovar['ivector'].append(ind) # save index of input vector 221 | ovar['bmu'].append(bmu_1d) # save best matching unit 222 | ovar['NbhF'].append(S) # save neighborhood function 223 | 224 | 225 | # Step 3: Output 226 | ovar['som'] = output2d # save final som map result 227 | ovar['bmu_proj_fin'] = [bmu1d(data,output2d,method=sim)[0] for ind, data in enumerate(input2d)] # best matching unit at final map for each input vector 228 | ovar['smu_proj_fin'] = [bmu1d(data,output2d,method=sim)[1] for ind, data in enumerate(input2d)] # second matching unit at final map for each input vector 229 | ovar['similarity_index'] = sim # save type of similarity index used 230 | 231 | dat = output2d 232 | if sim == 'ssim': 233 | values = [ strsim( d1, d2 ) for d1 in dat for d2 in dat ] 234 | if sim == 'sc': 235 | values = [ strcnt( d1, d2 ) for d1 in dat for d2 in dat ] 236 | if sim == 'ed': 237 | values = [ - np.linalg.norm(d1 - d2) for d1 in dat for d2 in dat ] 238 | if sim in ['lum', 'cnt', 'str']: 239 | values = [ sim_func[sim](d1, d2) for d1 in dat for d2 in dat ] 240 | 241 | # save betweenness similarity values (optional) 242 | ovar['sim_btw'] = np.array(values).reshape(len(dat),len(dat)) #[np.triu_indices(len(dat),k=1)] 243 | 244 | # save topological error 245 | terror = np.abs(np.array(ovar['bmu_proj_fin']) - np.array(ovar['smu_proj_fin'])).mean() 246 | ovar['topo_error'] = terror 247 | 248 | 249 | return ovar 250 | #============================================================================== 251 | -------------------------------------------------------------------------------- /nmc_met_base/spectra.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2021 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | """ 7 | This module contains: 8 | - a class to handle variance spectrum; 9 | - a function to compute DCT spectrum from a 2D field; 10 | - a function to plot a series of spectra. 11 | 12 | refer to: 13 | https://journals.ametsoc.org/view/journals/mwre/130/7/1520-0493_2002_130_1812_sdotda_2.0.co_2.xml 14 | https://journals.ametsoc.org/view/journals/mwre/145/9/mwr-d-17-0056.1.xml 15 | http://www.umr-cnrm.fr/gmapdoc/meshtml/EPYGRAM1.4.3/_modules/epygram/spectra.html 16 | http://www.umr-cnrm.fr/gmapdoc/meshtml/EPYGRAM1.4.3/gallery/notebooks/spectral_filtering_and_spectra.html 17 | https://bertvandenbroucke.netlify.app/2019/05/24/computing-a-power-spectrum-in-python/ 18 | 19 | Examples: 20 | variance = dctspectrum(precipitation) # precipitation is a 2D np.array 21 | spectra = Spectrum(variance, name="5km Precipitation", resolution=5) 22 | print(spectra.wavenumbers) 23 | print(spectra.wavelengths) 24 | plotspectra(spectra) 25 | """ 26 | 27 | 28 | import numpy as np 29 | import scipy.fftpack as tfm 30 | 31 | 32 | class Spectrum(object): 33 | """ 34 | A spectrum can be seen as a quantification of a signal's variance with 35 | regards to scale. 36 | If the signal is defined in physical space on N points, its spectral 37 | representation will be a squared mean value (wavenumber 0) and variances for 38 | N-1 wavenumbers. 39 | 40 | For details and documentation, see 41 | Denis et al. (2002) : 'Spectral Decomposition of Two-Dimensional 42 | Atmospheric Fields on Limited-Area Domains 43 | Using the Discrete Cosine Transform (DCT)' 44 | """ 45 | 46 | def __init__(self, variances, resolution=5, name="Spectrum"): 47 | """ 48 | :param variances: variances of the spectrum, from wavenumber 1 to N-1. 49 | :param name: an optional name for the spectrum. 50 | :param resolution: an optional resolution for the field represented by 51 | the spectrum. It is used to compute the according 52 | wavelengths. Resolution unit is arbitrary, to 53 | the will of the user. 54 | """ 55 | self.variances = np.array(variances) 56 | self.name = name 57 | self.resolution = resolution 58 | 59 | @property 60 | def wavenumbers(self): 61 | """Gets the wavenumbers of the spectrum.""" 62 | return np.arange(1, len(self.variances) + 1) 63 | 64 | @property 65 | def wavelengths(self): 66 | """Gets the wavelengths of the spectrum.""" 67 | K = len(self.variances) + 1 68 | return np.array([2. * self.resolution * K / k 69 | for k in self.wavenumbers]) 70 | 71 | 72 | def dctspectrum(x, verbose=False): 73 | """ 74 | Function *dctspectrum* takes a 2D-array as argument and returns its 1D 75 | DCT ellipse spectrum. 76 | 77 | For details and documentation, see 78 | Denis et al. (2002) : 'Spectral Decomposition of Two-Dimensional 79 | Atmospheric Fields on Limited-Area Domains Using 80 | the Discrete Cosine Transform (DCT).' 81 | 82 | :param x: a 2D-array 83 | :param verbose: verbose mode 84 | """ 85 | 86 | # compute transform 87 | if verbose: 88 | print("dctspectrum: compute DCT transform...") 89 | norm = 'ortho' # None 90 | y = tfm.dct(tfm.dct(x, norm=norm, axis=0), norm=norm, axis=1) 91 | 92 | # compute spectrum 93 | if verbose: 94 | print("dctspectrum: compute variance spectrum...") 95 | N, M = y.shape 96 | N2 = N ** 2 97 | M2 = M ** 2 98 | MN = M * N 99 | K = min(M, N) 100 | variance = np.zeros(K) # variances of the spectrum, from wavenumber 1 to N-1. 101 | variance[0] = y[0, 0] ** 2 / MN 102 | for j in range(0, N): 103 | j2 = float(j) ** 2 104 | for i in range(0, M): 105 | var = y[j, i] ** 2 / MN 106 | k = np.sqrt(float(i) ** 2 / M2 + j2 / N2) * K 107 | k_inf = int(np.floor(k)) 108 | k_sup = k_inf + 1 109 | weightsup = k - k_inf 110 | weightinf = 1.0 - weightsup 111 | if 0 <= k < 1: 112 | variance[1] += weightsup * var 113 | if 1 <= k < K - 1: 114 | variance[k_inf] += weightinf * var 115 | variance[k_sup] += weightsup * var 116 | if K - 1 <= k < K: 117 | variance[k_inf] += weightinf * var 118 | 119 | return variance 120 | 121 | 122 | def plotspectra(spectra_in, 123 | slopes=[{'exp':-3, 'offset':1, 'label':'-3'}, 124 | {'exp':-5. / 3., 'offset':1, 'label':'-5/3'}], 125 | zoom=None, unit='SI', title=None, figsize=None): 126 | """ 127 | To plot a series of spectra. 128 | 129 | :param spectra: a Spectrum instance or a list of. 130 | :param unit: string accepting LaTeX-mathematical syntaxes 131 | :param slopes: list of dict( 132 | - exp=x where x is exposant of a A*k**-x slope 133 | - offset=A where A is logscale offset in a A*k**-x slope; 134 | a offset=1 is fitted to intercept the first spectra at wavenumber = 2 135 | - label=(optional label) appearing 'k = label' in legend) 136 | :param zoom: dict(xmin=,xmax=,ymin=,ymax=) 137 | :param title: title for the plot 138 | :param figsize: figure sizes in inches, e.g. (5, 8.5). 139 | Default figsize is config.plotsizes. 140 | """ 141 | import matplotlib.pyplot as plt 142 | 143 | plt.rc('font', family='serif') 144 | if figsize is None: 145 | figsize = (14,12) 146 | fig, ax = plt.subplots(1, 1, figsize=figsize) 147 | 148 | if isinstance(spectra_in, Spectrum): 149 | spectra = [spectra_in] 150 | 151 | # prepare dimensions 152 | window = dict() 153 | window['ymin'] = min([min(s.variances) for s in spectra]) / 10 154 | window['ymax'] = max([max(s.variances) for s in spectra]) * 10 155 | window['xmax'] = max([max(s.wavelengths) for s in spectra]) * 1.5 156 | window['xmin'] = min([min(s.wavelengths) for s in spectra]) * 0.8 157 | if zoom is not None: 158 | for k, v in zoom.items(): 159 | window[k] = v 160 | x1 = window['xmax'] 161 | x2 = window['xmin'] 162 | 163 | # colors and linestyles 164 | colors = ['red', 'blue', 'green', 'orange', 'magenta', 'darkolivegreen', 165 | 'yellow', 'salmon', 'black'] 166 | linestyles = ['-', '--', '-.', ':'] 167 | 168 | # axes 169 | if title is not None : 170 | ax.set_title(title) 171 | ax.set_yscale('log') 172 | ax.set_ylim(window['ymin'], window['ymax']) 173 | ax.set_xscale('log') 174 | ax.set_xlim(window['xmax'], window['xmin']) 175 | ax.grid() 176 | ax.set_xlabel('Wavelength ($km$)') 177 | ax.set_ylabel(r'Variance Spectrum ($' + unit + '$)') 178 | 179 | # plot slopes 180 | # we take the second wavenumber (of first spectrum) as intercept, because 181 | # it is often better fitted with spectrum than the first one 182 | x_intercept = spectra[0].wavelengths[1] 183 | y_intercept = spectra[0].variances[1] 184 | i = 0 185 | for slope in slopes: 186 | # a slope is defined by y = A * k**-s and we plot it with 187 | # two points y1, y2 188 | try: 189 | label = slope['label'] 190 | except KeyError: 191 | # label = str(Fraction(slope['exp']).limit_denominator(10)) 192 | label = str(slope['exp']) 193 | # because we plot w/r to wavelength, as opposed to wavenumber 194 | s = -slope['exp'] 195 | A = y_intercept * x_intercept ** (-s) * slope['offset'] 196 | y1 = A * x1 ** s 197 | y2 = A * x2 ** s 198 | ax.plot([x1, x2], [y1, y2], color='0.7', 199 | linestyle=linestyles[i % len(linestyles)], 200 | label=r'$k^{' + label + '}$') 201 | i += 1 202 | 203 | # plot spectra 204 | i = 0 205 | for s in spectra: 206 | ax.plot(s.wavelengths, s.variances, color=colors[i % len(colors)], 207 | linestyle=linestyles[i // len(colors)], label=s.name) 208 | i += 1 209 | 210 | # legend 211 | legend = ax.legend(loc='lower left', shadow=True) 212 | for label in legend.get_texts(): 213 | label.set_fontsize('medium') 214 | 215 | return (fig, ax) 216 | -------------------------------------------------------------------------------- /nmc_met_base/stats.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2019 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | """ 7 | Statistic functions. 8 | """ 9 | 10 | import numpy as np 11 | 12 | 13 | def edf(data, alpha=0.05, x0=None, x1=None, n=100): 14 | """ 15 | Estimating empirical cumulative density functions (CDFs) and 16 | their confidence intervals from data. 17 | 18 | refer to https://james-brennan.github.io/posts/edf/ 19 | 20 | Args: 21 | data (numpy.array): numpy data array. 22 | alpha (float, optional): [description]. Defaults to 0.05. 23 | x0 ([type], optional): [description]. Defaults to None. 24 | x1 ([type], optional): [description]. Defaults to None. 25 | n (int, optional): number of estimating points. 26 | 27 | Examples: 28 | import scipy.stats 29 | import matplotlib.pyplot as plt 30 | import seaborn as sns 31 | 32 | data = scipy.stats.gamma(5,1).rvs(200) 33 | x, y, l, u = edf(data, alpha=0.05) 34 | plt.fill_between(x, l, u) 35 | plt.plot(x, y, 'k-') 36 | plt.title("Empirical distribution function - $\hat{F}(x)$") 37 | plt.xlabel("$x$") 38 | plt.ylabel("Density") 39 | plt.plot(data, [0.01]*len(data), '|', color='k') 40 | sns.despine() 41 | """ 42 | 43 | # set edf range 44 | x0 = data.min() if x0 is None else x0 45 | x1 = data.max() if x1 is None else x1 46 | 47 | # set estimating points 48 | x = np.linspace(x0, x1, n) # estimating points 49 | 50 | # prepare estimating parameters 51 | N = data.size 52 | y = np.zeros_like(x) # edf values 53 | l = np.zeros_like(x) # lower confidence interval 54 | u = np.zeros_like(x) # upper confidence interval 55 | 56 | # The Dvoretzky–Kiefer–Wolfowitz (DKW) inequality provides a method to 57 | # a non-parametric upper and lower confidence interval 58 | e = np.sqrt(1.0/(2*N) * np.log(2./alpha)) 59 | 60 | # calculation 61 | for i, xx in enumerate(x): 62 | y[i] = np.sum(data <= xx)/N 63 | l[i] = np.maximum( y[i] - e, 0 ) 64 | u[i] = np.minimum( y[i] + e, 1 ) 65 | return x, y, l, u 66 | 67 | 68 | def vcorrcoef(X, y, dim): 69 | """ 70 | Compute vectorized correlation coefficient. 71 | refer to: 72 | https://waterprogramming.wordpress.com/2014/06/13/numpy-vectorized-correlation-coefficient/ 73 | 74 | :param X: nD array. 75 | :param y: 1D array. 76 | :param dim: along dimension to compute correlation coefficient. 77 | :return: correlation coefficient array. 78 | """ 79 | 80 | X = np.array(X) 81 | ndim = X.ndim 82 | 83 | # roll lat dim axis to last 84 | X = np.rollaxis(X, dim, ndim) 85 | 86 | Xm = np.mean(X, axis=-1, keepdims=True) 87 | ym = np.mean(y) 88 | r_num = np.sum((X - Xm) * (y - ym), axis=-1) 89 | r_den = np.sqrt(np.sum((X - Xm) ** 2, axis=-1) * np.sum((y - ym) ** 2)) 90 | r = r_num / r_den 91 | 92 | return r 93 | 94 | 95 | def lowess(x, y, f=1./3.): 96 | """ 97 | Basic LOWESS smoother with uncertainty. 98 | Note: 99 | - Not robust (so no iteration) and 100 | only normally distributed errors. 101 | - No higher order polynomials d=1 102 | so linear smoother. 103 | 104 | refer to: https://james-brennan.github.io/posts/lowess_conf/ 105 | 106 | Args: 107 | x ([type]): [description] 108 | y ([type]): [description] 109 | f ([type], optional): [description]. Defaults to 1./3. 110 | 111 | Examples: 112 | x = 5*np.random.random(100) 113 | y = np.sin(x) * 3*np.exp(-x) + np.random.normal(0, 0.2, 100) 114 | 115 | #run it 116 | y_sm, y_std = lowess(x, y, f=1./5.) 117 | # plot it 118 | plt.plot(x[order], y_sm[order], color='tomato', label='LOWESS') 119 | plt.fill_between(x[order], y_sm[order] - y_std[order], 120 | y_sm[order] + y_std[order], alpha=0.3, label='LOWESS uncertainty') 121 | plt.plot(x, y, 'k.', label='Observations') 122 | plt.legend(loc='best') 123 | """ 124 | 125 | # get some paras 126 | xwidth = f*(x.max()-x.min()) 127 | 128 | # effective width after reduction factor 129 | N = len(x) # number of obs 130 | 131 | # Don't assume the data is sorted 132 | order = np.argsort(x) 133 | 134 | # storage 135 | y_sm = np.zeros_like(y) 136 | y_stderr = np.zeros_like(y) 137 | 138 | # define the weigthing function -- clipping too! 139 | tricube = lambda d : np.clip((1- np.abs(d)**3)**3, 0, 1) 140 | 141 | # run the regression for each observation i 142 | for i in range(N): 143 | dist = np.abs((x[order][i]-x[order]))/xwidth 144 | w = tricube(dist) 145 | # form linear system with the weights 146 | A = np.stack([w, x[order]*w]).T 147 | b = w * y[order] 148 | ATA = A.T.dot(A) 149 | ATb = A.T.dot(b) 150 | # solve the syste 151 | sol = np.linalg.solve(ATA, ATb) 152 | # predict for the observation only 153 | yest = A[i].dot(sol)# equiv of A.dot(yest) just for k 154 | place = order[i] 155 | y_sm[place]=yest 156 | sigma2 = (np.sum((A.dot(sol) -y [order])**2)/N ) 157 | # Calculate the standard error 158 | y_stderr[place] = np.sqrt(sigma2 * A[i].dot(np.linalg.inv(ATA)).dot(A[i])) 159 | 160 | return y_sm, y_stderr 161 | -------------------------------------------------------------------------------- /nmc_met_base/time.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2019 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | """ 7 | Date and time manipulate functions. 8 | """ 9 | 10 | import calendar 11 | import datetime as dt 12 | import pandas as pd 13 | from dateutil.relativedelta import relativedelta 14 | 15 | 16 | __months__ = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 17 | 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'] 18 | 19 | 20 | def datetime_range(start, end, delta): 21 | """ 22 | Generate a list of datetimes between an interval. 23 | https://stackoverflow.com/questions/10688006/generate-a-list-of-datetimes-between-an-interval 24 | 25 | :param start: start date time. 26 | :param end: end date time. 27 | :param delta: time delta. 28 | :return: datetime list. 29 | 30 | :example 31 | >>> start = dt.datetime(2015,1,1) 32 | >>> end = dt.datetime(2015,1,31) 33 | >>> for dt in datetime_range(start, end, {'days': 2, 'hours':12}): 34 | ... print(dt) 35 | """ 36 | current = start 37 | if not isinstance(delta, dt.timedelta): 38 | delta = dt.timedelta(**delta) 39 | result = [] 40 | while current <= end: 41 | result.append(current) 42 | current += delta 43 | return result 44 | 45 | 46 | def get_same_date_range(date_center, 47 | years=[1991+i for i in range(30)], 48 | period=15, freq="1D"): 49 | """ 50 | Generate date series for the same period of each year. 51 | 52 | Args: 53 | date_center (list): the central date, [month, day] 54 | years (list): year list, defaults to [1991+i for i in range(30)]. 55 | period (int, optional): date range length. Defaults to 15. 56 | freq (str, optional): date frequency. Defaults to "1D". 57 | 58 | Returns: 59 | the same period date series, [date_center-int(period/2), date_center+int(period/2)] 60 | """ 61 | 62 | times = pd.to_datetime([]) 63 | 64 | for year in years: 65 | start = dt.datetime(year, date_center[0], date_center[1]) - dt.timedelta(days=int(period/2)) 66 | times = times.append(pd.date_range(start=start, periods=period, freq=freq)) 67 | 68 | return times 69 | 70 | 71 | def get_same_date(mon_day, years=[1991+i for i in range(30)]): 72 | """ 73 | Generate the same date for each year. If mon_day = [2,29] 74 | in not leap year, just set empty item. 75 | 76 | Args: 77 | mon_day (list): the [month, day] list 78 | years (list, optional): year list, defaults to [1991+i for i in range(30)]. 79 | """ 80 | 81 | dates = [] 82 | for year in years: 83 | if mon_day == [2, 29]: 84 | if not calendar.isleap(year): 85 | continue 86 | dates.append(dt.datetime(year, mon_day[0], mon_day[1])) 87 | 88 | return dates 89 | 90 | 91 | def extract_same_date(data, middle_date=None, period=7, var_time="Datetime"): 92 | """ 93 | 该程序用于从多年的时间序列数据中获得历史同期的数据. 94 | 95 | Args: 96 | data (pandas dataframe): pandas数据表格, 其中一列为时间序列的时间戳. 97 | middle_date (date object, optional): 中间时间日期. Defaults to None. 98 | period (int, optional): 时间窗口半径. Defaults to 7, 表示在middle_date前后7天之内. 99 | """ 100 | 101 | if middle_date is None: 102 | middle_date = dt.date.today() 103 | 104 | same_dates = pd.date_range(start=middle_date-pd.Timedelta(period, unit='day'), 105 | periods=period*2+1) 106 | same_dates = same_dates.strftime('%m-%d').to_list() 107 | data['Date'] = data[var_time].dt.strftime("%m-%d") 108 | data = data.loc[data['Date'].isin(same_dates), :] 109 | data.drop('Date', axis=1, inplace=True) 110 | 111 | return data 112 | 113 | 114 | def d2s(d, fmt='%HZ%d%b%Y'): 115 | """ 116 | Convert datetime to grads time string. 117 | https://bitbucket.org/tmiyachi/pymet/src/8df8e3ff2f899d625939448d7e96755dfa535357/pymet/tools.py 118 | 119 | :param d: datetime object 120 | :param fmt: datetime format 121 | :return: string 122 | 123 | :Examples: 124 | >>> d2s(datetime(2009,10,13,12)) 125 | '12Z13OCT2009' 126 | >>> d2s(datetime(2009,10,13,12), fmt='%H:%MZ:%d%b%Y') 127 | '12:00Z13OCT2009' 128 | """ 129 | 130 | fmt = fmt.replace('%b', __months__[d.month - 1]) 131 | if d.year < 1900: 132 | fmt = fmt.replace('%Y', '{:04d}'.format(d.year)) 133 | d = d + relativedelta(year=1900) 134 | return d.strftime(fmt) 135 | 136 | 137 | def s2d(datestring): 138 | """ 139 | Convert GRADS time string to datetime object. 140 | https://bitbucket.org/tmiyachi/pymet/src/8df8e3ff2f899d625939448d7e96755dfa535357/pymet/tools.py 141 | 142 | :param datestring: GRADS time string 143 | :return: datetime object 144 | 145 | :Examples: 146 | >>> s2d('12:30Z13OCT2009') 147 | datetime(2009, 10, 13, 12, 30) 148 | >>> s2d('12Z13OCT2009') 149 | datetime(2009, 10, 13, 12) 150 | """ 151 | 152 | time, date = datestring.upper().split('Z') 153 | if time.count(':') > 0: 154 | hh, mm = time.split(':') 155 | else: 156 | hh = time 157 | mm = 0 158 | dd = date[:-7] 159 | mmm = __months__.index(date[-7:-4])+1 160 | yyyy = date[-4:] 161 | return dt.datetime(int(yyyy), int(mmm), int(dd), int(hh), int(mm)) 162 | 163 | 164 | def np64toDate(np64): 165 | """ 166 | Converts a Numpy datetime64 to a Python datetime. 167 | 168 | :param np64: Numpy datetime64 value. 169 | :return: 170 | """ 171 | return pd.to_datetime(str(np64)).replace(tzinfo=None).to_pydatetime() 172 | 173 | -------------------------------------------------------------------------------- /nmc_met_base/utilities.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2019 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | """ 7 | Collections of utilities functions. 8 | """ 9 | 10 | 11 | def lon2txt(lon, fmt='%g'): 12 | """ 13 | Format the longitude number with degrees. 14 | 15 | :param lon: longitude 16 | :param fmt: 17 | :return: 18 | 19 | :Examples: 20 | >>> lon2txt(135) 21 | '135\N{DEGREE SIGN}E' 22 | >>> lon2txt(-30) 23 | '30\N{DEGREE SIGN}W' 24 | >>> lon2txt(250) 25 | '110\N{DEGREE SIGN}W' 26 | """ 27 | lon = (lon + 360) % 360 28 | if lon > 180: 29 | lonlabstr = u'%s\N{DEGREE SIGN}W' % fmt 30 | lonlab = lonlabstr % abs(lon - 360) 31 | elif lon < 180 and lon != 0: 32 | lonlabstr = u'%s\N{DEGREE SIGN}E' % fmt 33 | lonlab = lonlabstr % lon 34 | else: 35 | lonlabstr = u'%s\N{DEGREE SIGN}' % fmt 36 | lonlab = lonlabstr % lon 37 | return lonlab 38 | 39 | 40 | def lat2txt(lat, fmt='%g'): 41 | """ 42 | Format the latitude number with degrees. 43 | :param lat: 44 | :param fmt: 45 | :return: 46 | 47 | :Examples: 48 | >>> lat2txt(60) 49 | '60\N{DEGREE SIGN}N' 50 | >>> lat2txt(-30) 51 | '30\N{DEGREE SIGN}S' 52 | """ 53 | if lat < 0: 54 | latlabstr = u'%s\N{DEGREE SIGN}S' % fmt 55 | latlab = latlabstr % abs(lat) 56 | elif lat > 0: 57 | latlabstr = u'%s\N{DEGREE SIGN}N' % fmt 58 | latlab = latlabstr % lat 59 | else: 60 | latlabstr = u'%s\N{DEGREE SIGN}' % fmt 61 | latlab = latlabstr % lat 62 | return latlab 63 | -------------------------------------------------------------------------------- /nmc_met_base/verify.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | # Copyright (c) 2021 NMC Developers. 4 | # Distributed under the terms of the GPL V3 License. 5 | 6 | import numpy as np 7 | 8 | 9 | def threat_score(in_obs, in_fcst, threshold, 10 | matching=False, return_list=False): 11 | """ 12 | calculate threat score and bias score. 13 | 14 | :param in_obs: observation or analysis data. 15 | :param in_fcst: forecast data, the same size as in_obs. 16 | :param threshold: threshold value. 17 | :param matching: if True, the obs and fcst will be sorted, 18 | and the position bias is ignored. 19 | :param return_list: if True, return list, not dictionary. 20 | :return: threat and bias score. 21 | """ 22 | 23 | # check missing value 24 | obs = in_obs.flatten() 25 | fcst = in_fcst.flatten() 26 | miss_index = np.logical_or(np.isnan(obs), np.isnan(fcst)) 27 | if np.all(miss_index): 28 | return None 29 | obs = obs[np.logical_not(miss_index)] 30 | fcst = fcst[np.logical_not(miss_index)] 31 | if matching: 32 | obs = np.sort(obs) 33 | fcst = np.sort(fcst) 34 | 35 | # calculate NA, NB, NC 36 | o_flag = np.full(obs.size, 0) 37 | f_flag = np.full(fcst.size, 0) 38 | index = obs > threshold 39 | if np.count_nonzero(index) > 0: 40 | o_flag[index] = 1 41 | index = fcst > threshold 42 | if np.count_nonzero(index) > 0: 43 | f_flag[index] = 1 44 | NA = np.count_nonzero( 45 | np.logical_and(o_flag == 1, f_flag == 1)) # hits 46 | NB = np.count_nonzero( 47 | np.logical_and(o_flag == 0, f_flag == 1)) # false alarms 48 | NC = np.count_nonzero( 49 | np.logical_and(o_flag == 1, f_flag == 0)) # misses 50 | 51 | # threat score 52 | if NA+NB+NC == 0: 53 | ts = np.nan 54 | else: 55 | ts = NA*1.0/(NA+NB+NC) 56 | 57 | # equitable threat score 58 | hits_random = (NA+NC)*(NA+NB)*1.0/obs.size 59 | if NA+NB+NC-hits_random == 0: 60 | ets = np.nan 61 | else: 62 | ets = (NA-hits_random)*1.0/(NA+NB+NC-hits_random) 63 | 64 | # bias score 65 | if NA+NC == 0: 66 | bs = np.nan 67 | else: 68 | bs = (NA+NB)*1.0/(NA+NC) 69 | 70 | # the probability of detection 71 | if NA+NC == 0: 72 | pod = 0 73 | else: 74 | pod = NA*1.0/(NA + NC) 75 | 76 | # flase alarm ratio 77 | if NA+NB == 0: 78 | far = 1 79 | else: 80 | far = NB*1.0/(NA + NB) 81 | 82 | # return score 83 | if return_list: 84 | return [NA, NB, NC, hits_random, ts, ets, bs, pod, far] 85 | else: 86 | return {'NA': NA, 'NB': NB, 'NC': NC, 'HITS_random': hits_random, 87 | 'TS': ts, 'ETS': ets, 'BS': bs, 'POD': pod, 'FAR': far} 88 | 89 | -------------------------------------------------------------------------------- /nmc_met_base/wind.py: -------------------------------------------------------------------------------- 1 | # Brian Blaylock 2 | # July 3, 2018 3 | 4 | """ 5 | ================= 6 | Wind Calculations 7 | https://github.com/blaylockbk/Carpenter_Workshop/blob/main/toolbox/wind.py 8 | ================= 9 | 10 | Functions related to wind vectors 11 | 12 | - spddir_to_uv 13 | - uv_to_spddir 14 | - unit_vector 15 | - angle_between 16 | 17 | """ 18 | 19 | import numpy as np 20 | 21 | 22 | def spddir_to_uv(wspd, wdir): 23 | """ 24 | Calculate the u and v wind components from wind speed and direction. 25 | 26 | See https://earthscience.stackexchange.com/a/11989/18840 27 | 28 | Parameters 29 | ---------- 30 | wspd, wdir : array_like 31 | Arrays of wind speed and wind direction (in degrees) 32 | 33 | Returns 34 | ------- 35 | u and v wind components 36 | """ 37 | if isinstance(wspd, list) or isinstance(wdir, list): 38 | wspd = np.array(wspd, dtype=float) 39 | wdir = np.array(wdir, dtype=float) 40 | 41 | rad = np.pi / 180.0 42 | u = -wspd * np.sin(rad * wdir) 43 | v = -wspd * np.cos(rad * wdir) 44 | 45 | # If the speed is zero, then u and v should be set to zero (not NaN) 46 | if hasattr(u, "__len__"): 47 | u[np.where(wspd == 0)] = 0 48 | v[np.where(wspd == 0)] = 0 49 | elif wspd == 0: 50 | u = float(0) 51 | v = float(0) 52 | 53 | return np.round(u, 3), np.round(v, 3) 54 | 55 | 56 | def uv_to_spddir(u, v): 57 | """ 58 | Calculates the wind speed and direction from u and v components. 59 | 60 | Takes into account the wind direction coordinates is different than 61 | the trig unit circle coordinate. 62 | If the wind direction is 360, then return zero. 63 | 64 | Parameters 65 | ---------- 66 | u, v: array_like 67 | u (west to east) and v (south to north) wind component. 68 | 69 | Returns 70 | ------- 71 | Wind speed and direction 72 | """ 73 | if isinstance(u, list) or isinstance(v, list): 74 | u = np.array(u) 75 | v = np.array(v) 76 | 77 | wdir = (270 - np.rad2deg(np.arctan2(v, u))) % 360 78 | wspd = np.sqrt(u * u + v * v) 79 | 80 | return wspd.round(3), wdir.round(3) 81 | 82 | 83 | def unit_vector(i, j): 84 | """ 85 | Return a unit vector for a 2D vector. 86 | """ 87 | magnitude = np.sqrt(i ** 2 + j ** 2) 88 | unit_i = i / magnitude 89 | unit_j = j / magnitude 90 | 91 | return unit_i, unit_j 92 | 93 | 94 | def angle_between(i1, j1, i2, j2): 95 | """ 96 | Calculate the angle between two 2D vectors (i.e., 2 wind vectors). 97 | 98 | Utilizes the cos equation: 99 | $cos(theta) = (v1 dot v2) / (V1 x V2)$ where V1 and V2 are the magnitude of vector1 and vecto2. 100 | 101 | For a two-dimensional vector where v1 = and v2 = : 102 | 103 | cos(theta) = (i1*i2 + j1*j2) / (sqrt(i1**2 + j1**2) * sqrt(i2**2 + j2**2) 104 | 105 | Parameters 106 | ---------- 107 | i1, j1 : array like 108 | i and j components representing the first vector 109 | i2, j2 : array like 110 | i and j components representing the second vector 111 | 112 | Returns 113 | ------- 114 | The angle between vectors vector 1 and vector 2 in degrees. 115 | 116 | Examples 117 | -------- 118 | 119 | >>> angle_between(0, 10, 30, 30) 120 | 45.0 121 | 122 | >>> angle_between(1, 0, 0, 1) 123 | 90.0 124 | 125 | >>> angle_between((1, 0), (-1, 0)) 126 | 180 127 | """ 128 | 129 | dot_product = i1 * i2 + j1 * j2 130 | magnitude1 = np.sqrt(i1 ** 2 + j1 ** 2) 131 | magnitude2 = np.sqrt(i2 ** 2 + j2 ** 2) 132 | 133 | theta = np.arccos(dot_product / (magnitude1 * magnitude2)) 134 | 135 | return np.rad2deg(theta).round(3) 136 | 137 | 138 | def wind_degree_labels(res="m"): 139 | """Wind degree increment and direction labels 140 | 141 | This is useful for labeling a matplotlib wind direction axis ticks. 142 | 143 | .. code-block:: python 144 | 145 | plt.yticks(*wind_degree_labels()) 146 | 147 | .. code-block:: python 148 | 149 | ticks, labels = wind_degree_labels() 150 | ax.set_yticks(ticks) 151 | ax.set_yticklabels(labels) 152 | 153 | ..image:: https://rechneronline.de/geo-coordinates/wind-rose.png 154 | 155 | Parameters 156 | ---------- 157 | res : {'l', 'm', 'h'} or {90, 45, 22.5} 158 | Low, medium, and high increment resolution. 159 | - l : returns 4 cardinal directions [N, E, S, W, N] 160 | - m : returns 8 cardinal directions [N, NE, E, SE, ...] 161 | - h : returns 16 cardinal directions [N, NNE, NE, ENE, E, ...] 162 | """ 163 | labels = [ 164 | "N", 165 | "NNE", 166 | "NE", 167 | "ENE", 168 | "E", 169 | "ESE", 170 | "SE", 171 | "SSE", 172 | "S", 173 | "SSW", 174 | "SW", 175 | "WSW", 176 | "W", 177 | "WNW", 178 | "NW", 179 | "NNW", 180 | "N", 181 | ] 182 | degrees = np.arange(0, 361, 22.5) 183 | 184 | if res in ["l", 90]: 185 | return degrees[::4], labels[::4] 186 | elif res in ["m", 45]: 187 | return degrees[::2], labels[::2] 188 | elif res in ["h", 22.5]: 189 | return degrees, labels 190 | 191 | 192 | def wind_profile_power_law(wind_speed_ref, z_ref, z=10, alpha=0.143): 193 | """ 194 | Adjust a wind to a different height above ground with the power law. 195 | 196 | Parameters 197 | ---------- 198 | wind_speed_ref : float or array-like 199 | The reference wind speed, wind u, or wind v (m/s) at height z_ref. 200 | z_ref : float or int 201 | The reference height of the given reference wind (m) 202 | z : float or int 203 | The height to adjust the wind. Default is 10 m. 204 | alpha : float or int, {'land', 'water'} 205 | The empirical alpha value for the power law relationship. 206 | Default is 0.143 (which is ~1/7), valid for neutral stability 207 | conditions over open land. 208 | - "land" is an alias for 0.143 209 | - "water" is an alias for 0.11 210 | 211 | References 212 | ---------- 213 | 214 | - https://en.wikipedia.org/wiki/Wind_profile_power_law 215 | - https://doi.org/10.1175/1520-0450(1994)033%3C0757:DTPLWP%3E2.0.CO;2 216 | - https://github.com/ydeos/ydeos_aerodynamics/blob/8f42a4959f093f204228aff682ed40b0a9bd3a27/ydeos_aerodynamics/profiles.py 217 | 218 | """ 219 | if alpha == "water": 220 | alpha = 0.11 221 | elif alpha == "land": 222 | alpha = 0.143 223 | 224 | wind_speed_z = wind_speed_ref * (z / z_ref) ** alpha 225 | return wind_speed_z 226 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # _*_ coding: utf-8 _*_ 2 | 3 | from os import path 4 | from setuptools import find_packages, setup 5 | from codecs import open 6 | 7 | name = "nmc_met_base" 8 | author = __import__(name).__author__ 9 | version = __import__(name).__version__ 10 | 11 | here = path.abspath(path.dirname(__file__)) 12 | 13 | # Get the long description from the README file 14 | with open(path.join(here, 'README.md'), encoding='utf-8') as f: 15 | long_description = f.read() 16 | 17 | setup( 18 | name=name, 19 | version=version, 20 | 21 | description=("A collections of basic functions for meteorological development."), 22 | long_description=long_description, 23 | long_description_content_type='text/markdown', 24 | url='https://github.com/nmcdev/nmc_met_base', 25 | 26 | # author 27 | author=author, 28 | author_email='kan.dai@foxmail.com', 29 | 30 | # LICENSE 31 | license='GPL3', 32 | 33 | classifiers=[ 34 | 'Development Status :: 4 - Beta', 35 | 'Intended Audience :: Developers', 36 | 'Intended Audience :: Science/Research', 37 | 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 38 | 'Topic :: Scientific/Engineering', 39 | 'Topic :: Scientific/Engineering :: Atmospheric Science', 40 | 'Programming Language :: Python :: 3.7', 41 | 'Programming Language :: Python :: 3.8', 42 | 'Operating System :: POSIX :: Linux', 43 | 'Operating System :: MacOS :: MacOS X', 44 | 'Operating System :: Microsoft :: Windows'], 45 | 46 | python_requires='>=3.6', 47 | zip_safe = False, 48 | platforms = ["all"], 49 | 50 | packages=find_packages(exclude=[ 51 | 'documents', 'docs', 'examples', 'notebooks', 'tests', 'build', 'dist']), 52 | include_package_data=True, 53 | package_data={'':['LICENSE','README.md']}, 54 | exclude_package_data={'': ['.gitignore']}, 55 | 56 | install_requires=[ 57 | 'numpy>=1.17.0', 58 | 'scipy>=1.4.0', 59 | 'pandas>=1.0.0', 60 | 'xarray>=0.16.0', 61 | 'pyproj>=2.6.0', 62 | 'python-dateutil>=2.8.1', 63 | 'numba>=0.49.0', 64 | 'metpy>=0.12.0'] 65 | ) 66 | 67 | # development mode (DOS command): 68 | # python setup.py develop 69 | # python setup.py develop --uninstall 70 | 71 | # build mode: 72 | # python setup.py build --build-base=D:/test/python/build 73 | 74 | # distribution mode: 75 | # python setup.py bdist_wheel # create source tar.gz file in /dist 76 | # twine upload --skip-existing dist/* # upload package to pypi 77 | --------------------------------------------------------------------------------