disdrodb 0.1.2__py3-none-any.whl → 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. disdrodb/__init__.py +68 -34
  2. disdrodb/_config.py +5 -4
  3. disdrodb/_version.py +16 -3
  4. disdrodb/accessor/__init__.py +20 -0
  5. disdrodb/accessor/methods.py +125 -0
  6. disdrodb/api/checks.py +177 -24
  7. disdrodb/api/configs.py +3 -3
  8. disdrodb/api/info.py +13 -13
  9. disdrodb/api/io.py +281 -22
  10. disdrodb/api/path.py +184 -195
  11. disdrodb/api/search.py +18 -9
  12. disdrodb/cli/disdrodb_create_summary.py +103 -0
  13. disdrodb/cli/disdrodb_create_summary_station.py +91 -0
  14. disdrodb/cli/disdrodb_run_l0.py +1 -1
  15. disdrodb/cli/disdrodb_run_l0_station.py +1 -1
  16. disdrodb/cli/disdrodb_run_l0a_station.py +1 -1
  17. disdrodb/cli/disdrodb_run_l0b.py +1 -1
  18. disdrodb/cli/disdrodb_run_l0b_station.py +3 -3
  19. disdrodb/cli/disdrodb_run_l0c.py +1 -1
  20. disdrodb/cli/disdrodb_run_l0c_station.py +3 -3
  21. disdrodb/cli/disdrodb_run_l1_station.py +2 -2
  22. disdrodb/cli/disdrodb_run_l2e_station.py +2 -2
  23. disdrodb/cli/disdrodb_run_l2m_station.py +2 -2
  24. disdrodb/configs.py +149 -4
  25. disdrodb/constants.py +61 -0
  26. disdrodb/data_transfer/download_data.py +127 -11
  27. disdrodb/etc/configs/attributes.yaml +339 -0
  28. disdrodb/etc/configs/encodings.yaml +473 -0
  29. disdrodb/etc/products/L1/global.yaml +13 -0
  30. disdrodb/etc/products/L2E/10MIN.yaml +12 -0
  31. disdrodb/etc/products/L2E/1MIN.yaml +1 -0
  32. disdrodb/etc/products/L2E/global.yaml +22 -0
  33. disdrodb/etc/products/L2M/10MIN.yaml +12 -0
  34. disdrodb/etc/products/L2M/GAMMA_ML.yaml +8 -0
  35. disdrodb/etc/products/L2M/NGAMMA_GS_LOG_ND_MAE.yaml +6 -0
  36. disdrodb/etc/products/L2M/NGAMMA_GS_ND_MAE.yaml +6 -0
  37. disdrodb/etc/products/L2M/NGAMMA_GS_Z_MAE.yaml +6 -0
  38. disdrodb/etc/products/L2M/global.yaml +26 -0
  39. disdrodb/issue/writer.py +2 -0
  40. disdrodb/l0/__init__.py +13 -0
  41. disdrodb/l0/configs/LPM/l0b_cf_attrs.yml +4 -4
  42. disdrodb/l0/configs/PARSIVEL/l0b_cf_attrs.yml +1 -1
  43. disdrodb/l0/configs/PARSIVEL/l0b_encodings.yml +3 -3
  44. disdrodb/l0/configs/PARSIVEL/raw_data_format.yml +1 -1
  45. disdrodb/l0/configs/PARSIVEL2/l0b_cf_attrs.yml +5 -5
  46. disdrodb/l0/configs/PARSIVEL2/l0b_encodings.yml +3 -3
  47. disdrodb/l0/configs/PARSIVEL2/raw_data_format.yml +1 -1
  48. disdrodb/l0/configs/PWS100/l0b_cf_attrs.yml +4 -4
  49. disdrodb/l0/configs/PWS100/raw_data_format.yml +1 -1
  50. disdrodb/l0/l0a_processing.py +37 -32
  51. disdrodb/l0/l0b_nc_processing.py +118 -8
  52. disdrodb/l0/l0b_processing.py +30 -65
  53. disdrodb/l0/l0c_processing.py +369 -259
  54. disdrodb/l0/readers/LPM/ARM/ARM_LPM.py +7 -0
  55. disdrodb/l0/readers/LPM/NETHERLANDS/DELFT_LPM_NC.py +66 -0
  56. disdrodb/l0/readers/LPM/SLOVENIA/{CRNI_VRH.py → UL.py} +3 -0
  57. disdrodb/l0/readers/LPM/SWITZERLAND/INNERERIZ_LPM.py +195 -0
  58. disdrodb/l0/readers/PARSIVEL/GPM/PIERS.py +0 -2
  59. disdrodb/l0/readers/PARSIVEL/JAPAN/JMA.py +4 -1
  60. disdrodb/l0/readers/PARSIVEL/NCAR/PECAN_MOBILE.py +1 -1
  61. disdrodb/l0/readers/PARSIVEL/NCAR/VORTEX2_2009.py +1 -1
  62. disdrodb/l0/readers/PARSIVEL2/ARM/ARM_PARSIVEL2.py +4 -0
  63. disdrodb/l0/readers/PARSIVEL2/BELGIUM/ILVO.py +168 -0
  64. disdrodb/l0/readers/PARSIVEL2/CANADA/UQAM_NC.py +69 -0
  65. disdrodb/l0/readers/PARSIVEL2/DENMARK/DTU.py +165 -0
  66. disdrodb/l0/readers/PARSIVEL2/FINLAND/FMI_PARSIVEL2.py +69 -0
  67. disdrodb/l0/readers/PARSIVEL2/FRANCE/ENPC_PARSIVEL2.py +255 -134
  68. disdrodb/l0/readers/PARSIVEL2/FRANCE/OSUG.py +525 -0
  69. disdrodb/l0/readers/PARSIVEL2/FRANCE/SIRTA_PARSIVEL2.py +1 -1
  70. disdrodb/l0/readers/PARSIVEL2/GPM/GCPEX.py +9 -7
  71. disdrodb/l0/readers/PARSIVEL2/KIT/BURKINA_FASO.py +1 -1
  72. disdrodb/l0/readers/PARSIVEL2/KIT/TEAMX.py +123 -0
  73. disdrodb/l0/readers/PARSIVEL2/{NETHERLANDS/DELFT.py → MPI/BCO_PARSIVEL2.py} +41 -71
  74. disdrodb/l0/readers/PARSIVEL2/MPI/BOWTIE.py +220 -0
  75. disdrodb/l0/readers/PARSIVEL2/NASA/APU.py +120 -0
  76. disdrodb/l0/readers/PARSIVEL2/NASA/LPVEX.py +109 -0
  77. disdrodb/l0/readers/PARSIVEL2/NCAR/FARM_PARSIVEL2.py +1 -0
  78. disdrodb/l0/readers/PARSIVEL2/NCAR/PECAN_FP3.py +1 -1
  79. disdrodb/l0/readers/PARSIVEL2/NCAR/PERILS_MIPS.py +126 -0
  80. disdrodb/l0/readers/PARSIVEL2/NCAR/PERILS_PIPS.py +165 -0
  81. disdrodb/l0/readers/PARSIVEL2/NCAR/VORTEX_SE_2016_P2.py +1 -1
  82. disdrodb/l0/readers/PARSIVEL2/NCAR/VORTEX_SE_2016_PIPS.py +20 -12
  83. disdrodb/l0/readers/PARSIVEL2/NETHERLANDS/DELFT_NC.py +5 -0
  84. disdrodb/l0/readers/PARSIVEL2/SPAIN/CENER.py +144 -0
  85. disdrodb/l0/readers/PARSIVEL2/SPAIN/CR1000DL.py +201 -0
  86. disdrodb/l0/readers/PARSIVEL2/SPAIN/LIAISE.py +137 -0
  87. disdrodb/l0/readers/PARSIVEL2/USA/C3WE.py +146 -0
  88. disdrodb/l0/readers/PWS100/FRANCE/ENPC_PWS100.py +105 -99
  89. disdrodb/l0/readers/PWS100/FRANCE/ENPC_PWS100_SIRTA.py +151 -0
  90. disdrodb/l1/__init__.py +5 -0
  91. disdrodb/l1/fall_velocity.py +46 -0
  92. disdrodb/l1/filters.py +34 -20
  93. disdrodb/l1/processing.py +46 -45
  94. disdrodb/l1/resampling.py +77 -66
  95. disdrodb/l1_env/routines.py +18 -3
  96. disdrodb/l2/__init__.py +7 -0
  97. disdrodb/l2/empirical_dsd.py +58 -10
  98. disdrodb/l2/processing.py +268 -117
  99. disdrodb/metadata/checks.py +132 -125
  100. disdrodb/metadata/standards.py +3 -1
  101. disdrodb/psd/fitting.py +631 -345
  102. disdrodb/psd/models.py +9 -6
  103. disdrodb/routines/__init__.py +54 -0
  104. disdrodb/{l0/routines.py → routines/l0.py} +316 -355
  105. disdrodb/{l1/routines.py → routines/l1.py} +76 -116
  106. disdrodb/routines/l2.py +1019 -0
  107. disdrodb/{routines.py → routines/wrappers.py} +98 -10
  108. disdrodb/scattering/__init__.py +16 -4
  109. disdrodb/scattering/axis_ratio.py +61 -37
  110. disdrodb/scattering/permittivity.py +504 -0
  111. disdrodb/scattering/routines.py +746 -184
  112. disdrodb/summary/__init__.py +17 -0
  113. disdrodb/summary/routines.py +4196 -0
  114. disdrodb/utils/archiving.py +434 -0
  115. disdrodb/utils/attrs.py +68 -125
  116. disdrodb/utils/cli.py +5 -5
  117. disdrodb/utils/compression.py +30 -1
  118. disdrodb/utils/dask.py +121 -9
  119. disdrodb/utils/dataframe.py +61 -7
  120. disdrodb/utils/decorators.py +31 -0
  121. disdrodb/utils/directories.py +35 -15
  122. disdrodb/utils/encoding.py +37 -19
  123. disdrodb/{l2 → utils}/event.py +15 -173
  124. disdrodb/utils/logger.py +14 -7
  125. disdrodb/utils/manipulations.py +81 -0
  126. disdrodb/utils/routines.py +166 -0
  127. disdrodb/utils/subsetting.py +214 -0
  128. disdrodb/utils/time.py +35 -177
  129. disdrodb/utils/writer.py +20 -7
  130. disdrodb/utils/xarray.py +5 -4
  131. disdrodb/viz/__init__.py +13 -0
  132. disdrodb/viz/plots.py +398 -0
  133. {disdrodb-0.1.2.dist-info → disdrodb-0.1.4.dist-info}/METADATA +4 -3
  134. {disdrodb-0.1.2.dist-info → disdrodb-0.1.4.dist-info}/RECORD +139 -98
  135. {disdrodb-0.1.2.dist-info → disdrodb-0.1.4.dist-info}/entry_points.txt +2 -0
  136. disdrodb/l1/encoding_attrs.py +0 -642
  137. disdrodb/l2/processing_options.py +0 -213
  138. disdrodb/l2/routines.py +0 -868
  139. /disdrodb/l0/readers/PARSIVEL/SLOVENIA/{UL_FGG.py → UL.py} +0 -0
  140. {disdrodb-0.1.2.dist-info → disdrodb-0.1.4.dist-info}/WHEEL +0 -0
  141. {disdrodb-0.1.2.dist-info → disdrodb-0.1.4.dist-info}/licenses/LICENSE +0 -0
  142. {disdrodb-0.1.2.dist-info → disdrodb-0.1.4.dist-info}/top_level.txt +0 -0
