Skip to content

Commit 10416e4

Browse files
authored
Update rainfarm.py (#311)
1 parent fc446bc commit 10416e4

File tree

3 files changed

+343
-53
lines changed

3 files changed

+343
-53
lines changed

doc/source/references.bib

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@ @ARTICLE{CRS2004
5151
DOI = "10.1017/S1350482704001239"
5252
}
5353

54+
@ARTICLE{DOnofrio2014,
55+
TITLE = "Stochastic rainfall downscaling of climate models",
56+
AUTHOR = "D'Onofrio, D and Palazzi, E and von Hardenberg, J and Provenzale, A and Calmanti, S",
57+
JOURNAL = "J. Hydrometeorol.",
58+
PUVLISHER = "American Meteorological Society",
59+
VOLUME = 15,
60+
NUMBER = 2,
61+
PAGES = "830--843",
62+
YEAR = 2014,
63+
}
64+
5465
@ARTICLE{EWWM2013,
5566
AUTHOR = "E. Ebert and L. Wilson and A. Weigel and M. Mittermaier and P. Nurmi and P. Gill and M. Göber and S. Joslyn and B. Brown and T. Fowler and A. Watkins",
5667
TITLE = "Progress and challenges in forecast verification",
@@ -306,6 +317,17 @@ @ARTICLE{SPN2013
306317
DOI = "10.1002/wrcr.20536"
307318
}
308319

320+
@Article{Terzago2018,
321+
AUTHOR = "Terzago, S. and Palazzi, E. and von Hardenberg, J.",
322+
TITLE = "Stochastic downscaling of precipitation in complex orography: a simple method to reproduce a realistic fine-scale climatology",
323+
JOURNAL = "Natural Hazards and Earth System Sciences",
324+
VOLUME = 18,
325+
YEAR = 2018,
326+
NUMBER = 11,
327+
PAGES = "2825--2840",
328+
DOI = "10.5194/nhess-18-2825-2018"
329+
}
330+
309331
@ARTICLE{TRT2004,
310332
AUTHOR = "A. M. Hering and C. Morel and G. Galli and P. Ambrosetti and M. Boscacci",
311333
TITLE = "Nowcasting thunderstorms in the Alpine Region using a radar based adaptive thresholding scheme",

pysteps/downscaling/rainfarm.py

Lines changed: 243 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
============================
55
66
Implementation of the RainFARM stochastic downscaling method as described in
7-
:cite:`Rebora2006`.
7+
:cite:`Rebora2006` and :cite:`DOnofrio2014`.
88
99
RainFARM is a downscaling algorithm for rainfall fields developed by Rebora et
1010
al. (2006). The method can represent the realistic small-scale variability of the
@@ -20,38 +20,211 @@
2020
import warnings
2121

2222
import numpy as np
23-
from scipy.ndimage import convolve
23+
from scipy.signal import convolve
24+
from pysteps.utils.spectral import rapsd
25+
from pysteps.utils.dimension import aggregate_fields
26+
27+
28+
def _gaussianize(precip):
29+
"""
30+
Gaussianize field using rank ordering as in :cite:`DOnofrio2014`.
31+
"""
32+
m, n = np.shape(precip)
33+
nn = m * n
34+
ii = np.argsort(precip.reshape(nn))
35+
precip_gaussianize = np.zeros(nn)
36+
precip_gaussianize[ii] = sorted(np.random.normal(0, 1, nn))
37+
precip_gaussianize = precip_gaussianize.reshape(m, n)
38+
sd = np.std(precip_gaussianize)
39+
if sd == 0:
40+
sd = 1
41+
return precip_gaussianize / sd
42+
43+
44+
def _compute_freq_array(array, ds_factor=1):
45+
"""
46+
Compute the frequency array following a given downscaling factor.
47+
"""
48+
freq_i = np.fft.fftfreq(array.shape[0] * ds_factor, d=1 / ds_factor)
49+
freq_j = np.fft.fftfreq(array.shape[1] * ds_factor, d=1 / ds_factor)
50+
freq_sqr = freq_i[:, None] ** 2 + freq_j[None, :] ** 2
51+
return np.sqrt(freq_sqr)
2452

2553

2654
def _log_slope(log_k, log_power_spectrum):
55+
"""
56+
Calculate the log-slope of the power spectrum given an array of logarithmic wavenumbers
57+
and an array of logarithmic power spectrum values.
58+
"""
2759
lk_min = log_k.min()
2860
lk_max = log_k.max()
2961
lk_range = lk_max - lk_min
3062
lk_min += (1 / 6) * lk_range
3163
lk_max -= (1 / 6) * lk_range
32-
3364
selected = (lk_min <= log_k) & (log_k <= lk_max)
3465
lk_sel = log_k[selected]
3566
ps_sel = log_power_spectrum[selected]
3667
alpha = np.polyfit(lk_sel, ps_sel, 1)[0]
3768
alpha = -alpha
69+
return alpha
3870

71+
72+
def _estimate_alpha(array, k):
73+
"""
74+
Estimate the alpha parameter using the power spectrum of the input array.
75+
"""
76+
fp = np.fft.fft2(array)
77+
fp_abs = abs(fp)
78+
log_power_spectrum = np.log(fp_abs**2)
79+
valid = (k != 0) & np.isfinite(log_power_spectrum)
80+
alpha = _log_slope(np.log(k[valid]), log_power_spectrum[valid])
3981
return alpha
4082

4183

42-
def _balanced_spatial_average(x, k):
43-
ones = np.ones_like(x)
44-
return convolve(x, k) / convolve(ones, k)
84+
def _compute_noise_field(freq_array_highres, alpha):
85+
"""
86+
Compute a field of correlated noise field using the given frequency array and alpha
87+
value.
88+
"""
89+
white_noise_field = np.random.rand(*freq_array_highres.shape)
90+
white_noise_field_complex = np.exp(complex(0, 1) * 2 * np.pi * white_noise_field)
91+
with warnings.catch_warnings():
92+
warnings.simplefilter("ignore")
93+
noise_field_complex = white_noise_field_complex * np.sqrt(
94+
freq_array_highres**-alpha
95+
)
96+
noise_field_complex[0, 0] = 0
97+
return np.fft.ifft2(noise_field_complex).real
4598

4699

47-
def downscale(precip, ds_factor, alpha=None, threshold=None, return_alpha=False):
100+
def _apply_spectral_fusion(
101+
array_low, array_high, freq_array_low, freq_array_high, ds_factor
102+
):
48103
"""
49-
Downscale a rainfall field by increasing its spatial resolution by
50-
a positive integer factor.
104+
Apply spectral fusion to merge two arrays in the frequency domain.
105+
"""
106+
107+
# Validate inputs
108+
if array_low.shape != freq_array_low.shape:
109+
raise ValueError("Shape of array_low must match shape of freq_array_low.")
110+
if array_high.shape != freq_array_high.shape:
111+
raise ValueError("Shape of array_high must match shape of freq_array_high.")
112+
113+
nax, _ = np.shape(array_low)
114+
nx, _ = np.shape(array_high)
115+
k0 = nax // 2
116+
117+
# Calculate power spectral density at specific frequency
118+
def compute_psd(array, fft_size):
119+
return rapsd(array, fft_method=np.fft)[k0 - 1] * fft_size**2
120+
121+
psd_low = compute_psd(array_low, nax)
122+
psd_high = compute_psd(array_high, nx)
123+
124+
# Normalize high-resolution array
125+
normalization_factor = np.sqrt(psd_low / psd_high)
126+
array_high *= normalization_factor
127+
128+
# Perform FFT on both arrays
129+
fft_low = np.fft.fft2(array_low)
130+
fft_high = np.fft.fft2(array_high)
131+
132+
# Initialize the merged FFT array with low-resolution data
133+
fft_merged = np.zeros_like(fft_high, dtype=np.complex128)
134+
fft_merged[0:k0, 0:k0] = fft_low[0:k0, 0:k0]
135+
fft_merged[nx - k0 : nx, 0:k0] = fft_low[k0 : 2 * k0, 0:k0]
136+
fft_merged[0:k0, nx - k0 : nx] = fft_low[0:k0, k0 : 2 * k0]
137+
fft_merged[nx - k0 : nx, nx - k0 : nx] = fft_low[k0 : 2 * k0, k0 : 2 * k0]
138+
139+
fft_merged[k0, 0] = np.conj(fft_merged[nx - k0, 0])
140+
fft_merged[0, k0] = np.conj(fft_merged[0, nx - k0])
141+
142+
# Compute frequency arrays
143+
freq_i = np.fft.fftfreq(nx, d=1 / ds_factor)
144+
freq_i = np.tile(freq_i, (nx, 1))
145+
freq_j = freq_i.T
146+
147+
# Compute frequency domain adjustment
148+
ddx = np.pi * (1 / nax - 1 / nx) / np.abs(freq_i[0, 1] - freq_i[0, 0])
149+
freq_squared_high = freq_array_high**2
150+
freq_squared_low_center = freq_array_low[k0, k0] ** 2
151+
152+
# Fuse in the frequency domain
153+
mask_high = freq_squared_high > freq_squared_low_center
154+
mask_low = ~mask_high
155+
fft_merged = fft_high * mask_high + fft_merged * mask_low * np.exp(
156+
-1j * ddx * freq_i - 1j * ddx * freq_j
157+
)
158+
159+
# Inverse FFT to obtain the merged array in the spatial domain
160+
merged = np.real(np.fft.ifftn(fft_merged)) / fft_merged.size
161+
162+
return merged
163+
164+
165+
def _compute_kernel_radius(ds_factor):
166+
return int(round(ds_factor / np.sqrt(np.pi)))
167+
168+
169+
def _make_tophat_kernel(ds_factor):
170+
"""Compute 2d uniform (tophat) kernel"""
171+
radius = _compute_kernel_radius(ds_factor)
172+
(mx, my) = np.mgrid[-radius : radius + 0.01, -radius : radius + 0.01]
173+
tophat = ((mx**2 + my**2) <= radius**2).astype(float)
174+
return tophat / tophat.sum()
175+
176+
177+
def _make_gaussian_kernel(ds_factor):
178+
"""
179+
Compute 2d gaussian kernel
180+
ref: https://github.com/scipy/scipy/blob/de80faf9d3480b9dbb9b888568b64499e0e70c19/scipy/ndimage/_filters.py#L179
181+
the smoothing sigma has width half a large pixel
182+
"""
183+
radius = _compute_kernel_radius(ds_factor)
184+
sigma = ds_factor / 2
185+
sigma2 = sigma * sigma
186+
x = np.arange(-radius, radius + 1)
187+
kern1d = np.exp(-0.5 / sigma2 * x**2)
188+
kern2d = np.outer(kern1d, kern1d)
189+
return kern2d / kern2d.sum()
190+
191+
192+
def _balanced_spatial_average(array, kernel):
193+
"""
194+
Compute the balanced spatial average of an array using a given kernel while handling
195+
missing or invalid values.
196+
"""
197+
array = array.copy()
198+
mask_valid = np.isfinite(array)
199+
array[~mask_valid] = 0.0
200+
array_conv = convolve(array, kernel, mode="same")
201+
array_conv /= convolve(mask_valid, kernel, mode="same")
202+
array_conv[~mask_valid] = np.nan
203+
return array_conv
204+
205+
206+
_make_kernel = dict()
207+
_make_kernel["gaussian"] = _make_gaussian_kernel
208+
_make_kernel["tophat"] = _make_tophat_kernel
209+
_make_kernel["uniform"] = _make_tophat_kernel
210+
211+
212+
def downscale(
213+
precip,
214+
ds_factor,
215+
alpha=None,
216+
threshold=None,
217+
return_alpha=False,
218+
kernel_type=None,
219+
spectral_fusion=False,
220+
):
221+
"""
222+
Downscale a rainfall field by increasing its spatial resolution by a positive
223+
integer factor.
51224
52225
Parameters
53226
----------
54-
precip: array-like
227+
precip: array_like
55228
Array of shape (m, n) containing the input field.
56229
The input is expected to contain rain rate values.
57230
All values are required to be finite.
@@ -65,10 +238,14 @@ def downscale(precip, ds_factor, alpha=None, threshold=None, return_alpha=False)
65238
Set all values lower than the threshold to zero.
66239
return_alpha: bool, optional
67240
Whether to return the estimated spectral slope ``alpha``.
241+
kernel_type: {None, "gaussian", "uniform", "tophat"}
242+
The name of the smoothing operator. If None no smoothing is applied.
243+
spectral_fusion: bool, optional
244+
Whether to apply spectral merging as in :cite:`DOnofrio2014`.
68245
69246
Returns
70247
-------
71-
r: array-like
248+
precip_highres: ndarray
72249
Array of shape (m * ds_factor, n * ds_factor) containing
73250
the downscaled field.
74251
alpha: float
@@ -79,54 +256,74 @@ def downscale(precip, ds_factor, alpha=None, threshold=None, return_alpha=False)
79256
Currently, the pysteps implementation of RainFARM only covers spatial downscaling.
80257
That is, it can improve the spatial resolution of a rainfall field. However, unlike
81258
the original algorithm from Rebora et al. (2006), it cannot downscale the temporal
82-
dimension.
259+
dimension. It implements spectral merging from D'Onofrio et al. (2014).
83260
84261
References
85262
----------
86263
:cite:`Rebora2006`
264+
:cite:`DOnofrio2014`
87265
88266
"""
89267

90-
ki = np.fft.fftfreq(precip.shape[0])
91-
kj = np.fft.fftfreq(precip.shape[1])
92-
k_sqr = ki[:, None] ** 2 + kj[None, :] ** 2
93-
k = np.sqrt(k_sqr)
268+
# Validate inputs
269+
if not np.isfinite(precip).all():
270+
raise ValueError("All values in 'precip' must be finite.")
271+
if not isinstance(ds_factor, int) or ds_factor <= 0:
272+
raise ValueError("'ds_factor' must be a positive integer.")
273+
274+
# Preprocess the input field if spectral fusion is enabled
275+
precip_transformed = _gaussianize(precip) if spectral_fusion else precip
94276

95-
ki_ds = np.fft.fftfreq(precip.shape[0] * ds_factor, d=1 / ds_factor)
96-
kj_ds = np.fft.fftfreq(precip.shape[1] * ds_factor, d=1 / ds_factor)
97-
k_ds_sqr = ki_ds[:, None] ** 2 + kj_ds[None, :] ** 2
98-
k_ds = np.sqrt(k_ds_sqr)
277+
# Compute frequency arrays for the original and high-resolution fields
278+
freq_array = _compute_freq_array(precip_transformed)
279+
freq_array_highres = _compute_freq_array(precip_transformed, ds_factor)
99280

281+
# Estimate spectral slope alpha if not provided
100282
if alpha is None:
101-
fp = np.fft.fft2(precip)
102-
fp_abs = abs(fp)
103-
log_power_spectrum = np.log(fp_abs**2)
104-
valid = (k != 0) & np.isfinite(log_power_spectrum)
105-
alpha = _log_slope(np.log(k[valid]), log_power_spectrum[valid])
283+
alpha = _estimate_alpha(precip_transformed, freq_array)
106284

107-
fg = np.exp(complex(0, 1) * 2 * np.pi * np.random.rand(*k_ds.shape))
108-
with warnings.catch_warnings():
109-
warnings.simplefilter("ignore")
110-
fg *= np.sqrt(k_ds_sqr ** (-alpha / 2))
111-
fg[0, 0] = 0
112-
g = np.fft.ifft2(fg).real
113-
g /= g.std()
114-
r = np.exp(g)
115-
116-
P_u = np.repeat(np.repeat(precip, ds_factor, axis=0), ds_factor, axis=1)
117-
rad = int(round(ds_factor / np.sqrt(np.pi)))
118-
(mx, my) = np.mgrid[-rad : rad + 0.01, -rad : rad + 0.01]
119-
tophat = ((mx**2 + my**2) <= rad**2).astype(float)
120-
tophat /= tophat.sum()
121-
122-
P_agg = _balanced_spatial_average(P_u, tophat)
123-
r_agg = _balanced_spatial_average(r, tophat)
124-
r *= P_agg / r_agg
285+
# Generate noise field
286+
noise_field = _compute_noise_field(freq_array_highres, alpha)
287+
288+
# Apply spectral fusion if enabled
289+
if spectral_fusion:
290+
noise_field /= noise_field.shape[0] ** 2
291+
noise_field = np.exp(noise_field)
292+
noise_field = _apply_spectral_fusion(
293+
precip_transformed, noise_field, freq_array, freq_array_highres, ds_factor
294+
)
295+
296+
# Normalize and exponentiate the noise field
297+
noise_field /= noise_field.std()
298+
noise_field = np.exp(noise_field)
299+
300+
# Aggregate the noise field to low resolution
301+
noise_lowres = aggregate_fields(noise_field, ds_factor, axis=(0, 1))
302+
303+
# Expand input and noise fields to high resolution
304+
precip_expanded = np.kron(precip, np.ones((ds_factor, ds_factor)))
305+
noise_lowres_expanded = np.kron(noise_lowres, np.ones((ds_factor, ds_factor)))
306+
307+
# Apply smoothing if a kernel type is provided
308+
if kernel_type:
309+
if kernel_type not in _make_kernel:
310+
raise ValueError(
311+
f"kernel type '{kernel_type}' is invalid, available kernels: {list(_make_kernel)}"
312+
)
313+
kernel = _make_kernel[kernel_type](ds_factor)
314+
precip_expanded = _balanced_spatial_average(precip_expanded, kernel)
315+
noise_lowres_expanded = _balanced_spatial_average(noise_lowres_expanded, kernel)
316+
317+
# Normalize the high-res precipitation field by the low-res noise field
318+
norm_k0 = precip_expanded / noise_lowres_expanded
319+
precip_highres = noise_field * norm_k0
125320

321+
# Apply thresholding if specified
126322
if threshold is not None:
127-
r[r < threshold] = 0
323+
precip_highres[precip_highres < threshold] = 0
128324

325+
# Return the downscaled field and optionally the spectral slope alpha
129326
if return_alpha:
130-
return r, alpha
327+
return precip_highres, alpha
131328

132-
return r
329+
return precip_highres

0 commit comments

Comments
 (0)