Skip to content

Commit 5ba3cac

Browse files
committed
Merge remote-tracking branch 'origin/master' into xarray/main
2 parents 58300b2 + 07a5aa8 commit 5ba3cac

22 files changed

+822
-120
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
repos:
22
- repo: https://github.com/psf/black
3-
rev: 24.3.0
3+
rev: 24.4.2
44
hooks:
55
- id: black
66
language_version: python3

examples/anvil_nowcast.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@
9999
np.array([0.5]), metadata, threshold=0.1, zerovalue=-15.0
100100
)
101101
forecast_sprog = sprog.forecast(
102-
rainrate_field_db[-3:], velocity, 3, n_cascade_levels=8, R_thr=rainrate_thr[0]
102+
rainrate_field_db[-3:], velocity, 3, n_cascade_levels=6, R_thr=rainrate_thr[0]
103103
)
104104
forecast_sprog, _ = transformation.dB_transform(
105105
forecast_sprog, threshold=-10.0, inverse=True

examples/thunderstorm_detection_and_tracking.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,17 @@
9090
# Properties of one of the identified cells:
9191
print(cells_id.iloc[0])
9292

93+
###############################################################################
94+
# Optionally, one can also ask to consider splits and merges of thunderstorm cells.
95+
# A cell at time t is considered to split if it will verlap more than 10% with more than
96+
# one cell at time t+1. Conversely, a cell is considered to be a merge, if more
97+
# than one cells fron time t will overlap more than 10% with it.
98+
99+
cells_id, labels = tstorm_detect.detection(
100+
input_image, time=time, output_splits_merges=True
101+
)
102+
print(cells_id.iloc[0])
103+
93104
###############################################################################
94105
# Example of thunderstorm tracking over a timeseries
95106
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

pysteps/blending/clim.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import numpy as np
2323

2424

25-
def get_default_skill(n_cascade_levels=8, n_models=1):
25+
def get_default_skill(n_cascade_levels=6, n_models=1):
2626
"""
2727
Get the default climatological skill values as given in :cite:`BPS2006`.
2828
Take subset of n_cascade_levels or add entries with small values (1e-4) if
@@ -31,7 +31,7 @@ def get_default_skill(n_cascade_levels=8, n_models=1):
3131
Parameters
3232
----------
3333
n_cascade_levels: int, optional
34-
Number of cascade levels. Defaults to 8.
34+
Number of cascade levels. Defaults to 6, see issue #385 on GitHub.
3535
n_models: int, optional
3636
Number of NWP models. Defaults to 1.
3737
@@ -157,7 +157,7 @@ def save_skill(
157157

158158
def calc_clim_skill(
159159
outdir_path,
160-
n_cascade_levels=8,
160+
n_cascade_levels=6,
161161
n_models=1,
162162
window_length=30,
163163
):
@@ -168,7 +168,7 @@ def calc_clim_skill(
168168
Parameters
169169
----------
170170
n_cascade_levels: int, optional
171-
Number of cascade levels.
171+
Number of cascade levels. Defaults to 6, see issue #385 on GitHub.
172172
outdir_path: string
173173
Path to folder where the historical skill are stored. Defaults to
174174
path_workdir from rcparams.

pysteps/blending/steps.py

Lines changed: 107 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,11 @@
4343
blend_means_sigmas
4444
"""
4545

46+
import math
4647
import time
4748

4849
import numpy as np
50+
from scipy.linalg import inv
4951
from scipy.ndimage import binary_dilation, generate_binary_structure, iterate_structure
5052

5153
from pysteps import cascade
@@ -74,7 +76,7 @@ def forecast(
7476
timestep,
7577
issuetime,
7678
n_ens_members,
77-
n_cascade_levels=8,
79+
n_cascade_levels=6,
7880
blend_nwp_members=False,
7981
precip_thr=None,
8082
norain_thr=0.0,
@@ -90,6 +92,8 @@ def forecast(
9092
conditional=False,
9193
probmatching_method="cdf",
9294
mask_method="incremental",
95+
resample_distribution=True,
96+
smooth_radar_mask_range=0,
9397
callback=None,
9498
return_output=True,
9599
seed=None,
@@ -153,8 +157,8 @@ def forecast(
153157
equal to or larger than the number of NWP ensemble members / number of
154158
NWP models.
155159
n_cascade_levels: int, optional
156-
The number of cascade levels to use. Default set to 8 due to default
157-
climatological skill values on 8 levels.
160+
The number of cascade levels to use. Defaults to 6,
161+
see issue #385 on GitHub.
158162
blend_nwp_members: bool
159163
Check if NWP models/members should be used individually, or if all of
160164
them are blended together per nowcast ensemble member. Standard set to
@@ -204,18 +208,32 @@ def forecast(
204208
If set to True, compute the statistics of the precipitation field
205209
conditionally by excluding pixels where the values are below the threshold
206210
precip_thr.
207-
mask_method: {'obs','incremental',None}, optional
208-
The method to use for masking no precipitation areas in the forecast field.
209-
The masked pixels are set to the minimum value of the observations.
210-
'obs' = apply precip_thr to the most recently observed precipitation intensity
211-
field, 'incremental' = iteratively buffer the mask with a certain rate
212-
(currently it is 1 km/min), None=no masking.
213211
probmatching_method: {'cdf','mean',None}, optional
214212
Method for matching the statistics of the forecast field with those of
215213
the most recently observed one. 'cdf'=map the forecast CDF to the observed
216214
one, 'mean'=adjust only the conditional mean value of the forecast field
217215
in precipitation areas, None=no matching applied. Using 'mean' requires
218216
that mask_method is not None.
217+
mask_method: {'obs','incremental',None}, optional
218+
The method to use for masking no precipitation areas in the forecast field.
219+
The masked pixels are set to the minimum value of the observations.
220+
'obs' = apply precip_thr to the most recently observed precipitation intensity
221+
field, 'incremental' = iteratively buffer the mask with a certain rate
222+
(currently it is 1 km/min), None=no masking.
223+
resample_distribution: bool, optional
224+
Method to resample the distribution from the extrapolation and NWP cascade as input
225+
for the probability matching. Not resampling these distributions may lead to losing
226+
some extremes when the weight of both the extrapolation and NWP cascade is similar.
227+
Defaults to True.
228+
smooth_radar_mask_range: int, Default is 0.
229+
Method to smooth the transition between the radar-NWP-noise blend and the NWP-noise
230+
blend near the edge of the radar domain (radar mask), where the radar data is either
231+
not present anymore or is not reliable. If set to 0 (grid cells), this generates a
232+
normal forecast without smoothing. To create a smooth mask, this range should be a
233+
positive value, representing a buffer band of a number of pixels by which the mask
234+
is cropped and smoothed. The smooth radar mask removes the hard edges between NWP
235+
and radar in the final blended product. Typically, a value between 50 and 100 km
236+
can be used. 80 km generally gives good results.
219237
callback: function, optional
220238
Optional function that is called after computation of each time step of
221239
the nowcast. The function takes one argument: a three-dimensional array
@@ -1396,7 +1414,6 @@ def worker(j):
13961414
# latest extrapolated radar rainfall field blended with the
13971415
# nwp model(s) rainfall forecast fields as 'benchmark'.
13981416

1399-
# TODO: Check probability matching method
14001417
# 8.7.1 first blend the extrapolated rainfall field (the field
14011418
# that is only used for post-processing steps) with the NWP
14021419
# rainfall forecast for this time step using the weights
@@ -1451,10 +1468,49 @@ def worker(j):
14511468
# forecast outside the radar domain. Therefore, fill these
14521469
# areas with the "..._mod_only" blended forecasts, consisting
14531470
# of the NWP and noise components.
1471+
14541472
nan_indices = np.isnan(R_f_new)
1455-
R_f_new[nan_indices] = R_f_new_mod_only[nan_indices]
1456-
nan_indices = np.isnan(R_pm_blended)
1457-
R_pm_blended[nan_indices] = R_pm_blended_mod_only[nan_indices]
1473+
if smooth_radar_mask_range != 0:
1474+
# Compute the smooth dilated mask
1475+
new_mask = blending.utils.compute_smooth_dilated_mask(
1476+
nan_indices,
1477+
max_padding_size_in_px=smooth_radar_mask_range,
1478+
)
1479+
1480+
# Ensure mask values are between 0 and 1
1481+
mask_model = np.clip(new_mask, 0, 1)
1482+
mask_radar = np.clip(1 - new_mask, 0, 1)
1483+
1484+
# Handle NaNs in R_f_new and R_f_new_mod_only by setting NaNs to 0 in the blending step
1485+
R_f_new_mod_only_no_nan = np.nan_to_num(
1486+
R_f_new_mod_only, nan=0
1487+
)
1488+
R_f_new_no_nan = np.nan_to_num(R_f_new, nan=0)
1489+
1490+
# Perform the blending of radar and model inside the radar domain using a weighted combination
1491+
R_f_new = np.nansum(
1492+
[
1493+
mask_model * R_f_new_mod_only_no_nan,
1494+
mask_radar * R_f_new_no_nan,
1495+
],
1496+
axis=0,
1497+
)
1498+
1499+
nan_indices = np.isnan(R_pm_blended)
1500+
R_pm_blended = np.nansum(
1501+
[
1502+
R_pm_blended * mask_radar,
1503+
R_pm_blended_mod_only * mask_model,
1504+
],
1505+
axis=0,
1506+
)
1507+
else:
1508+
R_f_new[nan_indices] = R_f_new_mod_only[nan_indices]
1509+
nan_indices = np.isnan(R_pm_blended)
1510+
R_pm_blended[nan_indices] = R_pm_blended_mod_only[
1511+
nan_indices
1512+
]
1513+
14581514
# Finally, fill the remaining nan values, if present, with
14591515
# the minimum value in the forecast
14601516
nan_indices = np.isnan(R_f_new)
@@ -1491,19 +1547,39 @@ def worker(j):
14911547
# Set to min value outside of mask
14921548
R_f_new[~MASK_prec_] = R_cmin
14931549

1550+
# If probmatching_method is not None, resample the distribution from
1551+
# both the extrapolation cascade and the model (NWP) cascade and use
1552+
# that for the probability matching
1553+
if probmatching_method is not None and resample_distribution:
1554+
# deal with missing values
1555+
arr1 = R_pm_ep[t_index]
1556+
arr2 = precip_models_pm_temp[j]
1557+
arr2 = np.where(np.isnan(arr2), np.nanmin(arr2), arr2)
1558+
arr1 = np.where(np.isnan(arr1), arr2, arr1)
1559+
# resample weights based on cascade level 2
1560+
R_pm_resampled = probmatching.resample_distributions(
1561+
first_array=arr1,
1562+
second_array=arr2,
1563+
probability_first_array=weights_pm_normalized[0],
1564+
)
1565+
else:
1566+
R_pm_resampled = R_pm_blended.copy()
1567+
14941568
if probmatching_method == "cdf":
14951569
# Adjust the CDF of the forecast to match the most recent
14961570
# benchmark rainfall field (R_pm_blended). If the forecast
14971571
if np.any(np.isfinite(R_f_new)):
14981572
R_f_new = probmatching.nonparam_match_empirical_cdf(
1499-
R_f_new, R_pm_blended
1573+
R_f_new, R_pm_resampled
15001574
)
1575+
R_pm_resampled = None
15011576
elif probmatching_method == "mean":
15021577
# Use R_pm_blended as benchmark field and
1503-
mu_0 = np.mean(R_pm_blended[R_pm_blended >= precip_thr])
1578+
mu_0 = np.mean(R_pm_resampled[R_pm_resampled >= precip_thr])
15041579
MASK = R_f_new >= precip_thr
15051580
mu_fct = np.mean(R_f_new[MASK])
15061581
R_f_new[MASK] = R_f_new[MASK] - mu_fct + mu_0
1582+
R_pm_resampled = None
15071583

15081584
R_f_out.append(R_f_new)
15091585

@@ -1666,7 +1742,7 @@ def calculate_weights_spn(correlations, cov):
16661742
if isinstance(cov, type(None)):
16671743
raise ValueError("cov must contain a covariance matrix")
16681744
else:
1669-
# Make a numpy matrix out of cov and get the inverse
1745+
# Make a numpy array out of cov and get the inverse
16701746
cov = np.where(cov == 0.0, 10e-5, cov)
16711747
# Make sure the determinant of the matrix is not zero, otherwise
16721748
# subtract 10e-5 from the cross-correlations between the models
@@ -1675,26 +1751,30 @@ def calculate_weights_spn(correlations, cov):
16751751
# Ensure the correlation of the model with itself is always 1.0
16761752
for i, _ in enumerate(cov):
16771753
cov[i][i] = 1.0
1678-
# Make a numpy matrix out of the array
1679-
cov_matrix = np.asmatrix(cov)
1680-
# Get the inverse of the matrix
1681-
cov_matrix_inv = cov_matrix.getI()
1682-
# The component weights are the dot product between cov_matrix_inv
1683-
# and cor_vec
1684-
weights = cov_matrix_inv.dot(correlations)
1754+
# Use a numpy array instead of a matrix
1755+
cov_matrix = np.array(cov)
1756+
# Get the inverse of the matrix using scipy's inv function
1757+
cov_matrix_inv = inv(cov_matrix)
1758+
# The component weights are the dot product between cov_matrix_inv and cor_vec
1759+
weights = np.dot(cov_matrix_inv, correlations)
16851760
weights = np.nan_to_num(
16861761
weights, copy=True, nan=10e-5, posinf=10e-5, neginf=10e-5
16871762
)
1763+
weights_dot_correlations = np.dot(weights, correlations)
16881764
# If the dot product of the weights with the correlations is
16891765
# larger than 1.0, we assign a weight of 0.0 to the noise (to make
16901766
# it numerically stable)
1691-
if weights.dot(correlations) > 1.0:
1767+
if weights_dot_correlations > 1.0:
16921768
noise_weight = np.array([0])
16931769
# Calculate the noise weight
16941770
else:
1695-
noise_weight = np.asarray(np.sqrt(1.0 - weights.dot(correlations)))[0]
1771+
noise_weight = np.sqrt(1.0 - weights_dot_correlations)
1772+
# Convert weights to a 1D array
1773+
weights = np.array(weights).flatten()
1774+
# Ensure noise_weight is a 1D array before concatenation
1775+
noise_weight = np.array(noise_weight).flatten()
16961776
# Finally, add the noise_weights to the weights variable.
1697-
weights = np.concatenate((np.array(weights)[0], noise_weight), axis=0)
1777+
weights = np.concatenate((weights, noise_weight), axis=0)
16981778

16991779
# Otherwise, the weight equals the correlation on that scale level and
17001780
# the noise component weight equals 1 - this weight. This only occurs for
@@ -1808,7 +1888,7 @@ def _check_inputs(
18081888
if isinstance(timesteps, list) and not sorted(timesteps) == timesteps:
18091889
raise ValueError("timesteps is not in ascending order")
18101890
if isinstance(timesteps, list):
1811-
if precip_models.shape[1] != len(timesteps) + 1:
1891+
if precip_models.shape[1] != math.ceil(timesteps[-1]) + 1:
18121892
raise ValueError(
18131893
"precip_models does not contain sufficient lead times for this forecast"
18141894
)

0 commit comments

Comments
 (0)