disdrodb/psd/fitting.py CHANGED
@@ -20,98 +20,150 @@ import scipy.stats as ss
20
20
  import xarray as xr
21
21
  from scipy.integrate import quad
22
22
  from scipy.optimize import minimize
23
- from scipy.special import gamma, gammainc, gammaln # Regularized lower incomplete gamma function
24
-
23
+ from scipy.special import gamma, gammaln # Regularized lower incomplete gamma function
24
+
25
+ from disdrodb.constants import DIAMETER_DIMENSION
26
+ from disdrodb.l1.fall_velocity import get_dataset_fall_velocity
27
+ from disdrodb.l2.empirical_dsd import (
28
+ get_median_volume_drop_diameter,
29
+ get_moment,
30
+ get_normalized_intercept_parameter_from_moments,
31
+ get_total_number_concentration,
32
+ )
25
33
  from disdrodb.psd.models import ExponentialPSD, GammaPSD, LognormalPSD, NormalizedGammaPSD
34
+ from disdrodb.utils.manipulations import get_diameter_bin_edges
26
35
  from disdrodb.utils.warnings import suppress_warnings
27
36
 
28
37
  # gamma(>171) return inf !
29
38
 
39
+ ####--------------------------------------------------------------------------------------.
40
+ #### Notes
41
+ ## Variable requirements for fitting PSD Models
42
+ # - drop_number_concentration and diameter coordinates
43
+ # - Always recompute other parameters to ensure not use model parameters of L2M
44
+
45
+ # ML: None
46
+
47
+ # MOM: moments
48
+ # --> get_moment(drop_number_concentration, diameter, diameter_bin_width, moment)
49
+
50
+ # GS: fall_velocity if target optimization is R (rain)
51
+ # - NormalizedGamma: "Nw", "D50"
52
+ # --> get_normalized_intercept_parameter_from_moments(moment_3, moment_4)
53
+ # --> get_median_volume_drop_diameter(drop_number_concentration, diameter, diameter_bin_width):
54
+ # --> get_mean_volume_drop_diameter(moment_3, moment_4) (Dm)
55
+
56
+ # - LogNormal,Exponential, Gamma: Nt
57
+ # --> get_total_number_concentration(drop_number_concentration, diameter_bin_width)
58
+
30
59
 
31
60
  ####--------------------------------------------------------------------------------------.
32
61
  #### Goodness of fit (GOF)
33
- def compute_gof_stats(drop_number_concentration, psd):
62
+ def compute_gof_stats(obs, pred, dim=DIAMETER_DIMENSION):
34
63
  """
35
- Compute various goodness-of-fit (GoF) statistics between observed and predicted values.
64
+ Compute various goodness-of-fit (GoF) statistics between obs and predicted values.
36
65
 
37
66
  Parameters
38
67
  ----------
39
- - drop_number_concentration: xarray.DataArray with dimensions ('time', 'diameter_bin_center')
40
- - psd: instance of PSD class
68
+ obs: xarray.DataArray
69
+ Observations DataArray with at least dimension ``dim``.
70
+ pred: xarray.DataArray
71
+ Predictions DataArray with at least dimension ``dim``.
72
+ dim: str
73
+ DataArray dimension over which to compute GOF statistics.
74
+ The default is DIAMETER_DIMENSION.
41
75
 
42
76
  Returns
43
77
  -------
44
- - ds: xarray.Dataset containing the computed GoF statistics
78
+ ds: xarray.Dataset
79
+ Dataset containing the computed GoF statistics.
45
80
  """
46
81
  from disdrodb.l2.empirical_dsd import get_mode_diameter
47
82
 
48
- # Retrieve diameter bin width
49
- diameter = drop_number_concentration["diameter_bin_center"]
50
- diameter_bin_width = drop_number_concentration["diameter_bin_width"]
83
+ # Retrieve diameter and diameter bin width
84
+ diameter = obs["diameter_bin_center"]
85
+ diameter_bin_width = obs["diameter_bin_width"]
86
+
87
+ # Compute errors
88
+ error = obs - pred
51
89
 
52
- # Define observed and predicted values and compute errors
53
- observed_values = drop_number_concentration
54
- fitted_values = psd(diameter) # .transpose(*observed_values.dims)
55
- error = observed_values - fitted_values
90
+ # Compute max obs and pred
91
+ obs_max = obs.max(dim=dim, skipna=False)
92
+ pred_max = pred.max(dim=dim, skipna=False)
93
+
94
+ # Compute NaN mask
95
+ mask_nan = np.logical_or(np.isnan(obs_max), np.isnan(pred_max))
56
96
 
57
97
  # Compute GOF statistics
58
98
  with suppress_warnings():
59
- # Compute Pearson correlation
60
- pearson_r = xr.corr(observed_values, fitted_values, dim="diameter_bin_center")
61
-
62
- # Compute MSE
63
- mse = (error**2).mean(dim="diameter_bin_center")
99
+ # Compute Pearson Correlation
100
+ pearson_r = xr.corr(obs, pred, dim=dim)
101
+
102
+ # Compute Mean Absolute Error (MAE)
103
+ mae = np.abs(error).mean(dim=dim, skipna=False)
104
+
105
+ # Compute maximum absolute error
106
+ max_error = np.abs(error).max(dim=dim, skipna=False)
107
+ relative_max_error = xr.where(max_error == 0, 0, xr.where(obs_max == 0, np.nan, max_error / obs_max))
108
+
109
+ # Compute deviation of N(D) at distribution mode
110
+ mode_deviation = obs_max - pred_max
111
+ mode_relative_deviation = xr.where(
112
+ mode_deviation == 0,
113
+ 0,
114
+ xr.where(obs_max == 0, np.nan, mode_deviation / obs_max),
115
+ )
64
116
 
65
- # Compute maximum error
66
- max_error = error.max(dim="diameter_bin_center")
67
- relative_max_error = error.max(dim="diameter_bin_center") / observed_values.max(dim="diameter_bin_center")
117
+ # Compute diameter difference of the distribution mode
118
+ diameter_mode_pred = get_mode_diameter(pred, diameter)
119
+ diameter_mode_obs = get_mode_diameter(obs, diameter)
120
+ diameter_mode_deviation = diameter_mode_obs - diameter_mode_pred
68
121
 
69
122
  # Compute difference in total number concentration
70
- total_number_concentration_obs = (observed_values * diameter_bin_width).sum(dim="diameter_bin_center")
71
- total_number_concentration_pred = (fitted_values * diameter_bin_width).sum(dim="diameter_bin_center")
123
+ total_number_concentration_obs = (obs * diameter_bin_width).sum(dim=dim, skipna=False)
124
+ total_number_concentration_pred = (pred * diameter_bin_width).sum(dim=dim, skipna=False)
72
125
  total_number_concentration_difference = total_number_concentration_pred - total_number_concentration_obs
73
126
 
74
127
  # Compute Kullback-Leibler divergence
75
128
  # - Compute pdf per bin
76
- pk_pdf = observed_values / total_number_concentration_obs
77
- qk_pdf = fitted_values / total_number_concentration_pred
129
+ pk_pdf = obs / total_number_concentration_obs
130
+ qk_pdf = pred / total_number_concentration_pred
78
131
 
79
132
  # - Compute probabilities per bin
80
133
  pk = pk_pdf * diameter_bin_width
81
- pk = pk / pk.sum(dim="diameter_bin_center") # this might not be necessary
134
+ pk = pk / pk.sum(dim=dim, skipna=False) # this might not be necessary
82
135
  qk = qk_pdf * diameter_bin_width
83
- qk = qk / qk.sum(dim="diameter_bin_center") # this might not be necessary
136
+ qk = qk / qk.sum(dim=dim, skipna=False) # this might not be necessary
84
137
 
85
- # - Compute divergence
138
+ # - Compute log probability ratio
139
+ epsilon = 1e-10
140
+ pk = xr.where(pk == 0, epsilon, pk)
141
+ qk = xr.where(qk == 0, epsilon, qk)
86
142
  log_prob_ratio = np.log(pk / qk)
87
143
  log_prob_ratio = log_prob_ratio.where(np.isfinite(log_prob_ratio))
88
- kl_divergence = (pk * log_prob_ratio).sum(dim="diameter_bin_center")
89
144
 
90
- # Other statistics that can be computed also from different diameter discretization
91
- # - Compute max deviation at distribution mode
92
- max_deviation = observed_values.max(dim="diameter_bin_center") - fitted_values.max(dim="diameter_bin_center")
93
- max_relative_deviation = max_deviation / fitted_values.max(dim="diameter_bin_center")
94
-
95
- # - Compute diameter difference of the distribution mode
96
- diameter_mode_deviation = get_mode_diameter(observed_values, diameter) - get_mode_diameter(
97
- fitted_values,
98
- diameter,
99
- )
145
+ # - Compute divergence
146
+ kl_divergence = (pk * log_prob_ratio).sum(dim=dim, skipna=False)
147
+ kl_divergence = xr.where((error == 0).all(dim=dim), 0, kl_divergence)
100
148
 
101
149
  # Create an xarray.Dataset to hold the computed statistics
102
150
  ds = xr.Dataset(
103
151
  {
104
- "r2": pearson_r**2, # Squared Pearson correlation coefficient
105
- "mse": mse, # Mean Squared Error
106
- "max_error": max_error, # Maximum Absolute Error
107
- "relative_max_error": relative_max_error, # Relative Maximum Error
108
- "total_number_concentration_difference": total_number_concentration_difference,
109
- "kl_divergence": kl_divergence, # Kullback-Leibler divergence
110
- "max_deviation": max_deviation, # Deviation at distribution mode
111
- "max_relative_deviation": max_relative_deviation, # Relative deviation at mode
112
- "diameter_mode_deviation": diameter_mode_deviation, # Difference in mode diameters
152
+ "R2": pearson_r**2, # Squared Pearson correlation coefficient
153
+ "MAE": mae, # Mean Absolute Error
154
+ "MaxAE": max_error, # Maximum Absolute Error
155
+ "RelMaxAE": relative_max_error, # Relative Maximum Absolute Error
156
+ "PeakDiff": mode_deviation, # Difference at distribution peak
157
+ "RelPeakDiff": mode_relative_deviation, # Relative difference at peak
158
+ "DmodeDiff": diameter_mode_deviation, # Difference in mode diameters
159
+ "NtDiff": total_number_concentration_difference,
160
+ "KLDiv": kl_divergence, # Kullback-Leibler divergence
113
161
  },
114
162
  )
163
+ # Round
164
+ ds = ds.round(2)
165
+ # Mask where input obs or pred is NaN
166
+ ds = ds.where(~mask_nan)
115
167
  return ds
116
168
 
117
169
 
@@ -176,13 +228,13 @@ def get_expected_probabilities(params, cdf_func, pdf_func, bin_edges, probabilit
176
228
  def get_adjusted_nt(cdf, params, Nt, bin_edges):
177
229
  """Adjust Nt for the proportion of missing drops. See Johnson's et al., 2013 Eqs. 3 and 4."""
178
230
  # Estimate proportion of missing drops (Johnson's 2011 Eqs. 3)
179
- # --> Alternative: p = 1 - np.sum(pdf(diameter, params)* diameter_bin_width) # [-]
231
+ # --> Alternative:
232
+ # - p = 1 - np.sum(pdf(diameter, params)* diameter_bin_width) # [-]
233
+ # - p = 1 - np.sum((Lambda ** (mu + 1)) / gamma(mu + 1) * D**mu * np.exp(-Lambda * D) * diameter_bin_width) # [-]
180
234
  p = 1 - np.diff(cdf([bin_edges[0], bin_edges[-1]], params)).item() # [-]
181
- # Adjusts Nt for the proportion of drops not observed
182
- # p = np.clip(p, 0, 1 - 1e-12)
183
- if np.isclose(p, 1, atol=1e-12):
184
- return np.nan
185
- return Nt / (1 - p) # [m-3]
235
+ # Adjusts Nt for the proportion of missing drops
236
+ nt_adj = np.nan if np.isclose(p, 1, atol=1e-12) else Nt / (1 - p) # [m-3]
237
+ return nt_adj
186
238
 
187
239
 
188
240
  def compute_negative_log_likelihood(
@@ -206,7 +258,7 @@ def compute_negative_log_likelihood(
206
258
  bin_edges : array-like
207
259
  Edges of the bins (length N+1).
208
260
  counts : array-like
209
- Observed counts in each bin (length N).
261
+ obs counts in each bin (length N).
210
262
  cdf_func : callable
211
263
  Cumulative distribution function of the distribution.
212
264
  pdf_func : callable
@@ -259,6 +311,8 @@ def compute_negative_log_likelihood(
259
311
 
260
312
  def estimate_lognormal_parameters(
261
313
  counts,
314
+ mu,
315
+ sigma,
262
316
  bin_edges,
263
317
  probability_method="cdf",
264
318
  likelihood="multinomial",
@@ -273,6 +327,12 @@ def estimate_lognormal_parameters(
273
327
  ----------
274
328
  counts : array-like
275
329
  The counts for each bin in the histogram.
330
+ mu: float
331
+ The initial guess of the mean of the log of the distribution.
332
+ A good default value is 0.
333
+ sigma: float
334
+ The initial guess of the standard deviation of the log distribution.
335
+ A good default value is 1.
276
336
  bin_edges : array-like
277
337
  The edges of the bins.
278
338
  probability_method : str, optional
@@ -306,9 +366,9 @@ def estimate_lognormal_parameters(
306
366
  ----------
307
367
  .. [1] https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.lognorm.html#scipy.stats.lognorm
308
368
  """
309
- # LogNormal
310
- # - mu = log(scale)
311
- # - loc = 0
369
+ # Definite initial guess for the parameters
370
+ scale = np.exp(mu) # mu = np.log(scale)
371
+ initial_params = [sigma, scale]
312
372
 
313
373
  # Initialize bad results
314
374
  null_output = (
@@ -329,9 +389,6 @@ def estimate_lognormal_parameters(
329
389
  sigma, scale = params
330
390
  return sigma > 0 and scale > 0
331
391
 
332
- # Definite initial guess for the parameters
333
- initial_params = [1.0, 1.0] # sigma, scale
334
-
335
392
  # Define bounds for sigma and scale
336
393
  bounds = [(1e-6, None), (1e-6, None)]
337
394
 
@@ -375,6 +432,7 @@ def estimate_lognormal_parameters(
375
432
 
376
433
  def estimate_exponential_parameters(
377
434
  counts,
435
+ Lambda,
378
436
  bin_edges,
379
437
  probability_method="cdf",
380
438
  likelihood="multinomial",
@@ -389,6 +447,10 @@ def estimate_exponential_parameters(
389
447
  ----------
390
448
  counts : array-like
391
449
  The counts for each bin in the histogram.
450
+ Lambda : float
451
+ The initial guess of the scale parameter.
452
+ scale = 1 / lambda correspond to the scale parameter of the scipy.stats.expon distribution.
453
+ A good default value is 1.
392
454
  bin_edges : array-like
393
455
  The edges of the bins.
394
456
  probability_method : str, optional
@@ -421,6 +483,10 @@ def estimate_exponential_parameters(
421
483
  ----------
422
484
  .. [1] https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.expon.html
423
485
  """
486
+ # Definite initial guess for parameters
487
+ scale = 1 / Lambda
488
+ initial_params = [scale]
489
+
424
490
  # Initialize bad results
425
491
  null_output = {"N0": np.nan, "Lambda": np.nan} if output_dictionary else np.array([np.nan, np.nan])
426
492
 
@@ -438,9 +504,6 @@ def estimate_exponential_parameters(
438
504
  scale = params[0]
439
505
  return scale > 0
440
506
 
441
- # Definite initial guess for the scale parameter
442
- initial_params = [1.0] # scale
443
-
444
507
  # Define bounds for scale
445
508
  bounds = [(1e-6, None)]
446
509
 
@@ -485,8 +548,8 @@ def estimate_exponential_parameters(
485
548
 
486
549
  def estimate_gamma_parameters(
487
550
  counts,
488
- a,
489
- scale,
551
+ mu,
552
+ Lambda,
490
553
  bin_edges,
491
554
  probability_method="cdf",
492
555
  likelihood="multinomial",
@@ -501,11 +564,13 @@ def estimate_gamma_parameters(
501
564
  ----------
502
565
  counts : array-like
503
566
  The counts for each bin in the histogram.
504
- a: float
505
- The shape parameter of the scipy.stats.gamma distribution.
506
- A good default value is 1.
507
- scale: float
508
- The scale parameter of the scipy.stats.gamma distribution.
567
+ mu: float
568
+ The initial guess of the shape parameter.
569
+ a = mu + 1 correspond to the shape parameter of the scipy.stats.gamma distribution.
570
+ A good default value is 0.
571
+ lambda: float
572
+ The initial guess of the scale parameter.
573
+ scale = 1 / lambda correspond to the scale parameter of the scipy.stats.gamma distribution.
509
574
  A good default value is 1.
510
575
  bin_edges : array-like
511
576
  The edges of the bins.
@@ -541,6 +606,11 @@ def estimate_gamma_parameters(
541
606
  .. [1] https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.gamma.html
542
607
 
543
608
  """
609
+ # Define initial guess for parameters
610
+ a = mu + 1 # (mu = a-1, a = mu+1)
611
+ scale = 1 / Lambda
612
+ initial_params = [a, scale]
613
+
544
614
  # Initialize bad results
545
615
  null_output = (
546
616
  {"N0": np.nan, "mu": np.nan, "lambda": np.nan} if output_dictionary else np.array([np.nan, np.nan, np.nan])
@@ -561,9 +631,6 @@ def estimate_gamma_parameters(
561
631
  a, scale = params
562
632
  return a > 0.1 and scale > 0 # using a > 0 cause some troubles
563
633
 
564
- # Definite initial guess for the parameters
565
- initial_params = [a, scale] # (mu=a-1, a=mu+1)
566
-
567
634
  # Define bounds for a and scale
568
635
  bounds = [(1e-6, None), (1e-6, None)]
569
636
 
@@ -603,7 +670,7 @@ def estimate_gamma_parameters(
603
670
 
604
671
  # Compute N0
605
672
  # - Use logarithmic computations to prevent overflow
606
- # - N0 = Nt * Lambda ** (mu + 1) / gamma(mu + 1)
673
+ # - N0 = Nt * Lambda ** (mu + 1) / gamma(mu + 1) # [m-3 * mm^(-mu-1)]
607
674
  with suppress_warnings():
608
675
  log_N0 = np.log(Nt) + (mu + 1) * np.log(Lambda) - gammaln(mu + 1)
609
676
  N0 = np.exp(log_N0)
@@ -617,12 +684,59 @@ def estimate_gamma_parameters(
617
684
  return output
618
685
 
619
686
 
687
+ def _get_initial_lognormal_parameters(ds, mom_method=None):
688
+ default_mu = 0 # mu = np.log(scale)
689
+ default_sigma = 1
690
+ if mom_method is None or mom_method == "None":
691
+ ds_init = xr.Dataset(
692
+ {
693
+ "mu": default_mu,
694
+ "sigma": default_sigma,
695
+ },
696
+ )
697
+ else:
698
+ ds_init = get_mom_parameters(
699
+ ds=ds,
700
+ psd_model="LognormalPSD",
701
+ mom_methods=mom_method,
702
+ )
703
+ # If initialization results in some not finite number, set default value
704
+ ds_init["mu"] = xr.where(
705
+ np.logical_and(np.isfinite(ds_init["mu"]), ds_init["mu"] > 0),
706
+ ds_init["mu"],
707
+ default_mu,
708
+ )
709
+ ds_init["sigma"] = xr.where(np.isfinite(ds_init["sigma"]), ds_init["sigma"], default_sigma)
710
+ return ds_init
711
+
712
+
713
+ def _get_initial_exponential_parameters(ds, mom_method=None):
714
+ default_lambda = 1 # lambda = 1 /scale
715
+ if mom_method is None or mom_method == "None":
716
+ ds_init = xr.Dataset(
717
+ {
718
+ "Lambda": default_lambda,
719
+ },
720
+ )
721
+ else:
722
+ ds_init = get_mom_parameters(
723
+ ds=ds,
724
+ psd_model="ExponentialPSD",
725
+ mom_methods=mom_method,
726
+ )
727
+ # If initialization results in some not finite number, set default value
728
+ ds_init["Lambda"] = xr.where(np.isfinite(ds_init["Lambda"]), ds_init["Lambda"], default_lambda)
729
+ return ds_init
730
+
731
+
620
732
  def _get_initial_gamma_parameters(ds, mom_method=None):
621
- if mom_method is None:
733
+ default_mu = 0 # a = mu + 1 | mu = a - 1
734
+ default_lambda = 1 # scale = 1 / Lambda
735
+ if mom_method is None or mom_method == "None":
622
736
  ds_init = xr.Dataset(
623
737
  {
624
- "a": xr.ones_like(ds["M1"]),
625
- "scale": xr.ones_like(ds["M1"]),
738
+ "mu": default_mu,
739
+ "Lambda": default_lambda,
626
740
  },
627
741
  )
628
742
  else:
@@ -631,11 +745,13 @@ def _get_initial_gamma_parameters(ds, mom_method=None):
631
745
  psd_model="GammaPSD",
632
746
  mom_methods=mom_method,
633
747
  )
634
- ds_init["a"] = ds_init["mu"] + 1
635
- ds_init["scale"] = 1 / ds_init["Lambda"]
636
748
  # If initialization results in some not finite number, set default value
637
- ds_init["a"] = xr.where(np.isfinite(ds_init["a"]), ds_init["a"], ds["M1"])
638
- ds_init["scale"] = xr.where(np.isfinite(ds_init["scale"]), ds_init["scale"], ds["M1"])
749
+ ds_init["mu"] = xr.where(
750
+ np.logical_and(np.isfinite(ds_init["mu"]), ds_init["mu"] > -1),
751
+ ds_init["mu"],
752
+ default_mu,
753
+ )
754
+ ds_init["Lambda"] = xr.where(np.isfinite(ds_init["Lambda"]), ds_init["Lambda"], default_lambda)
639
755
  return ds_init
640
756
 
641
757
 
@@ -663,13 +779,14 @@ def get_gamma_parameters(
663
779
  with the specified mom_method.
664
780
  init_method: str or list
665
781
  The method(s) of moments used to initialize the gamma parameters.
666
- If None, the scale parameter is set to 1 and mu to 0 (a=1).
782
+ If None (or 'None'), the scale parameter is set to 1 and mu to 0 (a=1).
667
783
  probability_method : str, optional
668
784
  Method to compute probabilities. The default value is ``cdf``.
669
785
  likelihood : str, optional
670
786
  Likelihood function to use for fitting. The default value is ``multinomial``.
671
787
  truncated_likelihood : bool, optional
672
788
  Whether to use truncated likelihood. The default value is ``True``.
789
+ See Johnson et al., 2011 and 2011 for more information.
673
790
  optimizer : str, optional
674
791
  Optimization method to use. The default value is ``Nelder-Mead``.
675
792
 
@@ -687,12 +804,21 @@ def get_gamma_parameters(
687
804
  The function uses `xr.apply_ufunc` to fit the lognormal distribution parameters
688
805
  in parallel, leveraging Dask for parallel computation.
689
806
 
807
+ References
808
+ ----------
809
+ Johnson, R. W., D. V. Kliche, and P. L. Smith, 2011: Comparison of Estimators for Parameters of Gamma Distributions
810
+ with Left-Truncated Samples. J. Appl. Meteor. Climatol., 50, 296-310, https://doi.org/10.1175/2010JAMC2478.1
811
+
812
+ Johnson, R.W., Kliche, D., & Smith, P.L. (2010).
813
+ Maximum likelihood estimation of gamma parameters for coarsely binned and truncated raindrop size data.
814
+ Quarterly Journal of the Royal Meteorological Society, 140. DOI:10.1002/qj.2209
815
+
690
816
  """
691
817
  # Define inputs
692
818
  counts = ds["drop_number_concentration"] * ds["diameter_bin_width"]
693
- diameter_breaks = np.append(ds["diameter_bin_lower"].data, ds["diameter_bin_upper"].data[-1])
819
+ diameter_breaks = get_diameter_bin_edges(ds)
694
820
 
695
- # Define initial parameters (a, scale)
821
+ # Define initial parameters (mu, Lambda)
696
822
  ds_init = _get_initial_gamma_parameters(ds, mom_method=init_method)
697
823
 
698
824
  # Define kwargs
@@ -709,10 +835,10 @@ def get_gamma_parameters(
709
835
  da_params = xr.apply_ufunc(
710
836
  estimate_gamma_parameters,
711
837
  counts,
712
- ds_init["a"],
713
- ds_init["scale"],
838
+ ds_init["mu"],
839
+ ds_init["Lambda"],
714
840
  kwargs=kwargs,
715
- input_core_dims=[["diameter_bin_center"], [], []],
841
+ input_core_dims=[[DIAMETER_DIMENSION], [], []],
716
842
  output_core_dims=[["parameters"]],
717
843
  vectorize=True,
718
844
  dask="parallelized",
@@ -720,8 +846,6 @@ def get_gamma_parameters(
720
846
  output_dtypes=["float64"],
721
847
  )
722
848
 
723
- ds_init.isel(velocity_method=0, time=-3)
724
-
725
849
  # Add parameters coordinates
726
850
  da_params = da_params.assign_coords({"parameters": ["N0", "mu", "Lambda"]})
727
851
 
@@ -735,7 +859,7 @@ def get_gamma_parameters(
735
859
 
736
860
  def get_lognormal_parameters(
737
861
  ds,
738
- init_method=None, # noqa: ARG001
862
+ init_method=None,
739
863
  probability_method="cdf",
740
864
  likelihood="multinomial",
741
865
  truncated_likelihood=True,
@@ -779,7 +903,10 @@ def get_lognormal_parameters(
779
903
  """
780
904
  # Define inputs
781
905
  counts = ds["drop_number_concentration"] * ds["diameter_bin_width"]
782
- diameter_breaks = np.append(ds["diameter_bin_lower"].data, ds["diameter_bin_upper"].data[-1])
906
+ diameter_breaks = get_diameter_bin_edges(ds)
907
+
908
+ # Define initial parameters (mu, sigma)
909
+ ds_init = _get_initial_lognormal_parameters(ds, mom_method=init_method)
783
910
 
784
911
  # Define kwargs
785
912
  kwargs = {
@@ -795,8 +922,10 @@ def get_lognormal_parameters(
795
922
  da_params = xr.apply_ufunc(
796
923
  estimate_lognormal_parameters,
797
924
  counts,
925
+ ds_init["mu"],
926
+ ds_init["sigma"],
798
927
  kwargs=kwargs,
799
- input_core_dims=[["diameter_bin_center"]],
928
+ input_core_dims=[[DIAMETER_DIMENSION], [], []],
800
929
  output_core_dims=[["parameters"]],
801
930
  vectorize=True,
802
931
  dask="parallelized",
@@ -818,7 +947,7 @@ def get_lognormal_parameters(
818
947
 
819
948
  def get_exponential_parameters(
820
949
  ds,
821
- init_method=None, # noqa: ARG001
950
+ init_method=None,
822
951
  probability_method="cdf",
823
952
  likelihood="multinomial",
824
953
  truncated_likelihood=True,
@@ -863,8 +992,11 @@ def get_exponential_parameters(
863
992
 
864
993
  """
865
994
  # Define inputs
866
- counts = ds["drop_number_concentration"] * ds["diameter_bin_width"]
867
- diameter_breaks = np.append(ds["diameter_bin_lower"].data, ds["diameter_bin_upper"].data[-1])
995
+ counts = ds["drop_number_concentration"] * ds["diameter_bin_width"] # mm-1 m-3 --> m-3
996
+ diameter_breaks = get_diameter_bin_edges(ds)
997
+
998
+ # Define initial parameters (Lambda)
999
+ ds_init = _get_initial_exponential_parameters(ds, mom_method=init_method)
868
1000
 
869
1001
  # Define kwargs
870
1002
  kwargs = {
@@ -880,8 +1012,9 @@ def get_exponential_parameters(
880
1012
  da_params = xr.apply_ufunc(
881
1013
  estimate_exponential_parameters,
882
1014
  counts,
1015
+ ds_init["Lambda"],
883
1016
  kwargs=kwargs,
884
- input_core_dims=[["diameter_bin_center"]],
1017
+ input_core_dims=[[DIAMETER_DIMENSION], []],
885
1018
  output_core_dims=[["parameters"]],
886
1019
  vectorize=True,
887
1020
  dask="parallelized",
@@ -900,162 +1033,6 @@ def get_exponential_parameters(
900
1033
  return ds_params
901
1034
 
902
1035
 
903
- ####-------------------------------------------------------------------------------------------------------------------.
904
-
905
-
906
- def _estimate_gamma_parameters_johnson(
907
- drop_number_concentration,
908
- diameter,
909
- diameter_breaks,
910
- output_dictionary=True,
911
- method="Nelder-Mead",
912
- mu=0.5,
913
- Lambda=3,
914
- **kwargs,
915
- ):
916
- """Deprecated Maximum likelihood estimation of Gamma model.
917
-
918
- N(D) = N_t * lambda**(mu+1) / gamma(mu+1) D**mu exp(-lambda*D)
919
-
920
- Args:
921
- spectra: The DSD for which to find parameters [mm-1 m-3].
922
- widths: Class widths for each DSD bin [mm].
923
- diams: Class-centre diameters for each DSD bin [mm].
924
- mu: Initial value for shape parameter mu [-].
925
- lambda_param: Initial value for slope parameter lambda [mm^-1].
926
- kwargs: Extra arguments for the optimization process.
927
-
928
- Returns
929
- -------
930
- Dictionary with estimated mu, lambda, and N0.
931
- mu (shape) N0 (scale) lambda(slope)
932
-
933
- Notes
934
- -----
935
- The last bin counts are not accounted in the fitting procedure !
936
-
937
- References
938
- ----------
939
- Johnson, R. W., D. V. Kliche, and P. L. Smith, 2011: Comparison of Estimators for Parameters of Gamma Distributions
940
- with Left-Truncated Samples. J. Appl. Meteor. Climatol., 50, 296-310, https://doi.org/10.1175/2010JAMC2478.1
941
-
942
- Johnson, R.W., Kliche, D., & Smith, P.L. (2010).
943
- Maximum likelihood estimation of gamma parameters for coarsely binned and truncated raindrop size data.
944
- Quarterly Journal of the Royal Meteorological Society, 140. DOI:10.1002/qj.2209
945
-
946
- """
947
- # Initialize bad results
948
- if output_dictionary:
949
- null_output = {"mu": np.nan, "lambda": np.nan, "N0": np.nan}
950
- else:
951
- null_output = np.array([np.nan, np.nan, np.nan])
952
-
953
- # Initialize parameters
954
- # --> Ideally with method of moments estimate
955
- # --> See equation 8 of Johnson's 2013
956
- x0 = [mu, Lambda]
957
-
958
- # Compute diameter_bin_width
959
- diameter_bin_width = np.diff(diameter_breaks)
960
-
961
- # Convert drop_number_concentration from mm-1 m-3 to m-3.
962
- spectra = np.asarray(drop_number_concentration) * diameter_bin_width
963
-
964
- # Define cost function
965
- # - Parameter to be optimized on first positions
966
- def _cost_function(parameters, spectra, diameter_breaks):
967
- # Assume spectra to be in unit [m-3] (drop_number_concentration*diameter_bin_width) !
968
- mu, Lambda = parameters
969
- # Precompute gamma integrals between various diameter bins
970
- # - gamminc(mu+1) already divides the integral by gamma(mu+1) !
971
- pgamma_d = gammainc(mu + 1, Lambda * diameter_breaks)
972
- # Compute probability with interval
973
- delta_pgamma_bins = pgamma_d[1:] - pgamma_d[:-1]
974
- # Compute normalization over interval
975
- denominator = pgamma_d[-1] - pgamma_d[0]
976
- # Compute cost function
977
- # a = mu - 1, x = lambda
978
- if mu > -1 and Lambda > 0:
979
- cost = np.sum(-spectra * np.log(delta_pgamma_bins / denominator))
980
- return cost
981
- return np.inf
982
-
983
- # Minimize the cost function
984
- with suppress_warnings():
985
- bounds = [(0, None), (0, None)] # Force mu and lambda to be non-negative
986
- res = minimize(
987
- _cost_function,
988
- x0=x0,
989
- args=(spectra, diameter_breaks),
990
- method=method,
991
- bounds=bounds,
992
- **kwargs,
993
- )
994
-
995
- # Check if the fit had success
996
- if not res.success:
997
- return null_output
998
-
999
- # Extract parameters
1000
- mu = res.x[0] # [-]
1001
- Lambda = res.x[1] # [mm-1]
1002
-
1003
- # Estimate tilde_N_T using the total drop concentration
1004
- tilde_N_T = np.sum(drop_number_concentration * diameter_bin_width) # [m-3]
1005
-
1006
- # Estimate proportion of missing drops (Johnson's 2011 Eqs. 3)
1007
- with suppress_warnings():
1008
- D = diameter
1009
- p = 1 - np.sum((Lambda ** (mu + 1)) / gamma(mu + 1) * D**mu * np.exp(-Lambda * D) * diameter_bin_width) # [-]
1010
-
1011
- # Convert tilde_N_T to N_T using Johnson's 2013 Eqs. 3 and 4.
1012
- # - Adjusts for the proportion of drops not observed
1013
- N_T = tilde_N_T / (1 - p) # [m-3]
1014
-
1015
- # Compute N0
1016
- N0 = N_T * (Lambda ** (mu + 1)) / gamma(mu + 1) # [m-3 * mm^(-mu-1)]
1017
-
1018
- # Compute Dm
1019
- # Dm = (mu + 4)/ Lambda
1020
-
1021
- # Compute Nw
1022
- # Nw = N0* D^mu / f(mu) , with f(mu of the Normalized PSD)
1023
-
1024
- # Define output
1025
- output = {"mu": mu, "Lambda": Lambda, "N0": N0} if output_dictionary else np.array([mu, Lambda, N0])
1026
- return output
1027
-
1028
-
1029
- def get_gamma_parameters_johnson2014(ds, method="Nelder-Mead"):
1030
- """Deprecated model. See Gamma Model with truncated_likelihood and 'pdf'."""
1031
- drop_number_concentration = ds["drop_number_concentration"]
1032
- diameter = ds["diameter_bin_center"]
1033
- diameter_breaks = np.append(ds["diameter_bin_lower"].data, ds["diameter_bin_upper"].data[-1])
1034
- # Define kwargs
1035
- kwargs = {
1036
- "output_dictionary": False,
1037
- "diameter_breaks": diameter_breaks,
1038
- "method": method,
1039
- }
1040
- da_params = xr.apply_ufunc(
1041
- _estimate_gamma_parameters_johnson,
1042
- drop_number_concentration,
1043
- diameter,
1044
- # diameter_bin_width,
1045
- kwargs=kwargs,
1046
- input_core_dims=[["diameter_bin_center"], ["diameter_bin_center"]], # ["diameter_bin_center"],
1047
- output_core_dims=[["parameters"]],
1048
- vectorize=True,
1049
- )
1050
-
1051
- # Add parameters coordinates
1052
- da_params = da_params.assign_coords({"parameters": ["mu", "Lambda", "N0"]})
1053
-
1054
- # Convert to skill Dataset
1055
- ds_params = da_params.to_dataset(dim="parameters")
1056
- return ds_params
1057
-
1058
-
1059
1036
  ####-----------------------------------------------------------------------------------------.
1060
1037
  #### Grid Search (GS)
1061
1038
 
@@ -1079,24 +1056,60 @@ def _compute_z(ND, D, dD):
1079
1056
  return Z
1080
1057
 
1081
1058
 
1059
+ def _compute_target_variable_error(target, ND_obs, ND_preds, D, dD, V):
1060
+ if target == "Z":
1061
+ errors = np.abs(_compute_z(ND_obs, D, dD) - _compute_z(ND_preds, D, dD))
1062
+ elif target == "R":
1063
+ errors = np.abs(_compute_rain_rate(ND_obs, D, dD, V) - _compute_rain_rate(ND_preds, D, dD, V))
1064
+ else: # if target == "LWC":
1065
+ errors = np.abs(_compute_lwc(ND_obs, D, dD) - _compute_lwc(ND_preds, D, dD))
1066
+ return errors
1067
+
1068
+
1082
1069
  def _compute_cost_function(ND_obs, ND_preds, D, dD, V, target, transformation, error_order):
1083
1070
  # Assume ND_obs of shape (D bins) and ND_preds of shape (# params, D bins)
1084
1071
  if target == "ND":
1085
1072
  if transformation == "identity":
1086
1073
  errors = np.mean(np.abs(ND_obs[None, :] - ND_preds) ** error_order, axis=1)
1074
+ return errors
1087
1075
  if transformation == "log":
1088
1076
  errors = np.mean(np.abs(np.log(ND_obs[None, :] + 1) - np.log(ND_preds + 1)) ** error_order, axis=1)
1089
- if transformation == "np.sqrt":
1077
+ return errors
1078
+ if transformation == "sqrt":
1090
1079
  errors = np.mean(np.abs(np.sqrt(ND_obs[None, :]) - np.sqrt(ND_preds)) ** error_order, axis=1)
1091
- elif target == "Z":
1092
- errors = np.abs(_compute_z(ND_obs, D, dD) - _compute_z(ND_preds, D, dD))
1093
- elif target == "R":
1094
- errors = np.abs(_compute_rain_rate(ND_obs, D, dD, V) - _compute_rain_rate(ND_preds, D, dD, V))
1095
- elif target == "LWC":
1096
- errors = np.abs(_compute_lwc(ND_obs, D, dD) - _compute_lwc(ND_preds, D, dD))
1097
- else:
1098
- raise ValueError("Invalid target")
1099
- return errors
1080
+ return errors
1081
+ # if target in ["Z", "R", "LWC"]:
1082
+ return _compute_target_variable_error(target, ND_obs, ND_preds, D, dD, V)
1083
+
1084
+
1085
+ def define_param_range(center, step, bounds, factor=2, refinement=20):
1086
+ """
1087
+ Create a refined parameter search range around a center value, constrained to bounds.
1088
+
1089
+ Parameters
1090
+ ----------
1091
+ center : float
1092
+ Center of the range (e.g., current best estimate).
1093
+ step : float
1094
+ Coarse step size used in the first search.
1095
+ bounds : tuple of (float, float)
1096
+ Lower and upper bounds (can include -np.inf, np.inf).
1097
+ factor : float, optional
1098
+ How wide the refined range extends from the center (in multiples of step).
1099
+ Default = 2.
1100
+ refinement : int, optional
1101
+ Factor to refine the step size (smaller step = finer grid).
1102
+ Default = 20.
1103
+
1104
+ Returns
1105
+ -------
1106
+ np.ndarray
1107
+ Array of values constrained to bounds.
1108
+ """
1109
+ lower = max(center - factor * step, bounds[0])
1110
+ upper = min(center + factor * step, bounds[1])
1111
+ new_step = step / refinement
1112
+ return np.arange(lower, upper, new_step)
1100
1113
 
1101
1114
 
1102
1115
  def apply_exponential_gs(
@@ -1132,9 +1145,15 @@ def apply_exponential_gs(
1132
1145
  transformation=transformation,
1133
1146
  error_order=error_order,
1134
1147
  )
1148
+ # Replace inf with NaN
1149
+ errors[~np.isfinite(errors)] = np.nan
1150
+
1151
+ # If all invalid, return NaN parameters
1152
+ if np.all(np.isnan(errors)):
1153
+ return np.array([np.nan, np.nan])
1135
1154
 
1136
- # Identify best parameter set
1137
- best_index = np.argmin(errors)
1155
+ # Otherwise, choose the best index
1156
+ best_index = np.nanargmin(errors)
1138
1157
  return np.array([N0_arr[best_index].item(), lambda_arr[best_index].item()])
1139
1158
 
1140
1159
 
@@ -1163,8 +1182,15 @@ def _apply_gamma_gs(mu_values, lambda_values, Nt, ND_obs, D, dD, V, target, tran
1163
1182
  error_order=error_order,
1164
1183
  )
1165
1184
 
1166
- # Best parameter
1167
- best_index = np.argmin(errors)
1185
+ # Replace inf with NaN
1186
+ errors[~np.isfinite(errors)] = np.nan
1187
+
1188
+ # If all invalid, return NaN parameters
1189
+ if np.all(np.isnan(errors)):
1190
+ return np.array([np.nan, np.nan, np.nan])
1191
+
1192
+ # Otherwise, choose the best index
1193
+ best_index = np.nanargmin(errors)
1168
1194
  return N0[best_index].item(), mu_arr[best_index].item(), lambda_arr[best_index].item()
1169
1195
 
1170
1196
 
@@ -1181,6 +1207,10 @@ def apply_gamma_gs(
1181
1207
  error_order,
1182
1208
  ):
1183
1209
  """Estimate GammaPSD model parameters using Grid Search."""
1210
+ # Define parameters bounds
1211
+ mu_bounds = (0.01, 20)
1212
+ lambda_bounds = (0.01, 60)
1213
+
1184
1214
  # Define initial set of parameters
1185
1215
  mu_step = 0.5
1186
1216
  lambda_step = 0.5
@@ -1200,10 +1230,13 @@ def apply_gamma_gs(
1200
1230
  transformation=transformation,
1201
1231
  error_order=error_order,
1202
1232
  )
1233
+ if np.isnan(N0): # if np.nan, return immediately
1234
+ return np.array([N0, mu, Lambda])
1203
1235
 
1204
1236
  # Second round of GS
1205
- mu_values = np.arange(mu - mu_step * 2, mu + mu_step * 2, step=mu_step / 20)
1206
- lambda_values = np.arange(Lambda - lambda_step * 2, Lambda + lambda_step * 2, step=lambda_step / 20)
1237
+ mu_values = define_param_range(mu, mu_step, bounds=mu_bounds)
1238
+ lambda_values = define_param_range(Lambda, lambda_step, bounds=lambda_bounds)
1239
+
1207
1240
  N0, mu, Lambda = _apply_gamma_gs(
1208
1241
  mu_values=mu_values,
1209
1242
  lambda_values=lambda_values,
@@ -1244,8 +1277,15 @@ def _apply_lognormal_gs(mu_values, sigma_values, Nt, ND_obs, D, dD, V, target, t
1244
1277
  error_order=error_order,
1245
1278
  )
1246
1279
 
1247
- # Best parameter
1248
- best_index = np.argmin(errors)
1280
+ # Replace inf with NaN
1281
+ errors[~np.isfinite(errors)] = np.nan
1282
+
1283
+ # If all invalid, return NaN parameters
1284
+ if np.all(np.isnan(errors)):
1285
+ return np.array([np.nan, np.nan, np.nan])
1286
+
1287
+ # Otherwise, choose the best index
1288
+ best_index = np.nanargmin(errors)
1249
1289
  return Nt, mu_arr[best_index].item(), sigma_arr[best_index].item()
1250
1290
 
1251
1291
 
@@ -1262,10 +1302,16 @@ def apply_lognormal_gs(
1262
1302
  error_order,
1263
1303
  ):
1264
1304
  """Estimate LognormalPSD model parameters using Grid Search."""
1305
+ # Define parameters bounds
1306
+ sigma_bounds = (0, np.inf) # > 0
1307
+ scale_bounds = (0.1, np.inf) # > 0
1308
+ # mu_bounds = (- np.inf, np.inf) # mu = np.log(scale)
1309
+
1265
1310
  # Define initial set of parameters
1266
- mu_step = 0.5
1267
- sigma_step = 0.5
1268
- mu_values = np.arange(0.01, 20, step=mu_step) # TODO: define realistic values
1311
+ scale_step = 0.2
1312
+ sigma_step = 0.2
1313
+ scale_values = np.arange(0.1, 20, step=scale_step)
1314
+ mu_values = np.log(scale_values) # TODO: define realistic values
1269
1315
  sigma_values = np.arange(0, 20, step=sigma_step) # TODO: define realistic values
1270
1316
 
1271
1317
  # First round of GS
@@ -1281,10 +1327,13 @@ def apply_lognormal_gs(
1281
1327
  transformation=transformation,
1282
1328
  error_order=error_order,
1283
1329
  )
1330
+ if np.isnan(mu): # if np.nan, return immediately
1331
+ return np.array([Nt, mu, sigma])
1284
1332
 
1285
1333
  # Second round of GS
1286
- mu_values = np.arange(mu - mu_step * 2, mu + mu_step * 2, step=mu_step / 20)
1287
- sigma_values = np.arange(sigma - sigma_step * 2, sigma + sigma_step * 2, step=sigma_step / 20)
1334
+ sigma_values = define_param_range(sigma, sigma_step, bounds=sigma_bounds)
1335
+ scale_values = define_param_range(np.exp(mu), scale_step, bounds=scale_bounds)
1336
+ mu_values = np.log(scale_values)
1288
1337
  Nt, mu, sigma = _apply_lognormal_gs(
1289
1338
  mu_values=mu_values,
1290
1339
  sigma_values=sigma_values,
@@ -1335,8 +1384,16 @@ def apply_normalized_gamma_gs(
1335
1384
  error_order=error_order,
1336
1385
  )
1337
1386
 
1338
- # Identify best parameter set
1339
- mu = mu_arr[np.argmin(errors)]
1387
+ # Replace inf with NaN
1388
+ errors[~np.isfinite(errors)] = np.nan
1389
+
1390
+ # If all invalid, return NaN parameters
1391
+ if np.all(np.isnan(errors)):
1392
+ return np.array([np.nan, np.nan, np.nan])
1393
+
1394
+ # Otherwise, choose the best index
1395
+ best_index = np.nanargmin(errors)
1396
+ mu = mu_arr[best_index]
1340
1397
  return np.array([Nw, mu, D50])
1341
1398
 
1342
1399
 
@@ -1346,6 +1403,12 @@ def get_exponential_parameters_gs(ds, target="ND", transformation="log", error_o
1346
1403
  # "transformation": "log", "identity", "sqrt", # only for drop_number_concentration
1347
1404
  # "error_order": 1, # MAE/MSE ... only for drop_number_concentration
1348
1405
 
1406
+ # Compute required variables
1407
+ ds["Nt"] = get_total_number_concentration(
1408
+ drop_number_concentration=ds["drop_number_concentration"],
1409
+ diameter_bin_width=ds["diameter_bin_width"],
1410
+ )
1411
+
1349
1412
  # Define kwargs
1350
1413
  kwargs = {
1351
1414
  "D": ds["diameter_bin_center"].data,
@@ -1365,7 +1428,7 @@ def get_exponential_parameters_gs(ds, target="ND", transformation="log", error_o
1365
1428
  # Other options
1366
1429
  kwargs=kwargs,
1367
1430
  # Settings
1368
- input_core_dims=[[], ["diameter_bin_center"], ["diameter_bin_center"]],
1431
+ input_core_dims=[[], [DIAMETER_DIMENSION], [DIAMETER_DIMENSION]],
1369
1432
  output_core_dims=[["parameters"]],
1370
1433
  vectorize=True,
1371
1434
  dask="parallelized",
@@ -1390,6 +1453,12 @@ def get_gamma_parameters_gs(ds, target="ND", transformation="log", error_order=1
1390
1453
  # "transformation": "log", "identity", "sqrt", # only for drop_number_concentration
1391
1454
  # "error_order": 1, # MAE/MSE ... only for drop_number_concentration
1392
1455
 
1456
+ # Compute required variables
1457
+ ds["Nt"] = get_total_number_concentration(
1458
+ drop_number_concentration=ds["drop_number_concentration"],
1459
+ diameter_bin_width=ds["diameter_bin_width"],
1460
+ )
1461
+
1393
1462
  # Define kwargs
1394
1463
  kwargs = {
1395
1464
  "D": ds["diameter_bin_center"].data,
@@ -1409,7 +1478,7 @@ def get_gamma_parameters_gs(ds, target="ND", transformation="log", error_order=1
1409
1478
  # Other options
1410
1479
  kwargs=kwargs,
1411
1480
  # Settings
1412
- input_core_dims=[[], ["diameter_bin_center"], ["diameter_bin_center"]],
1481
+ input_core_dims=[[], [DIAMETER_DIMENSION], [DIAMETER_DIMENSION]],
1413
1482
  output_core_dims=[["parameters"]],
1414
1483
  vectorize=True,
1415
1484
  dask="parallelized",
@@ -1434,6 +1503,12 @@ def get_lognormal_parameters_gs(ds, target="ND", transformation="log", error_ord
1434
1503
  # "transformation": "log", "identity", "sqrt", # only for drop_number_concentration
1435
1504
  # "error_order": 1, # MAE/MSE ... only for drop_number_concentration
1436
1505
 
1506
+ # Compute required variables
1507
+ ds["Nt"] = get_total_number_concentration(
1508
+ drop_number_concentration=ds["drop_number_concentration"],
1509
+ diameter_bin_width=ds["diameter_bin_width"],
1510
+ )
1511
+
1437
1512
  # Define kwargs
1438
1513
  kwargs = {
1439
1514
  "D": ds["diameter_bin_center"].data,
@@ -1453,7 +1528,7 @@ def get_lognormal_parameters_gs(ds, target="ND", transformation="log", error_ord
1453
1528
  # Other options
1454
1529
  kwargs=kwargs,
1455
1530
  # Settings
1456
- input_core_dims=[[], ["diameter_bin_center"], ["diameter_bin_center"]],
1531
+ input_core_dims=[[], [DIAMETER_DIMENSION], [DIAMETER_DIMENSION]],
1457
1532
  output_core_dims=[["parameters"]],
1458
1533
  vectorize=True,
1459
1534
  dask="parallelized",
@@ -1475,8 +1550,8 @@ def get_lognormal_parameters_gs(ds, target="ND", transformation="log", error_ord
1475
1550
  def get_normalized_gamma_parameters_gs(ds, target="ND", transformation="log", error_order=1):
1476
1551
  r"""Estimate $\mu$ of a Normalized Gamma distribution using Grid Search.
1477
1552
 
1478
- The D50 and Nw parameters of the Normalized Gamma distribution are derived empirically from the observed DSD.
1479
- $\mu$ is derived by minimizing the errors between the observed DSD and modelled Normalized Gamma distribution.
1553
+ The D50 and Nw parameters of the Normalized Gamma distribution are derived empirically from the obs DSD.
1554
+ $\mu$ is derived by minimizing the errors between the obs DSD and modelled Normalized Gamma distribution.
1480
1555
 
1481
1556
  Parameters
1482
1557
  ----------
@@ -1501,6 +1576,29 @@ def get_normalized_gamma_parameters_gs(ds, target="ND", transformation="log", er
1501
1576
  # "transformation": "log", "identity", "sqrt", # only for drop_number_concentration
1502
1577
  # "error_order": 1, # MAE/MSE ... only for drop_number_concentration
1503
1578
 
1579
+ # Compute required variables
1580
+ drop_number_concentration = ds["drop_number_concentration"]
1581
+ diameter_bin_width = ds["diameter_bin_width"]
1582
+ diameter = ds["diameter_bin_center"] / 1000 # conversion from mm to m
1583
+ m3 = get_moment(
1584
+ drop_number_concentration=drop_number_concentration,
1585
+ diameter=diameter, # m
1586
+ diameter_bin_width=diameter_bin_width, # mm
1587
+ moment=3,
1588
+ )
1589
+ m4 = get_moment(
1590
+ drop_number_concentration=drop_number_concentration,
1591
+ diameter=diameter, # m
1592
+ diameter_bin_width=diameter_bin_width, # mm
1593
+ moment=4,
1594
+ )
1595
+ ds["Nw"] = get_normalized_intercept_parameter_from_moments(moment_3=m3, moment_4=m4)
1596
+ ds["D50"] = get_median_volume_drop_diameter(
1597
+ drop_number_concentration=drop_number_concentration,
1598
+ diameter=diameter, # m
1599
+ diameter_bin_width=diameter_bin_width, # mm
1600
+ )
1601
+
1504
1602
  # Define kwargs
1505
1603
  kwargs = {
1506
1604
  "D": ds["diameter_bin_center"].data,
@@ -1521,7 +1619,7 @@ def get_normalized_gamma_parameters_gs(ds, target="ND", transformation="log", er
1521
1619
  # Other options
1522
1620
  kwargs=kwargs,
1523
1621
  # Settings
1524
- input_core_dims=[[], [], ["diameter_bin_center"], ["diameter_bin_center"]],
1622
+ input_core_dims=[[], [], [DIAMETER_DIMENSION], [DIAMETER_DIMENSION]],
1525
1623
  output_core_dims=[["parameters"]],
1526
1624
  vectorize=True,
1527
1625
  dask="parallelized",
@@ -1569,6 +1667,8 @@ def get_exponential_parameters_Zhang2008(moment_l, moment_m, l, m): # noqa: E74
1569
1667
  Meteor. Climatol.,
1570
1668
  https://doi.org/10.1175/2008JAMC1876.1
1571
1669
  """
1670
+ if l == m:
1671
+ raise ValueError("Equal l and m moment orders are not allowed.")
1572
1672
  num = moment_l * gamma(m + 1)
1573
1673
  den = moment_m * gamma(l + 1)
1574
1674
  Lambda = np.power(num / den, (1 / (m - l)))
@@ -1593,21 +1693,21 @@ def get_exponential_parameters_M34(moment_3, moment_4):
1593
1693
  return N0, Lambda
1594
1694
 
1595
1695
 
1596
- def get_gamma_parameters_M012(M0, M1, M2):
1597
- """Compute gamma distribution parameters following Cao et al., 2009.
1696
+ # def get_gamma_parameters_M012(M0, M1, M2):
1697
+ # """Compute gamma distribution parameters following Cao et al., 2009.
1598
1698
 
1599
- References
1600
- ----------
1601
- Cao, Q., and G. Zhang, 2009:
1602
- Errors in Estimating Raindrop Size Distribution Parameters Employing Disdrometer and Simulated Raindrop Spectra.
1603
- J. Appl. Meteor. Climatol., 48, 406-425, https://doi.org/10.1175/2008JAMC2026.1.
1604
- """
1605
- # TODO: really bad results. check formula !
1606
- G = M1**3 / M0 / M2
1607
- mu = 1 / (1 - G) - 2
1608
- Lambda = M0 / M1 * (mu + 1)
1609
- N0 = Lambda ** (mu + 1) * M0 / gamma(mu + 1)
1610
- return N0, mu, Lambda
1699
+ # References
1700
+ # ----------
1701
+ # Cao, Q., and G. Zhang, 2009:
1702
+ # Errors in Estimating Raindrop Size Distribution Parameters Employing Disdrometer and Simulated Raindrop Spectra.
1703
+ # J. Appl. Meteor. Climatol., 48, 406-425, https://doi.org/10.1175/2008JAMC2026.1.
1704
+ # """
1705
+ # # TODO: really bad results. check formula !
1706
+ # G = M1**3 / M0 / M2
1707
+ # mu = 1 / (1 - G) - 2
1708
+ # Lambda = M0 / M1 * (mu + 1)
1709
+ # N0 = Lambda ** (mu + 1) * M0 / gamma(mu + 1)
1710
+ # return N0, mu, Lambda
1611
1711
 
1612
1712
 
1613
1713
  def get_gamma_parameters_M234(M2, M3, M4):
@@ -1735,12 +1835,25 @@ def get_lognormal_parameters_M346(M3, M4, M6):
1735
1835
  return Nt, mu, sigma
1736
1836
 
1737
1837
 
1838
+ def _compute_moments(ds, moments):
1839
+ list_moments = [
1840
+ get_moment(
1841
+ drop_number_concentration=ds["drop_number_concentration"],
1842
+ diameter=ds["diameter_bin_center"] / 1000, # m
1843
+ diameter_bin_width=ds["diameter_bin_width"], # mm
1844
+ moment=int(moment.replace("M", "")),
1845
+ )
1846
+ for moment in moments
1847
+ ]
1848
+ return list_moments
1849
+
1850
+
1738
1851
  def _get_gamma_parameters_mom(ds: xr.Dataset, mom_method: str) -> xr.Dataset:
1739
1852
  # Get the correct function and list of variables for the requested method
1740
1853
  func, needed_moments = MOM_METHODS_DICT["GammaPSD"][mom_method]
1741
1854
 
1742
- # Extract the required arrays from the dataset
1743
- arrs = [ds[var_name] for var_name in needed_moments]
1855
+ # Compute required moments
1856
+ arrs = _compute_moments(ds, moments=needed_moments)
1744
1857
 
1745
1858
  # Apply the function. This will produce (mu, Lambda, N0) with the same coords/shapes as input data
1746
1859
  N0, mu, Lambda = func(*arrs)
@@ -1761,8 +1874,8 @@ def _get_lognormal_parameters_mom(ds: xr.Dataset, mom_method: str) -> xr.Dataset
1761
1874
  # Get the correct function and list of variables for the requested method
1762
1875
  func, needed_moments = MOM_METHODS_DICT["LognormalPSD"][mom_method]
1763
1876
 
1764
- # Extract the required arrays from the dataset
1765
- arrs = [ds[var_name] for var_name in needed_moments]
1877
+ # Compute required moments
1878
+ arrs = _compute_moments(ds, moments=needed_moments)
1766
1879
 
1767
1880
  # Apply the function. This will produce (mu, Lambda, N0) with the same coords/shapes as input data
1768
1881
  Nt, mu, sigma = func(*arrs)
@@ -1783,8 +1896,8 @@ def _get_exponential_parameters_mom(ds: xr.Dataset, mom_method: str) -> xr.Datas
1783
1896
  # Get the correct function and list of variables for the requested method
1784
1897
  func, needed_moments = MOM_METHODS_DICT["ExponentialPSD"][mom_method]
1785
1898
 
1786
- # Extract the required arrays from the dataset
1787
- arrs = [ds[var_name] for var_name in needed_moments]
1899
+ # Compute required moments
1900
+ arrs = _compute_moments(ds, moments=needed_moments)
1788
1901
 
1789
1902
  # Apply the function. This will produce (mu, Lambda, N0) with the same coords/shapes as input data
1790
1903
  N0, Lambda = func(*arrs)
@@ -1803,6 +1916,79 @@ def _get_exponential_parameters_mom(ds: xr.Dataset, mom_method: str) -> xr.Datas
1803
1916
  ####--------------------------------------------------------------------------------------.
1804
1917
  #### Routines dictionary
1805
1918
 
1919
+ ####--------------------------------------------------------------------------------------.
1920
+ ATTRS_PARAMS_DICT = {
1921
+ "GammaPSD": {
1922
+ "N0": {
1923
+ "description": "Intercept parameter of the Gamma PSD",
1924
+ "standard_name": "particle_size_distribution_intercept",
1925
+ "units": "mm**(-1-mu) m-3",
1926
+ "long_name": "GammaPSD intercept parameter",
1927
+ },
1928
+ "mu": {
1929
+ "description": "Shape parameter of the Gamma PSD",
1930
+ "standard_name": "particle_size_distribution_shape",
1931
+ "units": "",
1932
+ "long_name": "GammaPSD shape parameter",
1933
+ },
1934
+ "Lambda": {
1935
+ "description": "Slope (rate) parameter of the Gamma PSD",
1936
+ "standard_name": "particle_size_distribution_slope",
1937
+ "units": "mm-1",
1938
+ "long_name": "GammaPSD slope parameter",
1939
+ },
1940
+ },
1941
+ "NormalizedGammaPSD": {
1942
+ "Nw": {
1943
+ "standard_name": "normalized_intercept_parameter",
1944
+ "units": "mm-1 m-3",
1945
+ "long_name": "NormalizedGammaPSD Normalized Intercept Parameter",
1946
+ },
1947
+ "mu": {
1948
+ "description": "Dimensionless shape parameter controlling the curvature of the Normalized Gamma PSD",
1949
+ "standard_name": "particle_size_distribution_shape",
1950
+ "units": "",
1951
+ "long_name": "NormalizedGammaPSD Shape Parameter ",
1952
+ },
1953
+ "D50": {
1954
+ "standard_name": "median_volume_diameter",
1955
+ "units": "mm",
1956
+ "long_name": "NormalizedGammaPSD Median Volume Drop Diameter",
1957
+ },
1958
+ },
1959
+ "LognormalPSD": {
1960
+ "Nt": {
1961
+ "standard_name": "number_concentration_of_rain_drops_in_air",
1962
+ "units": "m-3",
1963
+ "long_name": "Total Number Concentration",
1964
+ },
1965
+ "mu": {
1966
+ "description": "Mean of the Lognormal PSD",
1967
+ "units": "log(mm)",
1968
+ "long_name": "Mean of the Lognormal PSD",
1969
+ },
1970
+ "sigma": {
1971
+ "standard_name": "Standard Deviation of the Lognormal PSD",
1972
+ "units": "",
1973
+ "long_name": "Standard Deviation of the Lognormal PSD",
1974
+ },
1975
+ },
1976
+ "ExponentialPSD": {
1977
+ "N0": {
1978
+ "description": "Intercept parameter of the Exponential PSD",
1979
+ "standard_name": "particle_size_distribution_intercept",
1980
+ "units": "mm-1 m-3",
1981
+ "long_name": "ExponentialPSD intercept parameter",
1982
+ },
1983
+ "Lambda": {
1984
+ "description": "Slope (rate) parameter of the Exponential PSD",
1985
+ "standard_name": "particle_size_distribution_slope",
1986
+ "units": "mm-1",
1987
+ "long_name": "ExponentialPSD slope parameter",
1988
+ },
1989
+ },
1990
+ }
1991
+
1806
1992
 
1807
1993
  MOM_METHODS_DICT = {
1808
1994
  "GammaPSD": {
@@ -1843,6 +2029,8 @@ OPTIMIZATION_ROUTINES_DICT = {
1843
2029
 
1844
2030
  def available_mom_methods(psd_model):
1845
2031
  """Implemented MOM methods for a given PSD model."""
2032
+ if psd_model not in MOM_METHODS_DICT:
2033
+ raise NotImplementedError(f"No MOM methods available for {psd_model}")
1846
2034
  return list(MOM_METHODS_DICT[psd_model])
1847
2035
 
1848
2036
 
@@ -1863,7 +2051,7 @@ def check_psd_model(psd_model, optimization):
1863
2051
  f"{optimization} optimization is not available for 'psd_model' {psd_model}. "
1864
2052
  f"Accepted PSD models are {valid_psd_models}."
1865
2053
  )
1866
- raise ValueError(msg)
2054
+ raise NotImplementedError(msg)
1867
2055
 
1868
2056
 
1869
2057
  def check_target(target):
@@ -1921,11 +2109,14 @@ def check_optimizer(optimizer):
1921
2109
  return optimizer
1922
2110
 
1923
2111
 
1924
- def check_mom_methods(mom_methods, psd_model):
2112
+ def check_mom_methods(mom_methods, psd_model, allow_none=False):
1925
2113
  """Check valid mom_methods arguments."""
1926
- if isinstance(mom_methods, str):
2114
+ if isinstance(mom_methods, (str, type(None))):
1927
2115
  mom_methods = [mom_methods]
2116
+ mom_methods = [str(v) for v in mom_methods] # None --> 'None'
1928
2117
  valid_mom_methods = available_mom_methods(psd_model)
2118
+ if allow_none:
2119
+ valid_mom_methods = [*valid_mom_methods, "None"]
1929
2120
  invalid_mom_methods = np.array(mom_methods)[np.isin(mom_methods, valid_mom_methods, invert=True)]
1930
2121
  if len(invalid_mom_methods) > 0:
1931
2122
  raise ValueError(
@@ -1970,36 +2161,50 @@ def check_optimization_kwargs(optimization_kwargs, optimization, psd_model):
1970
2161
  expected_arguments = dict_arguments.get(optimization, {})
1971
2162
 
1972
2163
  # Check for missing arguments in optimization_kwargs
1973
- missing_args = [arg for arg in expected_arguments if arg not in optimization_kwargs]
1974
- if missing_args:
1975
- raise ValueError(f"Missing required arguments for {optimization} optimization: {missing_args}")
2164
+ # missing_args = [arg for arg in expected_arguments if arg not in optimization_kwargs]
2165
+ # if missing_args:
2166
+ # raise ValueError(f"Missing required arguments for {optimization} optimization: {missing_args}")
1976
2167
 
1977
- # Validate argument values
1978
- _ = [check(optimization_kwargs[arg]) for arg, check in expected_arguments.items() if callable(check)]
2168
+ # Validate arguments values
2169
+ _ = [
2170
+ check(optimization_kwargs[arg])
2171
+ for arg, check in expected_arguments.items()
2172
+ if callable(check) and arg in optimization_kwargs
2173
+ ]
1979
2174
 
1980
2175
  # Further special checks
1981
- if optimization == "MOM":
2176
+ if optimization == "MOM" and "mom_methods" in optimization_kwargs:
1982
2177
  _ = check_mom_methods(mom_methods=optimization_kwargs["mom_methods"], psd_model=psd_model)
1983
- if optimization == "ML" and optimization_kwargs["init_method"] is not None:
1984
- _ = check_mom_methods(mom_methods=optimization_kwargs["init_method"], psd_model=psd_model)
2178
+ if optimization == "ML" and optimization_kwargs.get("init_method", None) is not None:
2179
+ _ = check_mom_methods(mom_methods=optimization_kwargs["init_method"], psd_model=psd_model, allow_none=True)
1985
2180
 
1986
2181
 
1987
2182
  ####--------------------------------------------------------------------------------------.
1988
2183
  #### Wrappers for fitting
1989
2184
 
1990
2185
 
1991
- def get_mom_parameters(ds: xr.Dataset, psd_model: str, mom_methods: str) -> xr.Dataset:
2186
+ def _finalize_attributes(ds_params, psd_model, optimization, optimization_kwargs):
2187
+ ds_params.attrs["disdrodb_psd_model"] = psd_model
2188
+ ds_params.attrs["disdrodb_psd_optimization"] = optimization
2189
+ ds_params.attrs["disdrodb_psd_optimization_kwargs"] = ", ".join(
2190
+ [f"{k}: {v}" for k, v in optimization_kwargs.items()],
2191
+ )
2192
+ return ds_params
2193
+
2194
+
2195
+ def get_mom_parameters(ds: xr.Dataset, psd_model: str, mom_methods=None) -> xr.Dataset:
1992
2196
  """
1993
2197
  Compute PSD model parameters using various method-of-moments (MOM) approaches.
1994
2198
 
1995
- The method is specified by the `mom_methods` acronym, e.g. 'M012', 'M234', 'M246'.
2199
+ The method is specified by the `mom_methods` abbreviations, e.g. 'M012', 'M234', 'M246'.
1996
2200
 
1997
2201
  Parameters
1998
2202
  ----------
1999
2203
  ds : xarray.Dataset
2000
2204
  An xarray Dataset with the required moments M0...M6 as data variables.
2001
- mom_methods: str or list
2002
- Valid MOM methods are {'M012', 'M234', 'M246', 'M456', 'M346'}.
2205
+ mom_methods: str or list (optional)
2206
+ See valid values with disdrodb.psd.available_mom_methods(psd_model)
2207
+ If None (the default), compute model parameters with all available MOM methods.
2003
2208
 
2004
2209
  Returns
2005
2210
  -------
@@ -2010,6 +2215,8 @@ def get_mom_parameters(ds: xr.Dataset, psd_model: str, mom_methods: str) -> xr.D
2010
2215
  """
2011
2216
  # Check inputs
2012
2217
  check_psd_model(psd_model=psd_model, optimization="MOM")
2218
+ if mom_methods is None:
2219
+ mom_methods = available_mom_methods(psd_model)
2013
2220
  mom_methods = check_mom_methods(mom_methods, psd_model=psd_model)
2014
2221
 
2015
2222
  # Retrieve function
@@ -2017,13 +2224,21 @@ def get_mom_parameters(ds: xr.Dataset, psd_model: str, mom_methods: str) -> xr.D
2017
2224
 
2018
2225
  # Compute parameters
2019
2226
  if len(mom_methods) == 1:
2020
- ds = func(ds=ds, mom_method=mom_methods[0])
2021
- ds.attrs["mom_method"] = mom_methods[0]
2022
- return ds
2023
- list_ds = [func(ds=ds, mom_method=mom_method) for mom_method in mom_methods]
2024
- ds = xr.concat(list_ds, dim="mom_method")
2025
- ds = ds.assign_coords({"mom_method": mom_methods})
2026
- return ds
2227
+ ds_params = func(ds=ds, mom_method=mom_methods[0])
2228
+ else:
2229
+ list_ds = [func(ds=ds, mom_method=mom_method) for mom_method in mom_methods]
2230
+ ds_params = xr.concat(list_ds, dim="mom_method")
2231
+ ds_params = ds_params.assign_coords({"mom_method": mom_methods})
2232
+
2233
+ # Add model attributes
2234
+ optimization_kwargs = {"mom_methods": mom_methods}
2235
+ ds_params = _finalize_attributes(
2236
+ ds_params=ds_params,
2237
+ psd_model=psd_model,
2238
+ optimization="MOM",
2239
+ optimization_kwargs=optimization_kwargs,
2240
+ )
2241
+ return ds_params
2027
2242
 
2028
2243
 
2029
2244
  def get_ml_parameters(
@@ -2052,7 +2267,7 @@ def get_ml_parameters(
2052
2267
  The PSD model to fit. See ``available_psd_models()``.
2053
2268
  init_method: str or list
2054
2269
  The method(s) of moments used to initialize the PSD model parameters.
2055
- See ``available_mom_methods(psd_model)``.
2270
+ Multiple methods can be specified. See ``available_mom_methods(psd_model)``.
2056
2271
  probability_method : str, optional
2057
2272
  Method to compute probabilities. The default value is ``cdf``.
2058
2273
  likelihood : str, optional
@@ -2076,21 +2291,51 @@ def get_ml_parameters(
2076
2291
  optimizer = check_optimizer(optimizer)
2077
2292
 
2078
2293
  # Check valid init_method
2079
- if init_method is not None:
2080
- init_method = check_mom_methods(mom_methods=init_method, psd_model=psd_model)
2294
+ init_method = check_mom_methods(mom_methods=init_method, psd_model=psd_model, allow_none=True)
2081
2295
 
2082
2296
  # Retrieve estimation function
2083
2297
  func = OPTIMIZATION_ROUTINES_DICT["ML"][psd_model]
2084
2298
 
2085
- # Retrieve parameters
2086
- ds_params = func(
2087
- ds=ds,
2088
- init_method=init_method,
2089
- probability_method=probability_method,
2090
- likelihood=likelihood,
2091
- truncated_likelihood=truncated_likelihood,
2092
- optimizer=optimizer,
2299
+ # Compute parameters
2300
+ if init_method is None or len(init_method) == 1:
2301
+ ds_params = func(
2302
+ ds=ds,
2303
+ init_method=init_method[0],
2304
+ probability_method=probability_method,
2305
+ likelihood=likelihood,
2306
+ truncated_likelihood=truncated_likelihood,
2307
+ optimizer=optimizer,
2308
+ )
2309
+ else:
2310
+ list_ds = [
2311
+ func(
2312
+ ds=ds,
2313
+ init_method=method,
2314
+ probability_method=probability_method,
2315
+ likelihood=likelihood,
2316
+ truncated_likelihood=truncated_likelihood,
2317
+ optimizer=optimizer,
2318
+ )
2319
+ for method in init_method
2320
+ ]
2321
+ ds_params = xr.concat(list_ds, dim="init_method")
2322
+ ds_params = ds_params.assign_coords({"init_method": init_method})
2323
+
2324
+ # Add model attributes
2325
+ optimization_kwargs = {
2326
+ "init_method": init_method,
2327
+ "probability_method": "probability_method",
2328
+ "likelihood": likelihood,
2329
+ "truncated_likelihood": truncated_likelihood,
2330
+ "optimizer": optimizer,
2331
+ }
2332
+ ds_params = _finalize_attributes(
2333
+ ds_params=ds_params,
2334
+ psd_model=psd_model,
2335
+ optimization="ML",
2336
+ optimization_kwargs=optimization_kwargs,
2093
2337
  )
2338
+
2094
2339
  # Return dataset with parameters
2095
2340
  return ds_params
2096
2341
 
@@ -2106,26 +2351,70 @@ def get_gs_parameters(ds, psd_model, target="ND", transformation="log", error_or
2106
2351
  # Check valid transformation
2107
2352
  transformation = check_transformation(transformation)
2108
2353
 
2354
+ # Check fall velocity is available if target R
2355
+ if "fall_velocity" not in ds:
2356
+ ds["fall_velocity"] = get_dataset_fall_velocity(ds)
2357
+
2109
2358
  # Retrieve estimation function
2110
2359
  func = OPTIMIZATION_ROUTINES_DICT["GS"][psd_model]
2111
2360
 
2112
2361
  # Estimate parameters
2113
2362
  ds_params = func(ds, target=target, transformation=transformation, error_order=error_order)
2114
2363
 
2364
+ # Add model attributes
2365
+ optimization_kwargs = {
2366
+ "target": target,
2367
+ "transformation": transformation,
2368
+ "error_order": error_order,
2369
+ }
2370
+ ds_params = _finalize_attributes(
2371
+ ds_params=ds_params,
2372
+ psd_model=psd_model,
2373
+ optimization="GS",
2374
+ optimization_kwargs=optimization_kwargs,
2375
+ )
2115
2376
  # Return dataset with parameters
2116
2377
  return ds_params
2117
2378
 
2118
2379
 
2380
+ def sanitize_drop_number_concentration(drop_number_concentration):
2381
+ """Sanitize drop number concentration array.
2382
+
2383
+ If N(D) is all zero or contain not finite values, set everything to np.nan
2384
+ """
2385
+ # Condition 1: all zeros along diameter_bin_center
2386
+ all_zero = (drop_number_concentration == 0).all(dim="diameter_bin_center")
2387
+
2388
+ # Condition 2: any non-finite along diameter_bin_center
2389
+ any_nonfinite = (~np.isfinite(drop_number_concentration)).any(dim="diameter_bin_center")
2390
+
2391
+ # Combine conditions
2392
+ invalid = all_zero | any_nonfinite
2393
+
2394
+ # Replace entire profile with NaN where invalid
2395
+ drop_number_concentration = drop_number_concentration.where(~invalid, np.nan)
2396
+ return drop_number_concentration
2397
+
2398
+
2119
2399
  def estimate_model_parameters(
2120
2400
  ds,
2121
2401
  psd_model,
2122
2402
  optimization,
2123
- optimization_kwargs,
2403
+ optimization_kwargs=None,
2124
2404
  ):
2125
2405
  """Routine to estimate PSD model parameters."""
2406
+ # Check inputs arguments
2407
+ optimization_kwargs = {} if optimization_kwargs is None else optimization_kwargs
2126
2408
  optimization = check_optimization(optimization)
2127
2409
  check_optimization_kwargs(optimization_kwargs=optimization_kwargs, optimization=optimization, psd_model=psd_model)
2128
2410
 
2411
+ # Check N(D)
2412
+ # --> If all 0, set to np.nan
2413
+ # --> If any is not finite --> set to np.nan
2414
+ if "drop_number_concentration" not in ds:
2415
+ raise ValueError("'drop_number_concentration' variable not present in input xarray.Dataset.")
2416
+ ds["drop_number_concentration"] = sanitize_drop_number_concentration(ds["drop_number_concentration"])
2417
+
2129
2418
  # Define function
2130
2419
  dict_func = {
2131
2420
  "ML": get_ml_parameters,
@@ -2137,10 +2426,7 @@ def estimate_model_parameters(
2137
2426
  # Retrieve parameters
2138
2427
  ds_params = func(ds, psd_model=psd_model, **optimization_kwargs)
2139
2428
 
2140
- # Finalize attributes
2141
- ds_params.attrs["disdrodb_psd_model"] = psd_model
2142
- ds_params.attrs["disdrodb_psd_optimization"] = optimization
2143
- if optimization == "GS":
2144
- ds_params.attrs["disdrodb_psd_optimization_target"] = optimization_kwargs["target"]
2145
-
2429
+ # Add parameters attributes (and units)
2430
+ for var, attrs in ATTRS_PARAMS_DICT[psd_model].items():
2431
+ ds_params[var].attrs = attrs
2146
2432
  return ds_params