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
@@ -0,0 +1,4196 @@
1
+ # -----------------------------------------------------------------------------.
2
+ # Copyright (c) 2021-2023 DISDRODB developers
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+ # -----------------------------------------------------------------------------.
17
+ """Utilities to create summary statistics."""
18
+ import gc
19
+ import importlib
20
+ import os
21
+ import shutil
22
+ import subprocess
23
+ import tempfile
24
+
25
+ import matplotlib.lines as mlines
26
+ import matplotlib.pyplot as plt
27
+ import numpy as np
28
+ import pandas as pd
29
+ import xarray as xr
30
+ from matplotlib.colors import ListedColormap, LogNorm, Normalize
31
+ from matplotlib.gridspec import GridSpec
32
+ from scipy.optimize import curve_fit
33
+
34
+ import disdrodb
35
+ from disdrodb.api.path import define_station_dir
36
+ from disdrodb.constants import DIAMETER_DIMENSION, VELOCITY_DIMENSION
37
+ from disdrodb.l2.empirical_dsd import get_drop_average_velocity
38
+ from disdrodb.scattering import RADAR_OPTIONS
39
+ from disdrodb.utils.dataframe import compute_2d_histogram, log_arange
40
+ from disdrodb.utils.event import group_timesteps_into_event
41
+ from disdrodb.utils.manipulations import (
42
+ get_diameter_bin_edges,
43
+ resample_drop_number_concentration,
44
+ unstack_radar_variables,
45
+ )
46
+ from disdrodb.utils.warnings import suppress_warnings
47
+ from disdrodb.utils.yaml import write_yaml
48
+ from disdrodb.viz import compute_dense_lines, max_blend_images, to_rgba
49
+
50
+ ####-----------------------------------------------------------------
51
+ #### PDF Latex Utilities
52
+
53
+
54
+ def is_latex_engine_available() -> bool:
55
+ """
56
+ Determine whether the Tectonic TeX/LaTeX engine is installed and accessible.
57
+
58
+ Returns
59
+ -------
60
+ bool
61
+ True if tectonic is found, False otherwise.
62
+ """
63
+ return shutil.which("tectonic") is not None
64
+
65
+
66
+ def save_table_to_pdf(
67
+ df: pd.DataFrame,
68
+ filepath: str,
69
+ index=True,
70
+ caption=None,
71
+ fontsize: str = r"\tiny",
72
+ orientation: str = "landscape",
73
+ ) -> None:
74
+ r"""Render a pandas DataFrame as a well-formatted table in PDF via LaTeX.
75
+
76
+ Parameters
77
+ ----------
78
+ df : pd.DataFrame
79
+ The data to render.
80
+ filepath : str
81
+ File path where write the final PDF (e.g. '<...>/table.pdf').
82
+ caption : str, optional
83
+ LaTeX caption for the table environment.
84
+ fontsize : str, optional
85
+ LaTeX font-size command to wrap the table (e.g. '\\small').
86
+ The default is '\\tiny'.
87
+ orientation : {'portrait', 'landscape'}
88
+ Page orientation. If 'landscape', the table will be laid out horizontally.
89
+ The default is 'landscape'.
90
+ """
91
+ # Export table to LaTeX
92
+ table_tex = df.to_latex(
93
+ index=index,
94
+ longtable=True,
95
+ caption=caption,
96
+ label=None,
97
+ escape=False,
98
+ )
99
+
100
+ # Define LaTeX document
101
+ opts = "a4paper"
102
+ doc = [
103
+ f"\\documentclass[{opts}]{{article}}",
104
+ "\\usepackage[margin=0.1in]{geometry}",
105
+ # Reduce column separation
106
+ "\\setlength{\\tabcolsep}{3pt}",
107
+ "\\usepackage{booktabs}",
108
+ "\\usepackage{longtable}",
109
+ "\\usepackage{caption}",
110
+ "\\captionsetup[longtable]{font=tiny}",
111
+ "\\usepackage{pdflscape}",
112
+ "\\begin{document}",
113
+ # Remove page numbers
114
+ "\\pagestyle{empty}",
115
+ ]
116
+
117
+ if orientation.lower() == "landscape":
118
+ doc.append("\\begin{landscape}")
119
+
120
+ doc.append(f"{{{fontsize}\n{table_tex}\n}}")
121
+
122
+ if orientation.lower() == "landscape":
123
+ doc.append("\\end{landscape}")
124
+
125
+ doc.append("\\end{document}")
126
+ document = "\n".join(doc)
127
+
128
+ # Compile with pdflatex in a temp dir
129
+ with tempfile.TemporaryDirectory() as td:
130
+ tex_path = os.path.join(td, "table.tex")
131
+ with open(tex_path, "w", encoding="utf-8") as f:
132
+ f.write(document)
133
+ for _ in range(2):
134
+ subprocess.run(
135
+ [
136
+ "tectonic",
137
+ "--outdir",
138
+ td,
139
+ tex_path,
140
+ ],
141
+ cwd=td,
142
+ stdout=subprocess.DEVNULL,
143
+ stderr=subprocess.DEVNULL,
144
+ check=True,
145
+ )
146
+ # Move result
147
+ shutil.move(os.path.join(td, "table.pdf"), filepath)
148
+
149
+
150
+ ####-----------------------------------------------------------------
151
+ #### Tables summaries
152
+
153
+
154
+ def create_table_rain_summary(df):
155
+ """Create rainy table summary."""
156
+ # Initialize dictionary
157
+ table = {}
158
+
159
+ # Keep rows with R > 0
160
+ df = df[df["R"] > 0]
161
+
162
+ # Number of years, months, days, minutes
163
+ if df.index.name == "time":
164
+ df = df.reset_index()
165
+ time = np.sort(np.asanyarray(df["time"]))
166
+
167
+ # Define start_time and end_time
168
+ start_time = pd.Timestamp(time[0])
169
+ end_time = pd.Timestamp(time[-1])
170
+
171
+ # Define years and years-month coverage
172
+ start_year = start_time.year
173
+ start_month = start_time.month_name()
174
+ end_year = end_time.year
175
+ end_month = end_time.month_name()
176
+ if start_year == end_year:
177
+ years_coverage = f"{start_year}"
178
+ years_month_coverage = f"{start_month[0:3]}-{end_month[0:3]} {start_year}"
179
+ else:
180
+ years_coverage = f"{start_year} - {end_year}"
181
+ years_month_coverage = f"{start_month[0:3]} {start_year} - {end_month[0:3]} {end_year}"
182
+ table["years_coverage"] = years_coverage
183
+ table["years_month_coverage"] = years_month_coverage
184
+
185
+ # Rainy minutes statistics
186
+ table["n_rainy_minutes"] = len(df["R"])
187
+ table["n_rainy_minutes_<0.1"] = df["R"].between(0, 0.1, inclusive="right").sum().item()
188
+ table["n_rainy_minutes_0.1_1"] = df["R"].between(0.1, 1, inclusive="right").sum().item()
189
+ table["n_rainy_minutes_1_10"] = df["R"].between(1, 10, inclusive="right").sum().item()
190
+ table["n_rainy_minutes_10_25"] = df["R"].between(10, 25, inclusive="right").sum().item()
191
+ table["n_rainy_minutes_25_50"] = df["R"].between(25, 50, inclusive="right").sum().item()
192
+ table["n_rainy_minutes_50_100"] = df["R"].between(50, 100, inclusive="right").sum().item()
193
+ table["n_rainy_minutes_100_200"] = df["R"].between(100, 200, inclusive="right").sum().item()
194
+ table["n_rainy_minutes_>200"] = np.sum(df["R"] > 200).item()
195
+
196
+ # Minutes with larger Dmax
197
+ table["n_minutes_Dmax_>7"] = np.sum(df["Dmax"] > 7).item()
198
+ table["n_minutes_Dmax_>8"] = np.sum(df["Dmax"] > 8).item()
199
+ table["n_minutes_Dmax_>9"] = np.sum(df["Dmax"] > 9).item()
200
+ return table
201
+
202
+
203
+ def create_table_dsd_summary(df):
204
+ """Create table with integral DSD parameters statistics."""
205
+ # Define additional variables
206
+ df["log10(Nw)"] = np.log10(df["Nw"])
207
+ df["log10(Nt)"] = np.log10(df["Nt"])
208
+
209
+ # List of variables to summarize
210
+ variables = ["W", "R", "Z", "D50", "Dm", "sigma_m", "Dmax", "Nw", "log10(Nw)", "Nt", "log10(Nt)"]
211
+
212
+ # Define subset dataframe
213
+ df_subset = df[variables]
214
+
215
+ # Prepare summary DataFrame
216
+ stats_cols = [
217
+ "MIN",
218
+ "Q1",
219
+ "Q5",
220
+ "Q10",
221
+ "Q50",
222
+ "Q90",
223
+ "Q95",
224
+ "Q99",
225
+ "MAX",
226
+ "MEAN",
227
+ "STD",
228
+ "MAD",
229
+ "SKEWNESS",
230
+ "KURTOSIS",
231
+ ]
232
+ df_stats = pd.DataFrame(index=variables, columns=stats_cols)
233
+
234
+ # Compute DSD integral parameters statistics
235
+ df_stats["MIN"] = df_subset.min()
236
+ df_stats["Q1"] = df_subset.quantile(0.01)
237
+ df_stats["Q5"] = df_subset.quantile(0.05)
238
+ df_stats["Q10"] = df_subset.quantile(0.10)
239
+ df_stats["Q50"] = df_subset.median()
240
+ df_stats["Q90"] = df_subset.quantile(0.90)
241
+ df_stats["Q95"] = df_subset.quantile(0.95)
242
+ df_stats["Q99"] = df_subset.quantile(0.99)
243
+ df_stats["MAX"] = df_subset.max()
244
+ df_stats["MEAN"] = df_subset.mean()
245
+ df_stats["STD"] = df_subset.std()
246
+ df_stats["MAD"] = df_subset.apply(lambda x: np.mean(np.abs(x - x.mean())))
247
+ df_stats["SKEWNESS"] = df_subset.skew()
248
+ df_stats["KURTOSIS"] = df_subset.kurt()
249
+
250
+ # Round statistics
251
+ df_stats = df_stats.astype(float).round(2)
252
+ return df_stats
253
+
254
+
255
+ def create_table_events_summary(df):
256
+ """Creata table with events statistics."""
257
+ # Event file
258
+ # - Events are separated by 1 hour or more rain-free periods in rain rate time series.
259
+ # - The events that are less than 'min_duration' minutes or the rain total is less than 0.1 mm
260
+ # are not reported.
261
+ event_settings = {
262
+ "neighbor_min_size": 2,
263
+ "neighbor_time_interval": "5MIN",
264
+ "event_max_time_gap": "1H",
265
+ "event_min_duration": "5MIN",
266
+ "event_min_size": 3,
267
+ }
268
+ # Keep rows with R > 0
269
+ df = df[df["R"] > 0]
270
+
271
+ # Number of years, months, days, minutes
272
+ if df.index.name == "time":
273
+ df = df.reset_index()
274
+ timesteps = np.sort(np.asanyarray(df["time"]))
275
+
276
+ # Define event list
277
+ event_list = group_timesteps_into_event(
278
+ timesteps=timesteps,
279
+ neighbor_min_size=event_settings["neighbor_min_size"],
280
+ neighbor_time_interval=event_settings["neighbor_time_interval"],
281
+ event_max_time_gap=event_settings["event_max_time_gap"],
282
+ event_min_duration=event_settings["event_min_duration"],
283
+ event_min_size=event_settings["event_min_size"],
284
+ )
285
+
286
+ # Create dataframe with statistics for each event
287
+ events_stats = []
288
+ rain_thresholds = [0.1, 1, 5, 10, 20, 50, 100]
289
+ for event in event_list:
290
+ # Retrieve event start_time and end_time
291
+ start, end = event["start_time"], event["end_time"]
292
+ # Retrieve event dataframe
293
+ df_event = df[(df["time"] >= start) & (df["time"] <= end)]
294
+ # Initialize event record
295
+ event_stats = {
296
+ # Event time info
297
+ "start_time": start,
298
+ "end_time": end,
299
+ "duration": int((end - start) / np.timedelta64(1, "m")),
300
+ # Rainy minutes above thresholds
301
+ **{f"rainy_minutes_>{thr}": int((df_event["R"] > thr).sum()) for thr in rain_thresholds},
302
+ # Total precipitation (mm)
303
+ "P_total": df_event["P"].sum(),
304
+ # R statistics
305
+ "mean_R": df_event["R"].mean(),
306
+ "median_R": df_event["R"].median(),
307
+ "max_R": df_event["R"].max(),
308
+ # DSD statistics
309
+ "max_Dmax": df_event["Dmax"].max(),
310
+ "mean_Dm": df_event["Dm"].mean(),
311
+ "median_Dm": df_event["Dm"].median(),
312
+ "max_Dm": df_event["Dm"].max(),
313
+ "mean_sigma_m": df_event["sigma_m"].mean(),
314
+ "median_sigma_m": df_event["sigma_m"].median(),
315
+ "max_sigma_m": df_event["sigma_m"].max(),
316
+ "mean_W": df_event["W"].mean(),
317
+ "median_W": df_event["W"].median(),
318
+ "max_W": df_event["W"].max(),
319
+ "max_Z": df_event["Z"].max(),
320
+ "mean_Nbins": int(df_event["Nbins"].mean()),
321
+ "max_Nbins": int(df_event["Nbins"].max()),
322
+ # TODO in future:
323
+ # - rain_detected = True
324
+ # - snow_detected = True
325
+ # - hail_detected = True
326
+ }
327
+ events_stats.append(event_stats)
328
+
329
+ df_events = pd.DataFrame.from_records(events_stats)
330
+ return df_events
331
+
332
+
333
+ def prepare_latex_table_dsd_summary(df):
334
+ """Prepare a DataFrame with DSD statistics for LaTeX table output."""
335
+ df = df.copy()
336
+ # Round float columns to nearest integer, leave ints unchanged
337
+ float_cols = df.select_dtypes(include=["float"]).columns
338
+ df[float_cols] = df[float_cols].astype(float).round(decimals=2).astype(str)
339
+ # Rename
340
+ rename_dict = {
341
+ "W": r"$W\,[\mathrm{g}\,\mathrm{m}^{-3}]$", # [g/m3]
342
+ "R": r"$R\,[\mathrm{mm}\,\mathrm{h}^{-1}]$", # [mm/hr]
343
+ "Z": r"$Z\,[\mathrm{dBZ}]$", # [dBZ]
344
+ "D50": r"$D_{50}\,[\mathrm{mm}]$", # [mm]
345
+ "Dm": r"$D_{m}\,[\mathrm{mm}]$", # [mm]
346
+ "sigma_m": r"$\sigma_{m}\,[\mathrm{mm}]$", # [mm]
347
+ "Dmax": r"$D_{\max}\,[\mathrm{mm}]$", # [mm]
348
+ "Nw": r"$N_{w}\,[\mathrm{mm}^{-1}\,\mathrm{m}^{-3}]$", # [mm$^{-1}$ m$^{-3}$]
349
+ "log10(Nw)": r"$\log_{10}(N_{w})$", # [$\log_{10}$(mm$^{-1}$ m$^{-3}$)]
350
+ "Nt": r"$N_{t}\,[\mathrm{m}^{-3}]$", # [m$^{-3}$]
351
+ "log10(Nt)": r"$\log_{10}(N_{t})$", # [log10(m$^{-3}$)]
352
+ }
353
+ df = df.rename(index=rename_dict)
354
+ return df
355
+
356
+
357
+ def prepare_latex_table_events_summary(df):
358
+ """Prepare a DataFrame with events statistics for LaTeX table output."""
359
+ df = df.copy()
360
+ # Round datetime to minutes
361
+ df["start_time"] = df["start_time"].dt.strftime("%Y-%m-%d %H:%M")
362
+ df["end_time"] = df["end_time"].dt.strftime("%Y-%m-%d %H:%M")
363
+ # Round float columns to nearest integer, leave ints unchanged
364
+ float_cols = df.select_dtypes(include=["float"]).columns
365
+ df[float_cols] = df[float_cols].astype(float).round(decimals=2).astype(str)
366
+ # Rename
367
+ rename_dict = {
368
+ "start_time": r"Start",
369
+ "end_time": r"End",
370
+ "duration": r"Min.",
371
+ "rainy_minutes_>0.1": r"R>0.1",
372
+ "rainy_minutes_>1": r"R>1",
373
+ "rainy_minutes_>5": r"R>5",
374
+ # 'rainy_minutes_>10': r'R>10',
375
+ # 'rainy_minutes_>20': r'R>20',
376
+ "rainy_minutes_>50": r"R>50",
377
+ # 'rainy_minutes_>100': r'R>100',
378
+ "P_total": r"$P_{\mathrm{tot}} [mm]$",
379
+ "mean_R": r"$R_{\mathrm{mean}}$",
380
+ "median_R": r"$R_{\mathrm{median}}$",
381
+ "max_R": r"$R_{\max}$",
382
+ "max_Dmax": r"$D_{\max}$",
383
+ "mean_Dm": r"$D_{m,\mathrm{mean}}$",
384
+ "median_Dm": r"$D_{m,\mathrm{median}}$",
385
+ "max_Dm": r"$D_{m,\max}$",
386
+ "mean_sigma_m": r"$\sigma_{m,\mathrm{mean}}$",
387
+ "median_sigma_m": r"$\sigma_{m,\mathrm{median}}$",
388
+ "max_sigma_m": r"$\sigma_{m,\max}$",
389
+ "mean_W": r"$W_{\mathrm{mean}}$",
390
+ "median_W": r"$W_{\mathrm{median}}$",
391
+ "max_W": r"$W_{\max}$",
392
+ "max_Z": r"$Z_{\max}$",
393
+ "mean_Nbins": r"$N_{\mathrm{bins},\mathrm{mean}}$",
394
+ "max_Nbins": r"$N_{\mathrm{bins},\max}$",
395
+ }
396
+ df = df[list(rename_dict)]
397
+ df = df.rename(columns=rename_dict)
398
+ return df
399
+
400
+
401
+ ####-------------------------------------------------------------------
402
+ #### Powerlaw routines
403
+
404
+
405
+ def fit_powerlaw_with_ransac(x, y):
406
+ """Fit powerlaw relationship with RANSAC algorithm."""
407
+ from sklearn.linear_model import LinearRegression, RANSACRegressor
408
+
409
+ x = np.asanyarray(x)
410
+ y = np.asanyarray(y)
411
+ X = np.log10(x).reshape(-1, 1)
412
+ Y = np.log10(y)
413
+ ransac = RANSACRegressor(
414
+ estimator=LinearRegression(),
415
+ min_samples=0.5,
416
+ residual_threshold=0.3,
417
+ random_state=42,
418
+ )
419
+ ransac.fit(X, Y)
420
+ b = ransac.estimator_.coef_[0] # slope
421
+ loga = ransac.estimator_.intercept_ # intercept
422
+ a = 10**loga
423
+ return a, b
424
+
425
+
426
+ def fit_powerlaw(x, y, xbins, quantile=0.5, min_counts=10, x_in_db=False, use_ransac=True):
427
+ """
428
+ Fit a power-law relationship ``y = a * x**b`` to binned median values.
429
+
430
+ This function bins ``x`` into intervals defined by ``xbins``, computes the
431
+ median of ``y`` in each bin (robust to outliers), and fits a power-law model
432
+ using the RANSAC or Levenberg-Marquardt algorithm.
433
+ Optionally, ``x`` can be converted from decibel units to linear scale automatically before fitting.
434
+
435
+ Parameters
436
+ ----------
437
+ x : array_like
438
+ Independent variable values. Must be positive and finite after filtering.
439
+ y : array_like
440
+ Dependent variable values. Must be positive and finite after filtering.
441
+ xbins : array_like
442
+ Bin edges for grouping ``x`` values (passed to ``pandas.cut``).
443
+ quantile : float, optional
444
+ Quantile of ``y`` to compute in each bin (between 0 and 1).
445
+ For example: 0.5 = median, 0.25 = lower quartile, 0.75 = upper quartile.
446
+ Default is 0.5 (median)
447
+ x_in_db : bool, optional
448
+ If True, converts ``x`` values from decibels (dB) to linear scale using
449
+ :func:`disdrodb.idecibel`. Default is False.
450
+ use_ransac: bool, optional
451
+ Whether to fit the powerlaw using the Random Sample Consensus (RANSAC)
452
+ algorithm or using the Levenberg-Marquardt algorithm.
453
+ The default is True.
454
+ To fit with RANSAC, scikit-learn must be installed.
455
+
456
+ Returns
457
+ -------
458
+ params : tuple of float
459
+ Estimated parameters ``(a, b)`` of the power-law relationship.
460
+ params_std : tuple of float
461
+ One standard deviation uncertainties ``(a_std, b_std)`` estimated from
462
+ the covariance matrix of the fit.
463
+ Parameters standard deviation is currently
464
+ not available if fitting with the RANSAC algorithm.
465
+
466
+ Notes
467
+ -----
468
+ - This implementation uses median statistics within bins, which reduces
469
+ the influence of outliers.
470
+ - Both ``x`` and ``y`` are filtered to retain only positive, finite values
471
+ before binning.
472
+ - Fitting is performed on the bin centers (midpoints between bin edges).
473
+
474
+ See Also
475
+ --------
476
+ predict_from_powerlaw : Predict values from the fitted power-law parameters.
477
+ inverse_powerlaw_parameters : Compute parameters of the inverse power law.
478
+
479
+ Examples
480
+ --------
481
+ >>> import numpy as np
482
+ >>> x = np.linspace(1, 50, 200)
483
+ >>> y = 2 * x**1.5 + np.random.normal(0, 5, size=x.size)
484
+ >>> xbins = np.arange(0, 60, 5)
485
+ >>> (a, b), (a_std, b_std) = fit_powerlaw(x, y, xbins)
486
+ """
487
+ # Set min_counts to 0 during pytest execution in order to test the summary routine
488
+ if os.environ.get("PYTEST_CURRENT_TEST"):
489
+ min_counts = 0
490
+
491
+ # Check if RANSAC algorithm is available
492
+ sklearn_available = importlib.util.find_spec("sklearn") is not None
493
+ if use_ransac and not sklearn_available:
494
+ use_ransac = False
495
+
496
+ # Ensure numpy array
497
+ x = np.asanyarray(x)
498
+ y = np.asanyarray(y)
499
+
500
+ # Ensure values > 0 and finite
501
+ valid_values = (x > 0) & (y > 0) & np.isfinite(x) & np.isfinite(y)
502
+ x = x[valid_values]
503
+ y = y[valid_values]
504
+
505
+ # Define dataframe
506
+ df_data = pd.DataFrame({"x": x, "y": y})
507
+
508
+ # Alternative code
509
+ # from disdrodb.utils.dataframe import compute_1d_histogram
510
+ # df_agg = compute_1d_histogram(
511
+ # df=df_data,
512
+ # column="x",
513
+ # variables="y",
514
+ # bins=xbins,
515
+ # prefix_name=True,
516
+ # include_quantiles=False
517
+ # )
518
+ # df_agg["count"] # instead of N
519
+
520
+ # - Keep only data within bin range
521
+ df_data = df_data[(df_data["x"] >= xbins[0]) & (df_data["x"] < xbins[-1])]
522
+ # - Define bins
523
+ df_data["x_bins"] = pd.cut(df_data["x"], bins=xbins, right=False)
524
+ # - Remove data outside specified bins
525
+ df_data = df_data[df_data["x_bins"].cat.codes != -1]
526
+
527
+ # Derive median y values at various x points
528
+ # - This typically remove outliers
529
+ df_agg = df_data.groupby(by="x_bins", observed=True)["y"].agg(
530
+ y=lambda s: s.quantile(quantile),
531
+ n="count",
532
+ mad=lambda s: (s - s.median()).abs().median(),
533
+ )
534
+ df_agg["x"] = np.array([iv.left + (iv.right - iv.left) / 2 for iv in df_agg.index])
535
+
536
+ # If input is in decibel, convert to linear scale
537
+ if x_in_db:
538
+ df_agg["x"] = disdrodb.idecibel(df_agg["x"])
539
+
540
+ # Remove bins with less than n counts
541
+ df_agg = df_agg[df_agg["n"] > min_counts]
542
+ if len(df_agg) < 5:
543
+ raise ValueError("Not enough data to fit a power law.")
544
+
545
+ # Estimate sigma based on MAD
546
+ sigma = df_agg["mad"]
547
+
548
+ # Fit the data
549
+ with suppress_warnings():
550
+ if use_ransac:
551
+ a, b = fit_powerlaw_with_ransac(x=df_agg["x"], y=df_agg["y"])
552
+ a_std = None
553
+ b_std = None
554
+ else:
555
+
556
+ (a, b), pcov = curve_fit(
557
+ lambda x, a, b: a * np.power(x, b),
558
+ df_agg["x"],
559
+ df_agg["y"],
560
+ method="lm",
561
+ sigma=sigma,
562
+ absolute_sigma=True,
563
+ maxfev=10_000, # max n iterations
564
+ )
565
+ (a_std, b_std) = np.sqrt(np.diag(pcov))
566
+ a_std = float(a_std)
567
+ b_std = float(b_std)
568
+
569
+ # Return the parameters and their standard deviation
570
+ return (float(a), float(b)), (a_std, b_std)
571
+
572
+
573
+ def predict_from_powerlaw(x, a, b):
574
+ """
575
+ Predict values from a power-law relationship ``y = a * x**b``.
576
+
577
+ Parameters
578
+ ----------
579
+ x : array_like
580
+ Independent variable values.
581
+ a : float
582
+ Power-law coefficient.
583
+ b : float
584
+ Power-law exponent.
585
+
586
+ Returns
587
+ -------
588
+ y : ndarray
589
+ Predicted dependent variable values.
590
+
591
+ Notes
592
+ -----
593
+ This function does not check for invalid (negative or zero) ``x`` values.
594
+ Ensure that ``x`` is compatible with the model before calling.
595
+ """
596
+ return a * np.power(x, b)
597
+
598
+
599
+ def inverse_powerlaw_parameters(a, b):
600
+ """
601
+ Compute parameters of the inverse power-law relationship.
602
+
603
+ Given a model ``y = a * x**b``, this returns parameters ``(A, B)``
604
+ such that the inverse relation ``x = A * y**B`` holds.
605
+
606
+ Parameters
607
+ ----------
608
+ a : float
609
+ Power-law coefficient in ``y = a * x**b``.
610
+ b : float
611
+ Power-law exponent in ``y = a * x**b``.
612
+
613
+ Returns
614
+ -------
615
+ A : float
616
+ Coefficient of the inverse power-law model.
617
+ B : float
618
+ Exponent of the inverse power-law model.
619
+ """
620
+ A = 1 / (a ** (1 / b))
621
+ B = 1 / b
622
+ return A, B
623
+
624
+
625
+ def predict_from_inverse_powerlaw(x, a, b):
626
+ """
627
+ Predict values from the inverse power-law relationship.
628
+
629
+ Given parameters ``a`` and ``b`` from ``x = a * y**b``, this function computes
630
+ ``y`` given ``x``.
631
+
632
+ Parameters
633
+ ----------
634
+ x : array_like
635
+ Values of ``x`` (independent variable in the original power law).
636
+ a : float
637
+ Power-law coefficient of the inverse power-law model.
638
+ b : float
639
+ Power-law exponent of the inverse power-law model.
640
+
641
+ Returns
642
+ -------
643
+ y : ndarray
644
+ Predicted dependent variable values.
645
+ """
646
+ return (x ** (1 / b)) / (a ** (1 / b))
647
+
648
+
649
+ ####-------------------------------------------------------------------
650
+ #### Drop spectrum plots
651
+
652
+
653
+ def plot_drop_spectrum(drop_number, norm=None, add_colorbar=True, title="Drop Spectrum"):
654
+ """Plot the drop spectrum."""
655
+ cmap = plt.get_cmap("Spectral_r").copy()
656
+ cmap.set_under("none")
657
+ if "time" in drop_number.dims:
658
+ drop_number = drop_number.sum(dim="time")
659
+ if norm is None:
660
+ norm = LogNorm(vmin=1, vmax=None) if drop_number.sum() > 0 else None
661
+
662
+ p = drop_number.plot.pcolormesh(
663
+ x=DIAMETER_DIMENSION,
664
+ y=VELOCITY_DIMENSION,
665
+ cmap=cmap,
666
+ extend="max",
667
+ norm=norm,
668
+ add_colorbar=add_colorbar,
669
+ cbar_kwargs={"label": "Number of particles"},
670
+ )
671
+ p.axes.set_xlabel("Diamenter [mm]")
672
+ p.axes.set_ylabel("Fall velocity [m/s]")
673
+ p.axes.set_title(title)
674
+ return p
675
+
676
+
677
+ def plot_raw_and_filtered_spectrums(
678
+ raw_drop_number,
679
+ drop_number,
680
+ theoretical_average_velocity,
681
+ measured_average_velocity=None,
682
+ norm=None,
683
+ figsize=(8, 4),
684
+ dpi=300,
685
+ ):
686
+ """Plot raw and filtered drop spectrum."""
687
+ # Drop number matrix
688
+ cmap = plt.get_cmap("Spectral_r").copy()
689
+ cmap.set_under("none")
690
+
691
+ if norm is None:
692
+ norm = LogNorm(1, None)
693
+
694
+ fig = plt.figure(figsize=figsize, dpi=dpi)
695
+ gs = GridSpec(1, 2, width_ratios=[1, 1.15], wspace=0.05) # More space for ax2
696
+ ax1 = fig.add_subplot(gs[0])
697
+ ax2 = fig.add_subplot(gs[1])
698
+
699
+ raw_drop_number.plot.pcolormesh(
700
+ x=DIAMETER_DIMENSION,
701
+ y=VELOCITY_DIMENSION,
702
+ ax=ax1,
703
+ cmap=cmap,
704
+ norm=norm,
705
+ extend="max",
706
+ add_colorbar=False,
707
+ )
708
+ theoretical_average_velocity.plot(ax=ax1, c="k", linestyle="dashed")
709
+ if measured_average_velocity is not None:
710
+ measured_average_velocity.plot(ax=ax1, c="k", linestyle="dotted")
711
+ ax1.set_xlabel("Diamenter [mm]")
712
+ ax1.set_ylabel("Fall velocity [m/s]")
713
+ ax1.set_title("Raw Spectrum")
714
+ drop_number.plot.pcolormesh(
715
+ x=DIAMETER_DIMENSION,
716
+ y=VELOCITY_DIMENSION,
717
+ cmap=cmap,
718
+ extend="max",
719
+ ax=ax2,
720
+ norm=norm,
721
+ cbar_kwargs={"label": "Number of particles"},
722
+ )
723
+ theoretical_average_velocity.plot(ax=ax2, c="k", linestyle="dashed", label="Theoretical velocity")
724
+ if measured_average_velocity is not None:
725
+ measured_average_velocity.plot(ax=ax2, c="k", linestyle="dotted", label="Measured average velocity")
726
+ ax2.set_yticks([])
727
+ ax2.set_yticklabels([])
728
+ ax2.set_xlabel("Diamenter [mm]")
729
+ ax2.set_ylabel("")
730
+ ax2.set_title("Filtered Spectrum")
731
+ ax2.legend(loc="lower right", frameon=False)
732
+ return fig
733
+
734
+
735
+ ####-------------------------------------------------------------------
736
+ #### N(D) Climatological plots
737
+
738
+
739
+ def create_nd_dataframe(ds, variables=None):
740
+ """Create pandas Dataframe with N(D) data."""
741
+ # Define variables to select
742
+ if isinstance(variables, str):
743
+ variables = [variables]
744
+ variables = [] if variables is None else variables
745
+ variables = ["drop_number_concentration", "Nw", "diameter_bin_center", "Dm", "D50", "R", *variables]
746
+ variables = np.unique(variables).tolist()
747
+
748
+ # Retrieve stacked N(D) dataframe
749
+ ds_stack = ds[variables].stack( # noqa: PD013
750
+ dim={"obs": ["time", "diameter_bin_center"]},
751
+ )
752
+ # Drop coordinates
753
+ coords_to_drop = [
754
+ "velocity_method",
755
+ "sample_interval",
756
+ *RADAR_OPTIONS,
757
+ ]
758
+ df_nd = ds_stack.to_dataframe().drop(columns=coords_to_drop, errors="ignore")
759
+ df_nd["D"] = df_nd["diameter_bin_center"]
760
+ df_nd["N(D)"] = df_nd["drop_number_concentration"]
761
+ df_nd = df_nd[df_nd["R"] != 0]
762
+ df_nd = df_nd[df_nd["N(D)"] != 0]
763
+
764
+ # Compute normalized density
765
+ df_nd["D/D50"] = df_nd["D"] / df_nd["D50"]
766
+ df_nd["D/Dm"] = df_nd["D"] / df_nd["Dm"]
767
+ df_nd["N(D)/Nw"] = df_nd["N(D)"] / df_nd["Nw"]
768
+ df_nd["log10[N(D)/Nw]"] = np.log10(df_nd["N(D)/Nw"])
769
+ return df_nd
770
+
771
+
772
+ def plot_normalized_dsd_density(df_nd, x="D/D50", figsize=(8, 8), dpi=300):
773
+ """Plot normalized DSD N(D)/Nw ~ D/D50 (or D/Dm) density."""
774
+ ds_stats = compute_2d_histogram(
775
+ df_nd,
776
+ x=x,
777
+ y="N(D)/Nw",
778
+ x_bins=np.arange(0, 4, 0.025),
779
+ y_bins=log_arange(1e-5, 50, log_step=0.1, base=10),
780
+ )
781
+ cmap = plt.get_cmap("Spectral_r").copy()
782
+ cmap.set_under(alpha=0)
783
+ norm = LogNorm(1, None)
784
+
785
+ ds_stats = ds_stats.isel({"N(D)/Nw": ds_stats["N(D)/Nw"] > 0})
786
+
787
+ fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
788
+ p = ds_stats["count"].plot.pcolormesh(
789
+ x=x,
790
+ y="N(D)/Nw",
791
+ ax=ax,
792
+ vmin=1,
793
+ cmap=cmap,
794
+ norm=norm,
795
+ extend="max",
796
+ yscale="log",
797
+ )
798
+ ax.set_ylim(1e-5, 20)
799
+ ax.set_xlim(0, 4)
800
+ ax.set_xlabel(f"{x} [-]")
801
+ ax.set_ylabel(r"$N(D)/N_w$ [-]")
802
+ ax.set_title("Normalized DSD")
803
+ return p
804
+
805
+
806
+ def plot_dsd_density(df_nd, diameter_bin_edges, figsize=(8, 8), dpi=300):
807
+ """Plot N(D) ~ D density."""
808
+ ds_stats = compute_2d_histogram(
809
+ df_nd,
810
+ x="D",
811
+ y="N(D)",
812
+ x_bins=diameter_bin_edges,
813
+ y_bins=log_arange(0.1, 20_000, log_step=0.1, base=10),
814
+ )
815
+ cmap = plt.get_cmap("Spectral_r").copy()
816
+ cmap.set_under(alpha=0)
817
+ norm = LogNorm(1, None)
818
+ fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
819
+ p = ds_stats["count"].plot.pcolormesh(x="D", y="N(D)", ax=ax, cmap=cmap, norm=norm, extend="max", yscale="log")
820
+ ax.set_xlim(0, 8)
821
+ ax.set_ylim(1, 20_000)
822
+ ax.set_xlabel(r"$D$ [mm]")
823
+ ax.set_ylabel(r"$N(D)$ [m$^{-3}$ mm$^{-1}$]")
824
+ ax.set_title("DSD")
825
+ return p
826
+
827
+
828
+ def plot_dsd_with_dense_lines(drop_number_concentration, r, figsize=(8, 8), dpi=300):
829
+ """Plot N(D) ~ D using dense lines."""
830
+ # Define intervals for rain rates
831
+ r_bins = [0, 2, 5, 10, 50, 100, 500]
832
+
833
+ # Define N(D) bins and diameter bin edeges
834
+ y_bins = log_arange(1, 20_000, log_step=0.025, base=10)
835
+ diameter_bin_edges = np.arange(0, 8, 0.01)
836
+
837
+ # Resample N(D) to high resolution !
838
+ # - quadratic, pchip
839
+ da = resample_drop_number_concentration(
840
+ drop_number_concentration.compute(),
841
+ diameter_bin_edges=diameter_bin_edges,
842
+ method="linear",
843
+ )
844
+ ds_resampled = xr.Dataset(
845
+ {
846
+ "R": r.compute(),
847
+ "drop_number_concentration": da,
848
+ },
849
+ )
850
+
851
+ # Define diameter bin edges to compute dense lines
852
+ x_bins = da.disdrodb.diameter_bin_edges
853
+
854
+ # Define discrete colormap (one color per rain-interval):
855
+ cmap_list = [
856
+ plt.get_cmap("Reds"),
857
+ plt.get_cmap("Oranges"),
858
+ plt.get_cmap("Purples"),
859
+ plt.get_cmap("Greens"),
860
+ plt.get_cmap("Blues"),
861
+ plt.get_cmap("Grays"),
862
+ ]
863
+ cmap_list = [ListedColormap(cmap(np.arange(0, cmap.N))[-40:]) for cmap in cmap_list]
864
+
865
+ # Compute dense lines
866
+ dict_rgb = {}
867
+ for i in range(0, len(r_bins) - 1):
868
+
869
+ # Define dataset subset
870
+ idx_rain_interval = np.logical_and(ds_resampled["R"] >= r_bins[i], ds_resampled["R"] < r_bins[i + 1])
871
+ da = ds_resampled.isel(time=idx_rain_interval)["drop_number_concentration"]
872
+ if da.sizes["time"] == 0:
873
+ continue
874
+
875
+ # Retrieve dense lines
876
+ da_dense_lines = compute_dense_lines(
877
+ da=da,
878
+ coord="diameter_bin_center",
879
+ x_bins=x_bins,
880
+ y_bins=y_bins,
881
+ normalization="max",
882
+ )
883
+
884
+ # Define cmap
885
+ cmap = cmap_list[i]
886
+
887
+ # Map colors and transparency
888
+ # da_rgb = to_rgba(da_dense_lines, cmap=cmap, scaling="linear")
889
+ # da_rgb = to_rgba(da_dense_lines, cmap=cmap, scaling="exp")
890
+ # da_rgb = to_rgba(da_dense_lines, cmap=cmap, scaling="log")
891
+ da_rgb = to_rgba(da_dense_lines, cmap=cmap, scaling="sqrt")
892
+
893
+ # Add to dictionary
894
+ dict_rgb[i] = da_rgb
895
+
896
+ # Blend images with max-alpha
897
+ ds_rgb = xr.concat(dict_rgb.values(), dim="r_class")
898
+ da_blended = max_blend_images(ds_rgb, dim="r_class")
899
+
900
+ # Prepare legend handles
901
+ handles = []
902
+ labels = []
903
+ for i in range(len(r_bins) - 1):
904
+ color = cmap_list[i](0.8) # pick a representative color from each cmap
905
+ handle = mlines.Line2D([], [], color=color, alpha=0.6, linewidth=2)
906
+ label = f"[{r_bins[i]} - {r_bins[i+1]}]"
907
+ handles.append(handle)
908
+ labels.append(label)
909
+
910
+ # Create figure
911
+ fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
912
+
913
+ p = ax.pcolormesh(
914
+ da_blended["diameter_bin_center"],
915
+ da_blended["drop_number_concentration"],
916
+ da_blended.data,
917
+ )
918
+
919
+ # Set axis scale and limits
920
+ ax.set_yscale("log")
921
+ ax.set_xlim(0, 8)
922
+ ax.set_ylim(1, 20_000)
923
+
924
+ # Add axis labels and title
925
+ ax.set_xlabel(r"$D$ [mm]")
926
+ ax.set_ylabel(r"$N(D)$ [m$^{-3}$ mm$^{-1}$]")
927
+ ax.set_title("DSD")
928
+
929
+ # Add legend with title
930
+ ax.legend(handles, labels, title="Rain rate [mm/hr]", loc="upper right")
931
+
932
+ # Return figure
933
+ return p
934
+
935
+
936
+ ####-------------------------------------------------------------------
937
+ #### DSD parameters plots
938
+
939
+
940
+ def define_lognorm_max_value(value):
941
+ """Round up to next nice number: 90->100, 400->500, 1200->2000."""
942
+ if value <= 0:
943
+ return 1
944
+ magnitude = 10 ** np.floor(np.log10(value))
945
+ first_digit = value / magnitude
946
+ nice_value = 1 if first_digit <= 1 else 2 if first_digit <= 2 else 5 if first_digit <= 5 else 10
947
+ return nice_value * magnitude
948
+
949
+
950
+ def plot_dsd_params_relationships(df, add_nt=False, dpi=300):
951
+ """Create a figure illustrating the relationships between DSD parameters."""
952
+ # TODO: option to use D50 instead of Dm
953
+
954
+ # Compute the required datasets for the plots
955
+ # - Dm vs Nw
956
+ ds_Dm_Nw_stats = compute_2d_histogram(
957
+ df,
958
+ x="Dm",
959
+ y="Nw",
960
+ variables=["R", "W", "Nt"],
961
+ x_bins=np.arange(0, 8, 0.1),
962
+ y_bins=log_arange(10, 1_000_000, log_step=0.05, base=10),
963
+ )
964
+
965
+ # - Dm vs LWC (W)
966
+ ds_Dm_LWC_stats = compute_2d_histogram(
967
+ df,
968
+ x="Dm",
969
+ y="W",
970
+ variables=["R", "Nw", "Nt"],
971
+ x_bins=np.arange(0, 8, 0.1),
972
+ y_bins=log_arange(0.01, 10, log_step=0.05, base=10),
973
+ )
974
+
975
+ # - Dm vs R
976
+ ds_Dm_R_stats = compute_2d_histogram(
977
+ df,
978
+ x="Dm",
979
+ y="R",
980
+ variables=["Nw", "W", "Nt"],
981
+ x_bins=np.arange(0, 8, 0.1),
982
+ y_bins=log_arange(0.1, 500, log_step=0.05, base=10),
983
+ )
984
+
985
+ # - Dm vs Nt
986
+ ds_Dm_Nt_stats = compute_2d_histogram(
987
+ df,
988
+ x="Dm",
989
+ y="Nt",
990
+ variables=["R", "W", "Nw"],
991
+ x_bins=np.arange(0, 8, 0.1),
992
+ y_bins=log_arange(1, 100_000, log_step=0.05, base=10),
993
+ )
994
+
995
+ # Define different colormaps for each column
996
+ cmap_counts = plt.get_cmap("viridis")
997
+ cmap_lwc = plt.get_cmap("YlOrRd")
998
+ cmap_r = plt.get_cmap("Blues")
999
+ cmap_nt = plt.get_cmap("Greens")
1000
+
1001
+ # Define normalizations for each variable
1002
+ norm_counts = LogNorm(1, None)
1003
+ norm_lwc = LogNorm(0.01, 10)
1004
+ norm_r = LogNorm(0.1, 500)
1005
+ # norm_nw = LogNorm(10, 100000)
1006
+ norm_nt = LogNorm(1, 10000)
1007
+
1008
+ # Define axis limits
1009
+ dm_lim = (0.3, 6)
1010
+ nw_lim = (10, 1_000_000)
1011
+ lwc_lim = (0.01, 10)
1012
+ r_lim = (0.1, 500)
1013
+ nt_lim = (1, 100_000)
1014
+
1015
+ # Define figure size
1016
+ if add_nt:
1017
+ figsize = (16, 16)
1018
+ nrows = 4
1019
+ height_ratios = [0.2, 1, 1, 1, 1]
1020
+ else:
1021
+ figsize = (16, 12)
1022
+ nrows = 3
1023
+ height_ratios = [0.2, 1, 1, 1]
1024
+
1025
+ # Create figure with 4x4 subplots
1026
+ fig = plt.figure(figsize=figsize, dpi=dpi)
1027
+ gs = fig.add_gridspec(nrows + 1, 4, height_ratios=height_ratios, hspace=0.05, wspace=0.02)
1028
+ axes = np.empty((nrows + 1, 4), dtype=object)
1029
+
1030
+ # Create colorbar axes in the bottom row of the gridspec
1031
+ cbar_axes = [fig.add_subplot(gs[0, j]) for j in range(4)]
1032
+
1033
+ # Create axes for the grid
1034
+ for i in range(1, nrows + 1):
1035
+ for j in range(4):
1036
+ axes[i, j] = fig.add_subplot(gs[i, j])
1037
+
1038
+ # Create empty subplot for diagonal elements (when y-axis variable matches column variable)
1039
+ # axes[2, 1].set_visible(False)
1040
+ # axes[3, 2].set_visible(False)
1041
+ # axes[4, 3].set_visible(False)
1042
+
1043
+ ####-------------------------------------------------------------------.
1044
+ #### Dm vs Nw
1045
+ #### - Counts
1046
+ im_counts = ds_Dm_Nw_stats["count"].plot.pcolormesh(
1047
+ x="Dm",
1048
+ y="Nw",
1049
+ cmap=cmap_counts,
1050
+ norm=norm_counts,
1051
+ extend="max",
1052
+ yscale="log",
1053
+ add_colorbar=False,
1054
+ ax=axes[1, 0],
1055
+ )
1056
+ axes[1, 0].set_ylabel(r"$N_w$ [mm$^{-1}$ m$^{-3}$]")
1057
+ axes[1, 0].set_xlim(*dm_lim)
1058
+ axes[1, 0].set_ylim(nw_lim)
1059
+
1060
+ #### - LWC
1061
+ im_lwc = ds_Dm_Nw_stats["W_median"].plot.pcolormesh(
1062
+ x="Dm",
1063
+ y="Nw",
1064
+ cmap=cmap_lwc,
1065
+ norm=norm_lwc,
1066
+ extend="both",
1067
+ yscale="log",
1068
+ add_colorbar=False,
1069
+ ax=axes[1, 1],
1070
+ )
1071
+ axes[1, 1].set_xlim(*dm_lim)
1072
+ axes[1, 1].set_ylim(nw_lim)
1073
+
1074
+ #### - R
1075
+ im_r = ds_Dm_Nw_stats["R_median"].plot.pcolormesh(
1076
+ x="Dm",
1077
+ y="Nw",
1078
+ cmap=cmap_r,
1079
+ norm=norm_r,
1080
+ extend="both",
1081
+ yscale="log",
1082
+ add_colorbar=False,
1083
+ ax=axes[1, 2],
1084
+ )
1085
+ axes[1, 2].set_xlim(*dm_lim)
1086
+ axes[1, 2].set_ylim(nw_lim)
1087
+ axes[1, 2].set_yticklabels([])
1088
+
1089
+ #### - Nt
1090
+ im_nt = ds_Dm_Nw_stats["Nt_median"].plot.pcolormesh(
1091
+ x="Dm",
1092
+ y="Nw",
1093
+ cmap=cmap_nt,
1094
+ norm=norm_nt,
1095
+ extend="both",
1096
+ yscale="log",
1097
+ add_colorbar=False,
1098
+ ax=axes[1, 3],
1099
+ )
1100
+ axes[1, 3].set_xlim(*dm_lim)
1101
+ axes[1, 3].set_ylim(nw_lim)
1102
+ axes[1, 3].set_yticklabels([])
1103
+
1104
+ ####-------------------------------------------------------------------.
1105
+ #### Dm vs LWC
1106
+ #### - Counts
1107
+ ds_Dm_LWC_stats["count"].plot.pcolormesh(
1108
+ x="Dm",
1109
+ y="W",
1110
+ cmap=cmap_counts,
1111
+ norm=norm_counts,
1112
+ extend="max",
1113
+ yscale="log",
1114
+ add_colorbar=False,
1115
+ ax=axes[2, 0],
1116
+ )
1117
+ axes[2, 0].set_ylabel(r"LWC [g/m³]")
1118
+ axes[2, 0].set_xlim(*dm_lim)
1119
+ axes[2, 0].set_ylim(lwc_lim)
1120
+
1121
+ #### - LWC
1122
+ # - Empty (diagonal where y-axis is W) - handled above in the loop
1123
+ ds_Dm_LWC_stats["R_median"].plot.pcolormesh(
1124
+ x="Dm",
1125
+ y="W",
1126
+ cmap=cmap_r,
1127
+ norm=norm_r,
1128
+ alpha=0, # fully transparent
1129
+ extend="both",
1130
+ yscale="log",
1131
+ add_colorbar=False,
1132
+ ax=axes[2, 1],
1133
+ )
1134
+ axes[2, 1].set_xlim(*dm_lim)
1135
+ axes[2, 1].set_ylim(lwc_lim)
1136
+ axes[2, 1].set_yticklabels([])
1137
+
1138
+ #### - R
1139
+ ds_Dm_LWC_stats["R_median"].plot.pcolormesh(
1140
+ x="Dm",
1141
+ y="W",
1142
+ cmap=cmap_r,
1143
+ norm=norm_r,
1144
+ extend="both",
1145
+ yscale="log",
1146
+ add_colorbar=False,
1147
+ ax=axes[2, 2],
1148
+ )
1149
+ axes[2, 2].set_xlim(*dm_lim)
1150
+ axes[2, 2].set_ylim(lwc_lim)
1151
+ axes[2, 2].set_yticklabels([])
1152
+
1153
+ #### - Nt
1154
+ im_nt = ds_Dm_LWC_stats["Nt_median"].plot.pcolormesh(
1155
+ x="Dm",
1156
+ y="W",
1157
+ cmap=cmap_nt,
1158
+ norm=norm_nt,
1159
+ extend="both",
1160
+ yscale="log",
1161
+ add_colorbar=False,
1162
+ ax=axes[2, 3],
1163
+ )
1164
+ axes[2, 3].set_xlim(*dm_lim)
1165
+ axes[2, 3].set_ylim(lwc_lim)
1166
+ axes[2, 3].set_yticklabels([])
1167
+
1168
+ ####-------------------------------------------------------------------.
1169
+ #### Dm vs R
1170
+ #### - Counts
1171
+ ds_Dm_R_stats["count"].plot.pcolormesh(
1172
+ x="Dm",
1173
+ y="R",
1174
+ cmap=cmap_counts,
1175
+ norm=norm_counts,
1176
+ extend="max",
1177
+ yscale="log",
1178
+ add_colorbar=False,
1179
+ ax=axes[3, 0],
1180
+ )
1181
+ axes[3, 0].set_ylabel(r"$R$ [mm h$^{-1}$]")
1182
+ axes[3, 0].set_xlim(*dm_lim)
1183
+ axes[3, 0].set_ylim(r_lim)
1184
+
1185
+ #### - LWC
1186
+ im_lwc = ds_Dm_R_stats["W_median"].plot.pcolormesh(
1187
+ x="Dm",
1188
+ y="R",
1189
+ cmap=cmap_lwc,
1190
+ norm=norm_lwc,
1191
+ extend="both",
1192
+ yscale="log",
1193
+ add_colorbar=False,
1194
+ ax=axes[3, 1],
1195
+ )
1196
+ axes[3, 1].set_xlim(*dm_lim)
1197
+ axes[3, 1].set_ylim(r_lim)
1198
+ axes[3, 1].set_yticklabels([])
1199
+
1200
+ #### - R
1201
+ # - Empty (diagonal where y-axis is R) - handled above in the loop
1202
+ #### - Nt
1203
+ ds_Dm_R_stats["Nt_median"].plot.pcolormesh(
1204
+ x="Dm",
1205
+ y="R",
1206
+ cmap=cmap_nt,
1207
+ norm=norm_nt,
1208
+ alpha=0, # fully transparent
1209
+ extend="both",
1210
+ yscale="log",
1211
+ add_colorbar=False,
1212
+ ax=axes[3, 2],
1213
+ )
1214
+ axes[3, 2].set_xlim(*dm_lim)
1215
+ axes[3, 2].set_ylim(r_lim)
1216
+ axes[3, 2].set_yticklabels([])
1217
+
1218
+ #### - Nt
1219
+ ds_Dm_R_stats["Nt_median"].plot.pcolormesh(
1220
+ x="Dm",
1221
+ y="R",
1222
+ cmap=cmap_nt,
1223
+ norm=norm_nt,
1224
+ extend="both",
1225
+ yscale="log",
1226
+ add_colorbar=False,
1227
+ ax=axes[3, 3],
1228
+ )
1229
+ axes[3, 3].set_xlim(*dm_lim)
1230
+ axes[3, 3].set_ylim(r_lim)
1231
+ axes[3, 3].set_yticklabels([])
1232
+
1233
+ ####-------------------------------------------------------------------.
1234
+ #### Dm vs Nt
1235
+ if add_nt:
1236
+ #### - Counts
1237
+ ds_Dm_Nt_stats["count"].plot.pcolormesh(
1238
+ x="Dm",
1239
+ y="Nt",
1240
+ cmap=cmap_counts,
1241
+ norm=norm_counts,
1242
+ extend="max",
1243
+ yscale="log",
1244
+ add_colorbar=False,
1245
+ ax=axes[4, 0],
1246
+ )
1247
+ axes[4, 0].set_ylabel(r"$N_t$ [m$^{-3}$]")
1248
+ axes[4, 0].set_xlabel(r"$D_m$ [mm]")
1249
+ axes[4, 0].set_xlim(*dm_lim)
1250
+ axes[4, 0].set_ylim(nt_lim)
1251
+
1252
+ #### - LWC
1253
+ ds_Dm_Nt_stats["W_median"].plot.pcolormesh(
1254
+ x="Dm",
1255
+ y="Nt",
1256
+ cmap=cmap_lwc,
1257
+ norm=norm_lwc,
1258
+ extend="both",
1259
+ yscale="log",
1260
+ add_colorbar=False,
1261
+ ax=axes[4, 1],
1262
+ )
1263
+ axes[4, 1].set_xlabel(r"$D_m$ [mm]")
1264
+ axes[4, 1].set_xlim(*dm_lim)
1265
+ axes[4, 1].set_ylim(nt_lim)
1266
+ axes[4, 1].set_yticklabels([])
1267
+
1268
+ #### - R
1269
+ ds_Dm_Nt_stats["R_median"].plot.pcolormesh(
1270
+ x="Dm",
1271
+ y="Nt",
1272
+ cmap=cmap_r,
1273
+ norm=norm_r,
1274
+ extend="both",
1275
+ yscale="log",
1276
+ add_colorbar=False,
1277
+ ax=axes[4, 2],
1278
+ )
1279
+ axes[4, 2].set_xlabel(r"$D_m$ [mm]")
1280
+ axes[4, 2].set_xlim(*dm_lim)
1281
+ axes[4, 2].set_ylim(nt_lim)
1282
+ axes[4, 2].set_yticklabels([])
1283
+
1284
+ #### - Nt
1285
+ # - Empty plot - handled above in the loop
1286
+ ds_Dm_Nt_stats["R_median"].plot.pcolormesh(
1287
+ x="Dm",
1288
+ y="Nt",
1289
+ cmap=cmap_r,
1290
+ norm=norm_r,
1291
+ alpha=0, # fully transparent
1292
+ extend="both",
1293
+ yscale="log",
1294
+ add_colorbar=False,
1295
+ ax=axes[4, 2],
1296
+ )
1297
+ axes[4, 3].set_xlabel(r"$D_m$ [mm]")
1298
+ axes[4, 3].set_xlim(*dm_lim)
1299
+ axes[4, 3].set_ylim(nt_lim)
1300
+ axes[4, 3].set_yticklabels([])
1301
+
1302
+ ####-------------------------------------------------------------------.
1303
+ #### Finalize figure
1304
+ # Remove x ticks and labels for all but bottom row
1305
+ for i in range(1, nrows):
1306
+ for j in range(4):
1307
+ if axes[i, j].get_visible():
1308
+ axes[i, j].set_xticklabels([])
1309
+ axes[i, j].set_xticks([])
1310
+ axes[i, j].set_xlabel("")
1311
+
1312
+ # Remove y ticks and labels for all but left row
1313
+ for i in range(1, nrows + 1):
1314
+ for j in range(1, 4):
1315
+ if axes[i, j].get_visible():
1316
+ axes[i, j].set_yticks([])
1317
+ axes[i, j].set_yticklabels([])
1318
+ axes[i, j].set_ylabel("")
1319
+
1320
+ # -------------------------------------------------.
1321
+ # Add colorbars
1322
+ # - Counts colorbar
1323
+ cbar1 = plt.colorbar(im_counts, cax=cbar_axes[0], orientation="horizontal", extend="both")
1324
+ cbar1.set_label("Counts", fontweight="bold")
1325
+ cbar1.ax.xaxis.set_label_position("top")
1326
+ cbar1.ax.set_aspect(0.25)
1327
+ # - LWC colorbar
1328
+ cbar2 = plt.colorbar(im_lwc, cax=cbar_axes[1], orientation="horizontal", extend="both")
1329
+ cbar2.set_label("Median LWC [g/m³]", fontweight="bold")
1330
+ cbar2.ax.xaxis.set_label_position("top")
1331
+ cbar2.ax.set_aspect(0.25)
1332
+ # - R colorbar
1333
+ cbar3 = plt.colorbar(im_r, cax=cbar_axes[2], orientation="horizontal", extend="both")
1334
+ cbar3.set_label("Median R [mm/h]", fontweight="bold")
1335
+ cbar3.ax.xaxis.set_label_position("top")
1336
+ cbar3.ax.set_aspect(0.3)
1337
+ # - Nt colorbar
1338
+ cbar4 = plt.colorbar(im_nt, cax=cbar_axes[3], orientation="horizontal", extend="both")
1339
+ cbar4.set_label("Median $N_t$ [m$^{-3}$]", fontweight="bold")
1340
+ cbar4.ax.xaxis.set_label_position("top")
1341
+ cbar4.ax.set_aspect(0.3)
1342
+
1343
+ # -------------------------------------------------.
1344
+ # Return figure
1345
+ return fig
1346
+
1347
+
1348
+ def plot_dsd_params_density(df, log_dm=False, lwc=True, log_normalize=False, figsize=(10, 10), dpi=300):
1349
+ """Generate a figure with various DSD relationships.
1350
+
1351
+ All histograms are computed first, then normalized, and finally plotted together.
1352
+
1353
+ Parameters
1354
+ ----------
1355
+ df : pandas.DataFrame
1356
+ DataFrame containing DSD parameters (Dm, Nt, Nw, LWC/W, R, sigma_m, M2, M3, M4, M6)
1357
+ log_dm : bool, optional
1358
+ If True, use linear scale for Dm axes. If False, use log scale. Default is True.
1359
+ lwc : bool, optional
1360
+ If True, use Liquid Water Content (W). If False, use Rain Rate (R). Default is True.
1361
+ figsize : tuple, optional
1362
+ Figure size (width, height) in inches. Default is (18, 18).
1363
+
1364
+ Returns
1365
+ -------
1366
+ fig : matplotlib.figure.Figure
1367
+ The figure object containing all subplots
1368
+ axes : numpy.ndarray
1369
+ Array of all subplot axes
1370
+ """
1371
+ # TODO: option to use D50 instead of Dm
1372
+
1373
+ # Common parameters
1374
+ cmap = plt.get_cmap("Spectral_r").copy()
1375
+ norm = Normalize(0, 1) # Normalized data goes from 0 to 1
1376
+
1377
+ # Define the water variable based on lwc flag
1378
+ df["LWC"] = df["W"]
1379
+ water_var = "LWC" if lwc else "R"
1380
+ water_label = "LWC [g/m³]" if lwc else "R [mm/h]"
1381
+
1382
+ log_step = 0.05
1383
+ linear_step = 0.1
1384
+
1385
+ # Dm range and scale settings
1386
+ if not log_dm:
1387
+ dm_bins = np.arange(0, 8, linear_step)
1388
+ dm_scale = None
1389
+ dm_lim = (0, 6)
1390
+ dm_ticklabels = [0, 2, 4, 6]
1391
+ else:
1392
+ dm_bins = log_arange(0.1, 10, log_step=log_step, base=10)
1393
+ dm_scale = "log"
1394
+ dm_lim = (0.3, 6)
1395
+ dm_ticklabels = [0.5, 1, 2, 5]
1396
+
1397
+ # Nt and Nw range
1398
+ nt_bins = log_arange(1, 100_000, log_step=log_step, base=10)
1399
+ nw_bins = log_arange(1, 1_000_000, log_step=log_step, base=10)
1400
+ nw_lim = (10, 1_000_000)
1401
+ nt_lim = (1, 100_000)
1402
+
1403
+ # Water range
1404
+ if lwc:
1405
+ water_bins = log_arange(0.001, 10, log_step=log_step, base=10)
1406
+ water_lim = (0.005, 10)
1407
+ else:
1408
+ water_bins = log_arange(0.1, 500, log_step=log_step, base=10)
1409
+ water_lim = (0.1, 500)
1410
+
1411
+ # Define sigma_m bins
1412
+ sigma_bins = np.arange(0, 4, linear_step / 2)
1413
+ sigma_lim = (0, 3)
1414
+
1415
+ # Compute all histograms first
1416
+ # 1. Dm vs Nt
1417
+ ds_stats_dm_nt = compute_2d_histogram(
1418
+ df,
1419
+ x="Dm",
1420
+ y="Nt",
1421
+ x_bins=dm_bins,
1422
+ y_bins=nt_bins,
1423
+ )
1424
+
1425
+ # 2. Dm vs Nw
1426
+ ds_stats_dm_nw = compute_2d_histogram(
1427
+ df,
1428
+ x="Dm",
1429
+ y="Nw",
1430
+ x_bins=dm_bins,
1431
+ y_bins=nw_bins,
1432
+ )
1433
+
1434
+ # 3. Dm vs LWC/R
1435
+ ds_stats_dm_w = compute_2d_histogram(
1436
+ df,
1437
+ x="Dm",
1438
+ y=water_var,
1439
+ x_bins=dm_bins,
1440
+ y_bins=water_bins,
1441
+ )
1442
+
1443
+ # 4. LWC/R vs Nt
1444
+ ds_stats_w_nt = compute_2d_histogram(
1445
+ df,
1446
+ x=water_var,
1447
+ y="Nt",
1448
+ x_bins=water_bins,
1449
+ y_bins=nt_bins,
1450
+ )
1451
+
1452
+ # 5. LWC/R vs Nw
1453
+ ds_stats_w_nw = compute_2d_histogram(
1454
+ df,
1455
+ x=water_var,
1456
+ y="Nw",
1457
+ x_bins=water_bins,
1458
+ y_bins=nw_bins,
1459
+ )
1460
+
1461
+ # 6. LWC/R vs sigma_m
1462
+ ds_stats_w_sigma = compute_2d_histogram(
1463
+ df,
1464
+ x=water_var,
1465
+ y="sigma_m",
1466
+ x_bins=water_bins,
1467
+ y_bins=sigma_bins,
1468
+ )
1469
+
1470
+ # 7. M2 vs M4
1471
+ ds_stats_m2_m4 = compute_2d_histogram(
1472
+ df,
1473
+ x="M2",
1474
+ y="M4",
1475
+ x_bins=log_arange(1, 10_000, log_step=log_step, base=10),
1476
+ y_bins=log_arange(1, 40_000, log_step=log_step, base=10),
1477
+ )
1478
+
1479
+ # 8. M3 vs M6
1480
+ ds_stats_m3_m6 = compute_2d_histogram(
1481
+ df,
1482
+ x="M3",
1483
+ y="M6",
1484
+ x_bins=log_arange(1, 10_000, log_step=log_step, base=10),
1485
+ y_bins=log_arange(0.1, 1000_000, log_step=log_step, base=10),
1486
+ )
1487
+
1488
+ # 9. M2 vs M6
1489
+ ds_stats_m2_m6 = compute_2d_histogram(
1490
+ df,
1491
+ x="M2",
1492
+ y="M6",
1493
+ x_bins=log_arange(1, 10_000, log_step=log_step, base=10),
1494
+ y_bins=log_arange(0.1, 1000_000, log_step=log_step, base=10),
1495
+ )
1496
+
1497
+ # Define normalization
1498
+ def max_normalize(ds):
1499
+ return ds["count"].where(ds["count"] > 0) / ds["count"].max().item()
1500
+
1501
+ def log_max_normalize(ds):
1502
+ counts = ds["count"].where(ds["count"] > 0)
1503
+ log_counts = np.log10(counts)
1504
+ max_log = float(log_counts.max().item())
1505
+ return log_counts / max_log
1506
+
1507
+ normalizer = log_max_normalize if log_normalize else max_normalize
1508
+
1509
+ # Normalize all histograms
1510
+ ds_stats_dm_nt["normalized"] = normalizer(ds_stats_dm_nt)
1511
+ ds_stats_dm_nw["normalized"] = normalizer(ds_stats_dm_nw)
1512
+ ds_stats_dm_w["normalized"] = normalizer(ds_stats_dm_w)
1513
+ ds_stats_w_nt["normalized"] = normalizer(ds_stats_w_nt)
1514
+ ds_stats_w_nw["normalized"] = normalizer(ds_stats_w_nw)
1515
+ ds_stats_w_sigma["normalized"] = normalizer(ds_stats_w_sigma)
1516
+ ds_stats_m2_m4["normalized"] = normalizer(ds_stats_m2_m4)
1517
+ ds_stats_m3_m6["normalized"] = normalizer(ds_stats_m3_m6)
1518
+ ds_stats_m2_m6["normalized"] = normalizer(ds_stats_m2_m6)
1519
+
1520
+ # Set up figure and axes
1521
+ fig, axes = plt.subplots(3, 3, figsize=figsize, dpi=dpi)
1522
+ fig.subplots_adjust(hspace=0.05, wspace=0.35)
1523
+
1524
+ # COLUMN 1: All plots with Dm as x-axis
1525
+ # 1. Dm vs Nt (0,0)
1526
+ _ = ds_stats_dm_nt["normalized"].plot.pcolormesh(
1527
+ x="Dm",
1528
+ y="Nt",
1529
+ cmap=cmap,
1530
+ norm=norm,
1531
+ extend="max",
1532
+ xscale=dm_scale,
1533
+ yscale="log",
1534
+ add_colorbar=False,
1535
+ ax=axes[0, 0],
1536
+ )
1537
+ axes[0, 0].set_xlabel("") # Hide x labels except for bottom row
1538
+ axes[0, 0].set_ylabel(r"$N_t$ [m$^{-3}$]")
1539
+ axes[0, 0].set_xlim(*dm_lim)
1540
+ axes[0, 0].set_ylim(*nt_lim)
1541
+ axes[0, 0].set_title(r"$D_m$ vs $N_t$")
1542
+
1543
+ # 2. Dm vs Nw (1,0)
1544
+ _ = ds_stats_dm_nw["normalized"].plot.pcolormesh(
1545
+ x="Dm",
1546
+ y="Nw",
1547
+ cmap=cmap,
1548
+ norm=norm,
1549
+ extend="max",
1550
+ xscale=dm_scale,
1551
+ yscale="log",
1552
+ add_colorbar=False,
1553
+ ax=axes[1, 0],
1554
+ )
1555
+ axes[1, 0].set_xlabel("") # Hide x labels except for bottom row
1556
+ axes[1, 0].set_ylabel(r"$N_w$ [mm$^{-1}$ m$^{-3}$]")
1557
+ axes[1, 0].set_xlim(*dm_lim)
1558
+ axes[1, 0].set_ylim(*nw_lim)
1559
+ axes[1, 0].set_title(r"$D_m$ vs $N_w$")
1560
+
1561
+ # 3. Dm vs LWC/R (2,0)
1562
+ _ = ds_stats_dm_w["normalized"].plot.pcolormesh(
1563
+ x="Dm",
1564
+ y=water_var,
1565
+ cmap=cmap,
1566
+ norm=norm,
1567
+ extend="max",
1568
+ xscale=dm_scale,
1569
+ yscale="log",
1570
+ add_colorbar=False,
1571
+ ax=axes[2, 0],
1572
+ )
1573
+ axes[2, 0].set_xlabel(r"$D_m$ [mm]")
1574
+ axes[2, 0].set_ylabel(water_label)
1575
+ axes[2, 0].set_xlim(*dm_lim)
1576
+ if lwc:
1577
+ axes[2, 0].set_ylim(*water_lim)
1578
+ axes[2, 0].set_yticks([0.01, 0.1, 0.5, 1, 5])
1579
+ axes[2, 0].set_yticklabels(["0.01", "0.1", "0.5", "1", "5"])
1580
+ else:
1581
+ axes[2, 0].set_ylim(*water_lim)
1582
+ axes[2, 0].set_title(f"$D_m$ vs {water_var}")
1583
+
1584
+ axes[2, 0].set_xticks(dm_ticklabels)
1585
+ axes[2, 0].set_xticklabels([str(v) for v in dm_ticklabels])
1586
+
1587
+ # COLUMN 2: All plots with LWC/R as x-axis
1588
+ # 4. LWC/R vs Nt (0,1)
1589
+ _ = ds_stats_w_nt["normalized"].plot.pcolormesh(
1590
+ x=water_var,
1591
+ y="Nt",
1592
+ cmap=cmap,
1593
+ norm=norm,
1594
+ extend="max",
1595
+ xscale="log",
1596
+ yscale="log",
1597
+ add_colorbar=False,
1598
+ ax=axes[0, 1],
1599
+ )
1600
+ axes[0, 1].set_xlabel("") # Hide x labels except for bottom row
1601
+ axes[0, 1].set_ylabel(r"$N_t$ [m$^{-3}$]")
1602
+ if lwc:
1603
+ axes[0, 1].set_xlim(*water_lim)
1604
+ axes[0, 1].set_xticks([0.01, 0.1, 1, 10])
1605
+ axes[0, 1].set_xticklabels(["0.01", "0.1", "1", "10"])
1606
+ else:
1607
+ axes[0, 1].set_xlim(*water_lim)
1608
+ axes[0, 1].set_ylim(*nt_lim)
1609
+ axes[0, 1].set_title(f"{water_var} vs $N_t$")
1610
+
1611
+ # 5. LWC/R vs Nw (1,1)
1612
+ _ = ds_stats_w_nw["normalized"].plot.pcolormesh(
1613
+ x=water_var,
1614
+ y="Nw",
1615
+ cmap=cmap,
1616
+ norm=norm,
1617
+ extend="max",
1618
+ xscale="log",
1619
+ yscale="log",
1620
+ add_colorbar=False,
1621
+ ax=axes[1, 1],
1622
+ )
1623
+ axes[1, 1].set_xlabel("") # Hide x labels except for bottom row
1624
+ axes[1, 1].set_ylabel(r"$N_w$ [mm$^{-1}$ m$^{-3}$]")
1625
+ if lwc:
1626
+ axes[1, 1].set_xlim(*water_lim)
1627
+ axes[1, 1].set_xticks([0.01, 0.1, 1, 10])
1628
+ axes[1, 1].set_xticklabels(["0.01", "0.1", "1", "10"])
1629
+ else:
1630
+ axes[1, 1].set_xlim(*water_lim)
1631
+ axes[1, 1].set_ylim(*nw_lim)
1632
+ axes[1, 1].set_title(f"{water_var} vs $N_w$")
1633
+
1634
+ # 6. LWC/R vs sigma_m (2,1)
1635
+ _ = ds_stats_w_sigma["normalized"].plot.pcolormesh(
1636
+ x=water_var,
1637
+ y="sigma_m",
1638
+ cmap=cmap,
1639
+ norm=norm,
1640
+ extend="max",
1641
+ xscale="log",
1642
+ add_colorbar=False,
1643
+ ax=axes[2, 1],
1644
+ )
1645
+ axes[2, 1].set_xlabel(water_label)
1646
+ axes[2, 1].set_ylabel(r"$\sigma_m$ [mm]")
1647
+ if lwc:
1648
+ axes[2, 1].set_xlim(*water_lim)
1649
+ axes[2, 1].set_xticks([0.01, 0.1, 1, 10])
1650
+ axes[2, 1].set_xticklabels(["0.01", "0.1", "1", "10"])
1651
+ else:
1652
+ axes[2, 1].set_xlim(*water_lim)
1653
+ axes[2, 1].set_ylim(*sigma_lim)
1654
+
1655
+ axes[2, 1].set_title(rf"{water_var} vs $\sigma_m$")
1656
+
1657
+ # COLUMN 3: Moment relationships
1658
+ # 7. M2 vs M4 (0,2)
1659
+ _ = ds_stats_m2_m4["normalized"].plot.pcolormesh(
1660
+ x="M2",
1661
+ y="M4",
1662
+ cmap=cmap,
1663
+ norm=norm,
1664
+ extend="max",
1665
+ xscale="log",
1666
+ yscale="log",
1667
+ add_colorbar=False,
1668
+ ax=axes[0, 2],
1669
+ )
1670
+ axes[0, 2].set_xlabel("") # Hide x labels except for bottom row
1671
+ axes[0, 2].set_ylabel(r"M4 [m$^{-3}$ mm$^{4}$]")
1672
+ axes[0, 2].set_xlim(1, 10_000)
1673
+ axes[0, 2].set_ylim(1, 40_000)
1674
+ axes[0, 2].set_title(r"M2 vs M4")
1675
+
1676
+ # 8. M3 vs M6 (1,2)
1677
+ _ = ds_stats_m3_m6["normalized"].plot.pcolormesh(
1678
+ x="M3",
1679
+ y="M6",
1680
+ cmap=cmap,
1681
+ norm=norm,
1682
+ extend="max",
1683
+ xscale="log",
1684
+ yscale="log",
1685
+ add_colorbar=False,
1686
+ ax=axes[1, 2],
1687
+ )
1688
+ axes[1, 2].set_xlabel("") # Hide x labels except for bottom row
1689
+ axes[1, 2].set_ylabel(r"M6 [m$^{-3}$ mm$^{6}$]")
1690
+ axes[1, 2].set_xlim(1, 10_000)
1691
+ axes[1, 2].set_ylim(0.1, 1000_000)
1692
+ axes[1, 2].set_title(r"M3 vs M6")
1693
+
1694
+ # 9. M2 vs M6 (2,2)
1695
+ _ = ds_stats_m2_m6["normalized"].plot.pcolormesh(
1696
+ x="M2",
1697
+ y="M6",
1698
+ cmap=cmap,
1699
+ norm=norm,
1700
+ extend="max",
1701
+ xscale="log",
1702
+ yscale="log",
1703
+ add_colorbar=False,
1704
+ ax=axes[2, 2],
1705
+ )
1706
+ axes[2, 2].set_xlabel(r"M* [m$^{-3}$ mm$^{*}$]")
1707
+ axes[2, 2].set_ylabel(r"M6 [m$^{-3}$ mm$^{6}$]")
1708
+ axes[2, 2].set_xlim(1, 10_000)
1709
+ axes[2, 2].set_ylim(0.1, 1000_000)
1710
+ axes[2, 2].set_title(r"M2 vs M6")
1711
+
1712
+ # Remove x-axis ticks and ticklabels for all but bottom row
1713
+ for i in range(2):
1714
+ for j in range(3):
1715
+ axes[i, j].set_xticklabels([])
1716
+ axes[i, j].tick_params(axis="x", which="both", bottom=False)
1717
+
1718
+ # Add subplot titles as text in top left corner of each plot
1719
+ title_bbox_dict = {
1720
+ "facecolor": "white",
1721
+ "alpha": 0.7,
1722
+ "edgecolor": "none",
1723
+ "pad": 1,
1724
+ }
1725
+ for ax in axes.flatten():
1726
+ # Add text in top left corner with some padding
1727
+ ax.text(
1728
+ 0.03,
1729
+ 0.95,
1730
+ ax.get_title(),
1731
+ transform=ax.transAxes,
1732
+ fontsize=11,
1733
+ # fontweight='bold',
1734
+ ha="left",
1735
+ va="top",
1736
+ bbox=title_bbox_dict,
1737
+ )
1738
+ ax.set_title("")
1739
+
1740
+ return fig
1741
+
1742
+
1743
+ def plot_dmax_relationships(df, diameter_bin_edges, dmax="Dmax", diameter_max=10, norm_vmax=None, dpi=300):
1744
+ """
1745
+ Plot 2x2 subplots showing relationships between Dmax and precipitation parameters.
1746
+
1747
+ Parameters
1748
+ ----------
1749
+ df : DataFrame
1750
+ Input dataframe containing the precipitation data
1751
+ dmax : str, default "Dmax"
1752
+ Column name for maximum diameter
1753
+ vmax : float, default 10
1754
+ Maximum value for Dmax axis limits
1755
+ dpi : int, default 300
1756
+ Resolution for the figure
1757
+ """
1758
+ # Compute 2D histograms
1759
+ # - Dmax-R
1760
+ ds_stats_dmax_r = compute_2d_histogram(
1761
+ df,
1762
+ x=dmax,
1763
+ y="R",
1764
+ x_bins=diameter_bin_edges,
1765
+ y_bins=log_arange(0.1, 500, log_step=0.05, base=10),
1766
+ )
1767
+ # - Dmax-Nw
1768
+ ds_stats_dmax_nw = compute_2d_histogram(
1769
+ df,
1770
+ x=dmax,
1771
+ y="Nw",
1772
+ x_bins=diameter_bin_edges,
1773
+ y_bins=log_arange(10, 1_000_000, log_step=0.05, base=10),
1774
+ )
1775
+ # - Dmax-Nt
1776
+ ds_stats_dmax_nt = compute_2d_histogram(
1777
+ df,
1778
+ x=dmax,
1779
+ y="Nt",
1780
+ x_bins=diameter_bin_edges,
1781
+ y_bins=log_arange(1, 100_000, log_step=0.05, base=10),
1782
+ )
1783
+ # - Dmax-Dm
1784
+ ds_stats_dmax_dm = compute_2d_histogram(
1785
+ df,
1786
+ x=dmax,
1787
+ y="Dm",
1788
+ variables=["R", "Nw", "sigma_m"],
1789
+ x_bins=diameter_bin_edges,
1790
+ y_bins=np.arange(0, 8, 0.05),
1791
+ )
1792
+
1793
+ # Define vmax for counts
1794
+ if norm_vmax:
1795
+ norm_vmax = define_lognorm_max_value(ds_stats_dmax_r["count"].max().item())
1796
+
1797
+ # Define plotting parameters
1798
+ cmap = plt.get_cmap("Spectral_r").copy()
1799
+ cmap.set_under(alpha=0)
1800
+ norm = LogNorm(1, norm_vmax)
1801
+
1802
+ # Create figure with 2x2 subplots
1803
+ figsize = (8, 6)
1804
+ fig = plt.figure(figsize=figsize, dpi=dpi)
1805
+
1806
+ # Create main gridspec with larger space between plots and colorbar
1807
+ # - Horizontal colorbar
1808
+ main_gs = fig.add_gridspec(2, 1, height_ratios=[1, 0.20], hspace=0.15)
1809
+
1810
+ # - Vertical colorbar
1811
+ # main_gs = fig.add_gridspec(1, 2, width_ratios=[1, 0.20], wspace=0.15)
1812
+
1813
+ # Create nested gridspec for the 2x2 subplots with smaller internal spacing
1814
+ subplots_gs = main_gs[0].subgridspec(2, 2, hspace=0.05, wspace=0.05)
1815
+
1816
+ # Create the 2x2 subplot grid
1817
+ axes = np.array(
1818
+ [
1819
+ [fig.add_subplot(subplots_gs[0, 0]), fig.add_subplot(subplots_gs[0, 1])],
1820
+ [fig.add_subplot(subplots_gs[1, 0]), fig.add_subplot(subplots_gs[1, 1])],
1821
+ ],
1822
+ )
1823
+
1824
+ # - Dmax vs R (top-left)
1825
+ ax1 = axes[0, 0]
1826
+ p1 = ds_stats_dmax_r["count"].plot.pcolormesh(
1827
+ x=dmax,
1828
+ y="R",
1829
+ cmap=cmap,
1830
+ norm=norm,
1831
+ extend="max",
1832
+ yscale="log",
1833
+ add_colorbar=False,
1834
+ ax=ax1,
1835
+ )
1836
+ ax1.set_xlabel(r"$D_{max}$ [mm]")
1837
+ ax1.set_ylabel(r"$R$ [mm h$^{-1}$]")
1838
+ ax1.set_xlim(0.2, diameter_max)
1839
+ ax1.set_ylim(0.1, 500)
1840
+
1841
+ # - Dmax vs Nw (top-right)
1842
+ ax2 = axes[0, 1]
1843
+ _ = ds_stats_dmax_nw["count"].plot.pcolormesh(
1844
+ x=dmax,
1845
+ y="Nw",
1846
+ cmap=cmap,
1847
+ norm=norm,
1848
+ extend="max",
1849
+ yscale="log",
1850
+ add_colorbar=False,
1851
+ ax=ax2,
1852
+ )
1853
+ ax2.set_xlabel(r"$D_{max}$ [mm]")
1854
+ ax2.set_ylabel(r"$N_w$ [mm$^{-1}$ m$^{-3}$]")
1855
+ ax2.set_xlim(0.2, diameter_max)
1856
+ ax2.set_ylim(10, 1_000_000)
1857
+
1858
+ # - Dmax vs Nt (bottom-left)
1859
+ ax3 = axes[1, 0]
1860
+ _ = ds_stats_dmax_nt["count"].plot.pcolormesh(
1861
+ x=dmax,
1862
+ y="Nt",
1863
+ cmap=cmap,
1864
+ norm=norm,
1865
+ extend="max",
1866
+ yscale="log",
1867
+ add_colorbar=False,
1868
+ ax=ax3,
1869
+ )
1870
+ ax3.set_xlabel(r"$D_{max}$ [mm]")
1871
+ ax3.set_ylabel(r"$N_t$ [m$^{-3}$]")
1872
+ ax3.set_xlim(0.2, diameter_max)
1873
+ ax3.set_ylim(1, 100_000)
1874
+
1875
+ # - Dmax vs Dm (bottom-right)
1876
+ ax4 = axes[1, 1]
1877
+ _ = ds_stats_dmax_dm["count"].plot.pcolormesh(
1878
+ x=dmax,
1879
+ y="Dm",
1880
+ cmap=cmap,
1881
+ norm=norm,
1882
+ extend="max",
1883
+ add_colorbar=False,
1884
+ ax=ax4,
1885
+ )
1886
+ ax4.set_xlabel(r"$D_{max}$ [mm]")
1887
+ ax4.set_ylabel(r"$D_m$ [mm]")
1888
+ ax4.set_xlim(0.2, diameter_max)
1889
+ ax4.set_ylim(0, 6)
1890
+
1891
+ # Remove xaxis labels and ticklables labels on first row
1892
+ for ax in axes[0, :]: # First row (both columns)
1893
+ ax.set_xlabel("")
1894
+ ax.set_xticks([])
1895
+ ax.set_xticklabels([])
1896
+ ax.tick_params(axis="x", which="both", bottom=True, top=False, labelbottom=False)
1897
+
1898
+ # Move y-axis of second column to the right
1899
+ for ax in axes[:, 1]: # Second column (both rows)
1900
+ ax.yaxis.tick_right()
1901
+ ax.yaxis.set_label_position("right")
1902
+
1903
+ # Add titles as legends in upper corners
1904
+ title_bbox_dict = {
1905
+ "facecolor": "white",
1906
+ "alpha": 0.7,
1907
+ "edgecolor": "none",
1908
+ "pad": 1,
1909
+ }
1910
+ axes[0, 0].text(
1911
+ 0.05,
1912
+ 0.95,
1913
+ r"$D_{max}$ vs $R$",
1914
+ transform=axes[0, 0].transAxes,
1915
+ fontsize=12,
1916
+ verticalalignment="top",
1917
+ bbox=title_bbox_dict,
1918
+ )
1919
+
1920
+ axes[0, 1].text(
1921
+ 0.05,
1922
+ 0.95,
1923
+ r"$D_{max}$ vs $N_w$",
1924
+ transform=axes[0, 1].transAxes,
1925
+ fontsize=12,
1926
+ verticalalignment="top",
1927
+ bbox=title_bbox_dict,
1928
+ )
1929
+
1930
+ axes[1, 0].text(
1931
+ 0.05,
1932
+ 0.95,
1933
+ r"$D_{max}$ vs $N_t$",
1934
+ transform=axes[1, 0].transAxes,
1935
+ fontsize=12,
1936
+ verticalalignment="top",
1937
+ bbox=title_bbox_dict,
1938
+ )
1939
+
1940
+ axes[1, 1].text(
1941
+ 0.05,
1942
+ 0.95,
1943
+ r"$D_{max}$ vs $D_m$",
1944
+ transform=axes[1, 1].transAxes,
1945
+ fontsize=12,
1946
+ verticalalignment="top",
1947
+ bbox=title_bbox_dict,
1948
+ )
1949
+
1950
+ # Add colorbar
1951
+ cax = fig.add_subplot(main_gs[1])
1952
+ # - Horizontal colorbar
1953
+ cbar = fig.colorbar(p1, cax=cax, extend="max", orientation="horizontal")
1954
+ cbar.set_label("Counts", labelpad=10)
1955
+ cbar.ax.set_aspect(0.1)
1956
+ cbar.ax.xaxis.set_label_position("top")
1957
+
1958
+ # - Vertical colorbar
1959
+ # cbar = fig.colorbar(p2, cax=cax, extend="max")
1960
+ # cbar.set_label('Count', rotation=270, labelpad=10)
1961
+ # cbar.ax.set_aspect(10)
1962
+ return fig
1963
+
1964
+
1965
+ ####-------------------------------------------------------------------
1966
+ #### Radar plots
1967
+
1968
+
1969
+ def _define_coeff_string(a):
1970
+ # - Format a coefficient as m * 10^{e}
1971
+ m_str, e_str = f"{a:.2e}".split("e")
1972
+ m, e = float(m_str), int(e_str)
1973
+ # Build coefficient string
1974
+ a_str = f"{a:.2f}" if e >= -1 else f"{m:.2f} \\times 10^{{{e}}}"
1975
+ return a_str
1976
+
1977
+
1978
+ def get_symbol_str(symbol, pol=""):
1979
+ """Generate symbol string with optional polarization subscript.
1980
+
1981
+ Parameters
1982
+ ----------
1983
+ symbol : str
1984
+ The base symbol (e.g., 'A', 'Z', 'z')
1985
+ pol : str, optional
1986
+ Polarization identifier (e.g., 'H', 'V')
1987
+
1988
+ Returns
1989
+ -------
1990
+ str
1991
+ LaTeX formatted symbol string
1992
+ """
1993
+ if pol:
1994
+ return rf"{symbol}_{{\mathrm{{{pol}}}}}"
1995
+ return symbol
1996
+
1997
+
1998
+ def plot_A_R(
1999
+ df,
2000
+ a,
2001
+ r,
2002
+ cmap=None,
2003
+ norm=None,
2004
+ add_colorbar=True,
2005
+ add_fit=True,
2006
+ pol="",
2007
+ title=None,
2008
+ ax=None,
2009
+ figsize=(8, 8),
2010
+ dpi=300,
2011
+ legend_fontsize=14,
2012
+ ):
2013
+ """Create a 2D histogram of A vs R."""
2014
+ # Define a_min and a_max
2015
+ a_min = 0.001
2016
+ a_max = 10
2017
+ a_bins = log_arange(a_min, a_max, log_step=0.025, base=10)
2018
+ rlims = (0.1, 500)
2019
+ r_bins = log_arange(*rlims, log_step=0.025, base=10)
2020
+
2021
+ # Compute 2D histogram
2022
+ ds_stats = compute_2d_histogram(
2023
+ df,
2024
+ x=r,
2025
+ y=a,
2026
+ x_bins=r_bins,
2027
+ y_bins=a_bins,
2028
+ )
2029
+
2030
+ # Define colormap and norm
2031
+ if cmap is None:
2032
+ cmap = plt.get_cmap("Spectral_r").copy()
2033
+ cmap.set_under(alpha=0)
2034
+ norm = LogNorm(1, None) if norm is None else norm
2035
+
2036
+ # Define ticks and ticklabels
2037
+ r_ticks = [0.1, 1, 10, 50, 100, 500]
2038
+ a_ticks = [0.001, 0.01, 0.1, 0.5, 1, 5] # Adapt on a_max
2039
+
2040
+ # Define A symbol
2041
+ a_symbol = get_symbol_str("A", pol)
2042
+
2043
+ # Set default title if not provided
2044
+ if title is None:
2045
+ title = rf"${a_symbol}$ vs $R$"
2046
+
2047
+ # Create figure if ax is None
2048
+ if ax is None:
2049
+ fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
2050
+
2051
+ # Plot 2D histogram
2052
+ p = ds_stats["count"].plot.pcolormesh(
2053
+ x=r,
2054
+ y=a,
2055
+ ax=ax,
2056
+ cmap=cmap,
2057
+ norm=norm,
2058
+ add_colorbar=add_colorbar,
2059
+ extend="max",
2060
+ xscale="log",
2061
+ yscale="log",
2062
+ cbar_kwargs={"label": "Counts"} if add_colorbar else {},
2063
+ )
2064
+ ax.set_xlabel(r"$R$ [mm h$^{-1}$]")
2065
+ ax.set_ylabel(rf"${a_symbol}$ [dB km$^{{-1}}$]")
2066
+ ax.set_ylim(a_min, a_max)
2067
+ ax.set_xlim(*rlims)
2068
+ ax.set_xticks(r_ticks)
2069
+ ax.set_xticklabels([str(v) for v in r_ticks])
2070
+ ax.set_yticks(a_ticks)
2071
+ ax.set_yticklabels([str(v) for v in a_ticks])
2072
+ ax.set_title(title)
2073
+ if add_fit:
2074
+ # Fit powerlaw k = a * R ** b
2075
+ (a_c, b), _ = fit_powerlaw(x=df[r], y=df[a], xbins=r_bins, x_in_db=False)
2076
+ # Invert for R = A * k ** B
2077
+ A_c, B = inverse_powerlaw_parameters(a_c, b)
2078
+ # Define legend title
2079
+ a_str = _define_coeff_string(a_c)
2080
+ A_str = _define_coeff_string(A_c)
2081
+ legend_str = rf"${a_symbol} = {a_str} \, R^{{{b:.2f}}}$" "\n" rf"$R = {A_str} \, {a_symbol}^{{{B:.2f}}}$"
2082
+ # Get power law predictions
2083
+ x_pred = np.arange(*rlims)
2084
+ r_pred = predict_from_powerlaw(x_pred, a=a_c, b=b)
2085
+ # Add fitted power law
2086
+ ax.plot(x_pred, r_pred, linestyle="dashed", color="black")
2087
+ # Add legend
2088
+ legend_bbox_dict = {"facecolor": "white", "edgecolor": "black", "alpha": 0.7}
2089
+ ax.text(
2090
+ 0.05,
2091
+ 0.95,
2092
+ legend_str,
2093
+ transform=ax.transAxes,
2094
+ ha="left",
2095
+ va="top",
2096
+ fontsize=legend_fontsize,
2097
+ bbox=legend_bbox_dict,
2098
+ )
2099
+ return p
2100
+
2101
+
2102
+ def plot_A_Z(
2103
+ df,
2104
+ a,
2105
+ z,
2106
+ cmap=None,
2107
+ norm=None,
2108
+ add_colorbar=True,
2109
+ add_fit=True,
2110
+ pol="",
2111
+ title=None,
2112
+ ax=None,
2113
+ a_lim=(0.0001, 10),
2114
+ z_lim=(0, 70),
2115
+ figsize=(8, 8),
2116
+ dpi=300,
2117
+ legend_fontsize=14,
2118
+ ):
2119
+ """Create a 2D histogram of A vs Z."""
2120
+ # Define bins
2121
+ a_bins = log_arange(*a_lim, log_step=0.025, base=10)
2122
+ z_bins = np.arange(*z_lim, 0.5)
2123
+
2124
+ # Compute 2d histogram
2125
+ ds_stats = compute_2d_histogram(
2126
+ df,
2127
+ x=z,
2128
+ y=a,
2129
+ x_bins=z_bins,
2130
+ y_bins=a_bins,
2131
+ )
2132
+
2133
+ # Define colormap and norm
2134
+ if cmap is None:
2135
+ cmap = plt.get_cmap("Spectral_r").copy()
2136
+ cmap.set_under(alpha=0)
2137
+ if norm is None:
2138
+ norm = LogNorm(1, None)
2139
+
2140
+ # Ticks
2141
+ a_ticks = [0.001, 0.01, 0.1, 0.5, 1, 5]
2142
+
2143
+ # Define symbols
2144
+ a_symbol = get_symbol_str("A", pol)
2145
+ z_symbol = get_symbol_str("Z", pol)
2146
+ z_lower_symbol = get_symbol_str("z", pol)
2147
+
2148
+ # Set default title if not provided
2149
+ if title is None:
2150
+ title = rf"${a_symbol}$ vs ${z_symbol}$"
2151
+
2152
+ # Create figure if ax is None
2153
+ if ax is None:
2154
+ fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
2155
+
2156
+ # Plot 2D histogram
2157
+ p = ds_stats["count"].plot.pcolormesh(
2158
+ x=z,
2159
+ y=a,
2160
+ ax=ax,
2161
+ cmap=cmap,
2162
+ norm=norm,
2163
+ add_colorbar=add_colorbar,
2164
+ extend="max",
2165
+ xscale=None,
2166
+ yscale="log",
2167
+ cbar_kwargs={"label": "Counts"} if add_colorbar else {},
2168
+ )
2169
+ ax.set_xlabel(rf"${z_symbol}$ [dBZ]")
2170
+ ax.set_ylabel(rf"${a_symbol}$ [dB km$^{{-1}}$]")
2171
+ ax.set_xlim(*z_lim)
2172
+ ax.set_ylim(*a_lim)
2173
+ ax.set_yticks(a_ticks)
2174
+ ax.set_yticklabels([str(v) for v in a_ticks])
2175
+ ax.set_title(title)
2176
+
2177
+ # Fit and plot the power law
2178
+ if add_fit:
2179
+ # Fit powerlaw k = a * Z ** b (Z in dBZ -> x_in_db=True)
2180
+ (a_c, b), _ = fit_powerlaw(
2181
+ x=df[z],
2182
+ y=df[a],
2183
+ xbins=z_bins,
2184
+ x_in_db=True,
2185
+ )
2186
+ # Invert for Z = A * k ** B
2187
+ A_c, B = inverse_powerlaw_parameters(a_c, b)
2188
+ # Legend text
2189
+ a_str = _define_coeff_string(a_c)
2190
+ A_str = _define_coeff_string(A_c)
2191
+ legend_str = (
2192
+ rf"${a_symbol} = {a_str} \, {z_lower_symbol}^{{{b:.2f}}}$"
2193
+ "\n"
2194
+ rf"${z_lower_symbol} = {A_str} \, {a_symbol}^{{{B:.2f}}}$"
2195
+ )
2196
+ # Predictions
2197
+ x_pred = np.arange(*z_lim)
2198
+ x_pred_linear = disdrodb.idecibel(x_pred) # convert to linear for prediction
2199
+ y_pred = predict_from_powerlaw(x_pred_linear, a=a_c, b=b)
2200
+ ax.plot(x_pred, y_pred, linestyle="dashed", color="black")
2201
+ # Add legend
2202
+ legend_bbox_dict = {"facecolor": "white", "edgecolor": "black", "alpha": 0.7}
2203
+ ax.text(
2204
+ 0.05,
2205
+ 0.95,
2206
+ legend_str,
2207
+ transform=ax.transAxes,
2208
+ ha="left",
2209
+ va="top",
2210
+ fontsize=legend_fontsize,
2211
+ bbox=legend_bbox_dict,
2212
+ )
2213
+ return p
2214
+
2215
+
2216
+ def plot_A_KDP(
2217
+ df,
2218
+ a,
2219
+ kdp,
2220
+ log_a=True,
2221
+ log_kdp=False,
2222
+ a_lim=(0.001, 10),
2223
+ kdp_lim=None,
2224
+ pol="",
2225
+ ax=None,
2226
+ cmap=None,
2227
+ norm=None,
2228
+ add_colorbar=True,
2229
+ add_fit=True,
2230
+ title=None,
2231
+ figsize=(8, 8),
2232
+ dpi=300,
2233
+ legend_fontsize=14,
2234
+ ):
2235
+ """Create a 2D histogram of k(H/V) vs KDP."""
2236
+ # Bins & limits for a
2237
+ if log_a:
2238
+ a_bins = log_arange(*a_lim, log_step=0.025, base=10)
2239
+ yscale = "log"
2240
+ a_ticks = [0.001, 0.01, 0.1, 0.5, 1, 5]
2241
+ else:
2242
+ a_bins = np.arange(a_lim[0], a_lim[1], 0.01)
2243
+ yscale = None
2244
+ a_ticks = None
2245
+
2246
+ # Bins & limits for KDP
2247
+ if log_kdp:
2248
+ kdp_lim = (0.05, 10) if kdp_lim is None else kdp_lim
2249
+ kdp_bins = log_arange(*kdp_lim, log_step=0.05, base=10)
2250
+ xscale = "log"
2251
+ kdp_ticks = [0.05, 0.1, 0.5, 1, 5, 10]
2252
+ else:
2253
+ kdp_lim = (0, 8) if kdp_lim is None else kdp_lim
2254
+ kdp_bins = np.arange(kdp_lim[0], kdp_lim[1], 0.1)
2255
+ xscale = None
2256
+ kdp_ticks = None
2257
+
2258
+ # Compute 2D histogram
2259
+ ds_stats = compute_2d_histogram(
2260
+ df,
2261
+ x=kdp,
2262
+ y=a,
2263
+ x_bins=kdp_bins,
2264
+ y_bins=a_bins,
2265
+ )
2266
+
2267
+ # Colormap & norm
2268
+ if cmap is None:
2269
+ cmap = plt.get_cmap("Spectral_r").copy()
2270
+ cmap.set_under(alpha=0)
2271
+ if norm is None:
2272
+ norm = LogNorm(1, None)
2273
+
2274
+ # Define symbols
2275
+ a_symbol = get_symbol_str("A", pol)
2276
+
2277
+ # Set default title if not provided
2278
+ if title is None:
2279
+ title = rf"${a_symbol}$ vs $K_{{\mathrm{{DP}}}}$"
2280
+
2281
+ # Create figure if ax is None
2282
+ if ax is None:
2283
+ fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
2284
+
2285
+ # Plot 2D histogram
2286
+ p = ds_stats["count"].plot.pcolormesh(
2287
+ x=kdp,
2288
+ y=a,
2289
+ ax=ax,
2290
+ cmap=cmap,
2291
+ norm=norm,
2292
+ add_colorbar=add_colorbar,
2293
+ extend="max",
2294
+ xscale=xscale,
2295
+ yscale=yscale,
2296
+ cbar_kwargs={"label": "Counts"} if add_colorbar else {},
2297
+ )
2298
+ ax.set_xlabel(r"$K_{\mathrm{DP}}$ [deg km$^{-1}$]")
2299
+ ax.set_ylabel(rf"${a_symbol}$ [dB km$^{{-1}}$]")
2300
+ ax.set_xlim(*kdp_lim)
2301
+ ax.set_ylim(*a_lim)
2302
+ if kdp_ticks is not None:
2303
+ ax.set_xticks(kdp_ticks)
2304
+ ax.set_xticklabels([str(v) for v in kdp_ticks])
2305
+ if a_ticks is not None:
2306
+ ax.set_yticks(a_ticks)
2307
+ ax.set_yticklabels([str(v) for v in a_ticks])
2308
+ ax.set_title(title)
2309
+
2310
+ # Fit and overlay power law: k = a * KDP^b
2311
+ if add_fit:
2312
+ (a_c, b), _ = fit_powerlaw(
2313
+ x=df[kdp],
2314
+ y=df[a],
2315
+ xbins=kdp_bins,
2316
+ x_in_db=False,
2317
+ )
2318
+ # Invert: KDP = A * k^B
2319
+ A_c, B = inverse_powerlaw_parameters(a_c, b)
2320
+
2321
+ a_str = _define_coeff_string(a_c)
2322
+ A_str = _define_coeff_string(A_c)
2323
+ legend_str = (
2324
+ rf"${a_symbol} = {a_str}\,K_{{\mathrm{{DP}}}}^{{{b:.2f}}}$"
2325
+ "\n"
2326
+ rf"$K_{{\mathrm{{DP}}}} = {A_str}\,{a_symbol}^{{{B:.2f}}}$"
2327
+ )
2328
+
2329
+ # Predictions along KDP axis
2330
+ if log_kdp:
2331
+ x_pred = np.logspace(np.log10(kdp_lim[0]), np.log10(kdp_lim[1]), 400)
2332
+ else:
2333
+ x_pred = np.arange(kdp_lim[0], kdp_lim[1], 0.05)
2334
+ y_pred = predict_from_powerlaw(x_pred, a=a_c, b=b)
2335
+
2336
+ ax.plot(x_pred, y_pred, linestyle="dashed", color="black")
2337
+ # Add legend
2338
+ legend_bbox_dict = {"facecolor": "white", "edgecolor": "black", "alpha": 0.7}
2339
+ ax.text(
2340
+ 0.05,
2341
+ 0.95,
2342
+ legend_str,
2343
+ transform=ax.transAxes,
2344
+ ha="left",
2345
+ va="top",
2346
+ fontsize=legend_fontsize,
2347
+ bbox=legend_bbox_dict,
2348
+ )
2349
+
2350
+ return p
2351
+
2352
+
2353
+ def plot_R_Z(
2354
+ df,
2355
+ z,
2356
+ r,
2357
+ cmap=None,
2358
+ norm=None,
2359
+ add_colorbar=True,
2360
+ add_fit=True,
2361
+ pol="",
2362
+ title=None,
2363
+ ax=None,
2364
+ figsize=(8, 8),
2365
+ dpi=300,
2366
+ legend_fontsize=14,
2367
+ ):
2368
+ """Create a 2D histogram of Z vs R."""
2369
+ # Define axis limits
2370
+ z_lims = (0, 70)
2371
+ r_lims = (0.1, 500)
2372
+
2373
+ # Compute 2d histogram
2374
+ ds_stats = compute_2d_histogram(
2375
+ df,
2376
+ x=z,
2377
+ y=r,
2378
+ x_bins=np.arange(*z_lims, 0.5),
2379
+ y_bins=log_arange(*r_lims, log_step=0.05, base=10),
2380
+ )
2381
+
2382
+ # Define colormap and norm
2383
+ if cmap is None:
2384
+ cmap = plt.get_cmap("Spectral_r").copy()
2385
+ cmap.set_under(alpha=0)
2386
+ norm = LogNorm(1, None) if norm is None else norm
2387
+
2388
+ # Define rain ticks and ticklabels
2389
+ r_ticks = [0.1, 1, 10, 50, 100, 500]
2390
+
2391
+ # Define symbols
2392
+ z_symbol = get_symbol_str("Z", pol)
2393
+ z_lower_symbol = get_symbol_str("z", pol)
2394
+
2395
+ # Set default title if not provided
2396
+ if title is None:
2397
+ title = rf"${z_symbol}$ vs $R$"
2398
+
2399
+ # Create figure if ax is None
2400
+ if ax is None:
2401
+ fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
2402
+
2403
+ # Plot 2D histogram
2404
+ p = ds_stats["count"].plot.pcolormesh(
2405
+ x=z,
2406
+ y=r,
2407
+ ax=ax,
2408
+ cmap=cmap,
2409
+ norm=norm,
2410
+ add_colorbar=add_colorbar,
2411
+ extend="max",
2412
+ yscale="log",
2413
+ cbar_kwargs={"label": "Counts"} if add_colorbar else {},
2414
+ )
2415
+ ax.set_ylabel(r"$R$ [mm h$^{-1}$]")
2416
+ ax.set_xlabel(rf"${z_symbol}$ [dBZ]")
2417
+ ax.set_xlim(*z_lims)
2418
+ ax.set_ylim(*r_lims)
2419
+ ax.set_yticks(r_ticks)
2420
+ ax.set_yticklabels([str(v) for v in r_ticks])
2421
+ ax.set_title(title)
2422
+
2423
+ # Fit and plot the powerlaw
2424
+ if add_fit:
2425
+ # Fit powerlaw R = a * z ** b
2426
+ (a, b), _ = fit_powerlaw(x=df[z], y=df[r], xbins=np.arange(10, 50, 1), x_in_db=True)
2427
+ # Invert for z = A * R ** B
2428
+ A, B = inverse_powerlaw_parameters(a, b)
2429
+ # Define legend title
2430
+ a_str = _define_coeff_string(a)
2431
+ A_str = _define_coeff_string(A)
2432
+ legend_str = (
2433
+ rf"$R = {a_str} \, {z_lower_symbol}^{{{b:.2f}}}$" "\n" rf"${z_lower_symbol} = {A_str} \, R^{{{B:.2f}}}$"
2434
+ )
2435
+ # Get power law predictions
2436
+ x_pred = np.arange(*z_lims)
2437
+ x_pred_linear = disdrodb.idecibel(x_pred)
2438
+ r_pred = predict_from_powerlaw(x_pred_linear, a=a, b=b)
2439
+ # Add fitted powerlaw
2440
+ ax.plot(x_pred, r_pred, linestyle="dashed", color="black")
2441
+ # Add legend
2442
+ legend_bbox_dict = {"facecolor": "white", "edgecolor": "black", "alpha": 0.7}
2443
+ ax.text(
2444
+ 0.05,
2445
+ 0.95,
2446
+ legend_str,
2447
+ transform=ax.transAxes,
2448
+ ha="left",
2449
+ va="top",
2450
+ fontsize=legend_fontsize,
2451
+ bbox=legend_bbox_dict,
2452
+ )
2453
+ return p
2454
+
2455
+
2456
+ def plot_R_KDP(
2457
+ df,
2458
+ kdp,
2459
+ r,
2460
+ kdp_lim=None,
2461
+ r_lim=None,
2462
+ cmap=None,
2463
+ norm=None,
2464
+ add_colorbar=True,
2465
+ log_scale=False,
2466
+ add_fit=True,
2467
+ title=None,
2468
+ ax=None,
2469
+ figsize=(8, 8),
2470
+ dpi=300,
2471
+ legend_fontsize=14,
2472
+ ):
2473
+ """Create a 2D histogram of KDP vs R."""
2474
+ # Define bins
2475
+ if not log_scale:
2476
+ kdp_lim = (0, 8) if kdp_lim is None else kdp_lim
2477
+ r_lim = (0, 200) if r_lim is None else r_lim
2478
+ xbins = np.arange(*kdp_lim, 0.1)
2479
+ ybins = np.arange(*r_lim, 1)
2480
+ xscale = None
2481
+ yscale = None
2482
+ else:
2483
+ kdp_lim = (0.1, 10) if kdp_lim is None else kdp_lim
2484
+ r_lim = (0.1, 500) if r_lim is None else r_lim
2485
+ xbins = log_arange(*kdp_lim, log_step=0.05, base=10)
2486
+ ybins = log_arange(*r_lim, log_step=0.05, base=10)
2487
+ xscale = "log"
2488
+ yscale = "log"
2489
+
2490
+ # Compute 2d histogram
2491
+ ds_stats = compute_2d_histogram(
2492
+ df,
2493
+ x=kdp,
2494
+ y=r,
2495
+ x_bins=xbins,
2496
+ y_bins=ybins,
2497
+ # y_bins=log_arange(0.1, 500, log_step=0.05, base=10),
2498
+ )
2499
+
2500
+ # Define colormap and norm
2501
+ if cmap is None:
2502
+ cmap = plt.get_cmap("Spectral_r").copy()
2503
+ cmap.set_under(alpha=0)
2504
+ norm = LogNorm(1, None) if norm is None else norm
2505
+
2506
+ # Set default title if not provided
2507
+ if title is None:
2508
+ title = r"$K_{\mathrm{DP}}$ vs $R$"
2509
+
2510
+ # Create figure if ax is None
2511
+ if ax is None:
2512
+ fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
2513
+
2514
+ # Plot 2D histogram
2515
+ p = ds_stats["count"].plot.pcolormesh(
2516
+ x=kdp,
2517
+ y=r,
2518
+ ax=ax,
2519
+ cmap=cmap,
2520
+ norm=norm,
2521
+ add_colorbar=add_colorbar,
2522
+ xscale=xscale,
2523
+ yscale=yscale,
2524
+ extend="max",
2525
+ cbar_kwargs={"label": "Counts"} if add_colorbar else {},
2526
+ )
2527
+ ax.set_ylabel(r"$R$ [mm h$^{-1}$]")
2528
+ ax.set_xlabel(r"$K_{\mathrm{DP}}$ [deg km$^{-1}$]")
2529
+ ax.set_xlim(*kdp_lim)
2530
+ ax.set_ylim(*r_lim)
2531
+ ax.set_title(title)
2532
+
2533
+ # Fit and plot the power law
2534
+ if add_fit:
2535
+ # Fit powerlaw R = a * KDP ** b
2536
+ (a, b), _ = fit_powerlaw(x=df[kdp], y=df[r], xbins=xbins, x_in_db=False)
2537
+ # Invert for KDP = A * R ** B
2538
+ A, B = inverse_powerlaw_parameters(a, b)
2539
+ # Define legend title
2540
+ a_str = _define_coeff_string(a)
2541
+ A_str = _define_coeff_string(A)
2542
+ legend_str = (
2543
+ rf"$R = {a_str} \, K_{{\mathrm{{DP}}}}^{{{b:.2f}}}$"
2544
+ "\n"
2545
+ rf"$K_{{\mathrm{{DP}}}} = {A_str} \, R^{{{B:.2f}}}$"
2546
+ )
2547
+ # Get power law predictions
2548
+ x_pred = np.arange(*kdp_lim)
2549
+ r_pred = predict_from_powerlaw(x_pred, a=a, b=b)
2550
+ # Add fitted line
2551
+ ax.plot(x_pred, r_pred, linestyle="dashed", color="black")
2552
+ # Add legend
2553
+ legend_bbox_dict = {"facecolor": "white", "edgecolor": "black", "alpha": 0.7}
2554
+ ax.text(
2555
+ 0.05,
2556
+ 0.95,
2557
+ legend_str,
2558
+ transform=ax.transAxes,
2559
+ ha="left",
2560
+ va="top",
2561
+ fontsize=legend_fontsize,
2562
+ bbox=legend_bbox_dict,
2563
+ )
2564
+ return p
2565
+
2566
+
2567
+ def plot_ZDR_Z(
2568
+ df,
2569
+ z,
2570
+ zdr,
2571
+ zdr_lim=(0, 2.5),
2572
+ z_lim=(0, 70),
2573
+ cmap=None,
2574
+ norm=None,
2575
+ add_colorbar=True,
2576
+ add_fit=True,
2577
+ pol="",
2578
+ title=None,
2579
+ ax=None,
2580
+ figsize=(8, 8),
2581
+ dpi=300,
2582
+ legend_fontsize=14,
2583
+ ):
2584
+ """Create a 2D histogram of Zdr vs Z."""
2585
+ # Compute 2d histogram
2586
+ ds_stats = compute_2d_histogram(
2587
+ df,
2588
+ x=z,
2589
+ y=zdr,
2590
+ x_bins=np.arange(*z_lim, 0.5),
2591
+ y_bins=np.arange(*zdr_lim, 0.025),
2592
+ )
2593
+
2594
+ # Define colormap and norm
2595
+ if cmap is None:
2596
+ cmap = plt.get_cmap("Spectral_r").copy()
2597
+ cmap.set_under(alpha=0)
2598
+ norm = LogNorm(1, None) if norm is None else norm
2599
+
2600
+ # Define symbols
2601
+ z_symbol = get_symbol_str("Z", pol)
2602
+ z_lower_symbol = get_symbol_str("z", pol)
2603
+
2604
+ # Set default title if not provided
2605
+ if title is None:
2606
+ title = rf"$Z_{{\mathrm{{DR}}}}$ vs ${z_symbol}$"
2607
+
2608
+ # Create figure if ax is None
2609
+ if ax is None:
2610
+ fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
2611
+
2612
+ # Plot 2D histogram
2613
+ p = ds_stats["count"].plot.pcolormesh(
2614
+ x=z,
2615
+ y=zdr,
2616
+ ax=ax,
2617
+ cmap=cmap,
2618
+ norm=norm,
2619
+ add_colorbar=add_colorbar,
2620
+ extend="max",
2621
+ cbar_kwargs={"label": "Counts"} if add_colorbar else {},
2622
+ )
2623
+ ax.set_xlabel(rf"${z_symbol}$ [dBZ]")
2624
+ ax.set_ylabel(r"$Z_{DR}$ [dB]")
2625
+ ax.set_xlim(*z_lim)
2626
+ ax.set_ylim(*zdr_lim)
2627
+ ax.set_title(title)
2628
+
2629
+ # Fit and plot the power law
2630
+ if add_fit:
2631
+ # Fit powerlaw ZDR = a * Z ** b
2632
+ (a, b), _ = fit_powerlaw(
2633
+ x=df[z],
2634
+ y=df[zdr],
2635
+ xbins=np.arange(5, 40, 1),
2636
+ x_in_db=True,
2637
+ )
2638
+ # Invert for Z = A * ZDR ** B
2639
+ A, B = inverse_powerlaw_parameters(a, b)
2640
+ # Define legend title
2641
+ a_str = _define_coeff_string(a)
2642
+ A_str = _define_coeff_string(A)
2643
+ legend_str = (
2644
+ rf"$Z_{{\mathrm{{DR}}}} = {a_str} \, {z_lower_symbol}^{{{b:.2f}}}$"
2645
+ "\n"
2646
+ rf"${z_lower_symbol} = {A_str} \, Z_{{\mathrm{{DR}}}}^{{{B:.2f}}}$"
2647
+ )
2648
+ # Get power law predictions
2649
+ x_pred = np.arange(0, 70)
2650
+ x_pred_linear = disdrodb.idecibel(x_pred)
2651
+ r_pred = predict_from_powerlaw(x_pred_linear, a=a, b=b)
2652
+ # Add fitted line
2653
+ ax.plot(x_pred, r_pred, linestyle="dashed", color="black")
2654
+ # Add legend
2655
+ legend_bbox_dict = {"facecolor": "white", "edgecolor": "black", "alpha": 0.7}
2656
+ ax.text(
2657
+ 0.05,
2658
+ 0.95,
2659
+ legend_str,
2660
+ transform=ax.transAxes,
2661
+ ha="left",
2662
+ va="top",
2663
+ fontsize=legend_fontsize,
2664
+ bbox=legend_bbox_dict,
2665
+ )
2666
+ return p
2667
+
2668
+
2669
+ def plot_KDP_Z(
2670
+ df,
2671
+ kdp,
2672
+ z,
2673
+ z_lim=(0, 70),
2674
+ log_kdp=False,
2675
+ kdp_lim=None,
2676
+ cmap=None,
2677
+ norm=None,
2678
+ add_colorbar=True,
2679
+ add_fit=True,
2680
+ pol="",
2681
+ title=None,
2682
+ ax=None,
2683
+ figsize=(8, 8),
2684
+ dpi=300,
2685
+ legend_fontsize=14,
2686
+ ):
2687
+ """Create a 2D histogram of KDP vs Z."""
2688
+ # Bins & limits
2689
+ z_bins = np.arange(*z_lim, 0.5)
2690
+ if log_kdp:
2691
+ kdp_lim = (0.01, 10) if kdp_lim is None else kdp_lim
2692
+ kdp_bins = log_arange(*kdp_lim, log_step=0.05, base=10)
2693
+ yscale = "log"
2694
+ kdp_ticks = [0.01, 0.1, 0.5, 1, 5, 10]
2695
+ else:
2696
+ kdp_lim = (0, 10) if kdp_lim is None else kdp_lim
2697
+ kdp_bins = np.arange(*kdp_lim, 0.1)
2698
+ yscale = None
2699
+ kdp_ticks = None
2700
+
2701
+ # Compute 2D histogram
2702
+ ds_stats = compute_2d_histogram(
2703
+ df,
2704
+ x=z,
2705
+ y=kdp,
2706
+ x_bins=z_bins,
2707
+ y_bins=kdp_bins,
2708
+ )
2709
+
2710
+ # Colormap & norm
2711
+ if cmap is None:
2712
+ cmap = plt.get_cmap("Spectral_r").copy()
2713
+ cmap.set_under(alpha=0)
2714
+ if norm is None:
2715
+ norm = LogNorm(1, None)
2716
+
2717
+ # Define symbols
2718
+ z_symbol = get_symbol_str("Z", pol)
2719
+ z_lower_symbol = get_symbol_str("z", pol)
2720
+
2721
+ # Set default title if not provided
2722
+ if title is None:
2723
+ title = rf"$K_{{\mathrm{{DP}}}}$ vs ${z_symbol}$"
2724
+
2725
+ # Create figure if ax is None
2726
+ if ax is None:
2727
+ fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
2728
+
2729
+ # Plot 2D histogram
2730
+ p = ds_stats["count"].plot.pcolormesh(
2731
+ x=z,
2732
+ y=kdp,
2733
+ ax=ax,
2734
+ cmap=cmap,
2735
+ norm=norm,
2736
+ add_colorbar=add_colorbar,
2737
+ extend="max",
2738
+ yscale=yscale,
2739
+ cbar_kwargs={"label": "Counts"} if add_colorbar else {},
2740
+ )
2741
+ ax.set_xlabel(rf"${z_symbol}$ [dBZ]")
2742
+ ax.set_ylabel(r"$K_{\mathrm{DP}}$ [deg km$^{-1}$]")
2743
+ ax.set_xlim(*z_lim)
2744
+ ax.set_ylim(*kdp_lim)
2745
+ if kdp_ticks is not None:
2746
+ ax.set_yticks(kdp_ticks)
2747
+ ax.set_yticklabels([str(v) for v in kdp_ticks])
2748
+ ax.set_title(title)
2749
+
2750
+ # Fit and overlay power law
2751
+ if add_fit:
2752
+ # Fit: KDP = a * Z^b (Z in dBZ → x_in_db=True)
2753
+ (a, b), _ = fit_powerlaw(
2754
+ x=df[z],
2755
+ y=df[kdp],
2756
+ xbins=np.arange(15, 50),
2757
+ x_in_db=True,
2758
+ )
2759
+ # Invert: Z = A * KDP^B
2760
+ A, B = inverse_powerlaw_parameters(a, b)
2761
+
2762
+ # Define legend title
2763
+ a_str = _define_coeff_string(a)
2764
+ A_str = _define_coeff_string(A)
2765
+ legend_str = (
2766
+ rf"$K_{{\mathrm{{DP}}}} = {a_str}\,{z_lower_symbol}^{{{b:.2f}}}$"
2767
+ "\n"
2768
+ rf"${z_lower_symbol} = {A_str}\,K_{{\mathrm{{DP}}}}^{{{B:.2f}}}$"
2769
+ )
2770
+
2771
+ # Get power law predictions
2772
+ x_pred = np.arange(*z_lim)
2773
+ x_pred_linear = disdrodb.idecibel(x_pred)
2774
+ y_pred = predict_from_powerlaw(x_pred_linear, a=a, b=b)
2775
+ # Add fitted power law
2776
+ ax.plot(x_pred, y_pred, linestyle="dashed", color="black")
2777
+ # Add legend
2778
+ legend_bbox_dict = {"facecolor": "white", "edgecolor": "black", "alpha": 0.7}
2779
+ ax.text(
2780
+ 0.05,
2781
+ 0.95,
2782
+ legend_str,
2783
+ transform=ax.transAxes,
2784
+ ha="left",
2785
+ va="top",
2786
+ fontsize=legend_fontsize,
2787
+ bbox=legend_bbox_dict,
2788
+ )
2789
+
2790
+ return p
2791
+
2792
+
2793
+ def plot_ADP_KDP_ZDR(
2794
+ df,
2795
+ adp,
2796
+ kdp,
2797
+ zdr,
2798
+ y_lim=(0, 0.015),
2799
+ zdr_lim=(0, 6),
2800
+ cmap=None,
2801
+ norm=None,
2802
+ add_colorbar=True,
2803
+ title=None,
2804
+ ax=None,
2805
+ figsize=(8, 8),
2806
+ dpi=300,
2807
+ ):
2808
+ """Create a 2D histogram of ADP/KDP vs ZDR.
2809
+
2810
+ References
2811
+ ----------
2812
+ Ryzhkov, A., P. Zhang, and J. Hu, 2025.
2813
+ Suggested Modifications for the S-Band Polarimetric Radar Rainfall Estimation Algorithm.
2814
+ J. Hydrometeor., 26, 1053-1062. https://doi.org/10.1175/JHM-D-25-0014.1.
2815
+ """
2816
+ # Compute ADP/KDP
2817
+ df["ADP/KDP"] = df[adp] / df[kdp]
2818
+
2819
+ # Bins & limits
2820
+ y_bins = np.arange(y_lim[0], y_lim[1], (y_lim[1] - y_lim[0]) / 200)
2821
+ zdr_bins = np.arange(zdr_lim[0], zdr_lim[1] + 0.025, 0.025)
2822
+
2823
+ # Compute 2D histogram
2824
+ ds_stats = compute_2d_histogram(
2825
+ df,
2826
+ x=zdr,
2827
+ y="ADP/KDP",
2828
+ x_bins=zdr_bins,
2829
+ y_bins=y_bins,
2830
+ )
2831
+
2832
+ # Colormap & norm
2833
+ if cmap is None:
2834
+ cmap = plt.get_cmap("Spectral_r").copy()
2835
+ cmap.set_under(alpha=0)
2836
+ if norm is None:
2837
+ norm = LogNorm(1, None)
2838
+
2839
+ # Set default title if not provided
2840
+ if title is None:
2841
+ title = r"$A_{\mathrm{DP}} / K_{\mathrm{DP}}$ vs $Z_{\mathrm{DR}}$"
2842
+
2843
+ # Create figure if ax is None
2844
+ if ax is None:
2845
+ fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
2846
+
2847
+ # Plot 2D histogram
2848
+ p = ds_stats["count"].plot.pcolormesh(
2849
+ x=zdr,
2850
+ y="ADP/KDP",
2851
+ ax=ax,
2852
+ cmap=cmap,
2853
+ norm=norm,
2854
+ add_colorbar=add_colorbar,
2855
+ extend="max",
2856
+ cbar_kwargs={"label": "Counts"} if add_colorbar else {},
2857
+ )
2858
+ ax.set_xlabel(r"$Z_{\mathrm{DR}}$ [dB]")
2859
+ ax.set_ylabel(r"$A_{\mathrm{DP}} / K_{\mathrm{DP}}$ [dB deg$^{{-1}}$]")
2860
+ ax.set_xlim(*zdr_lim)
2861
+ ax.set_ylim(*y_lim)
2862
+ ax.set_title(title)
2863
+
2864
+ return p
2865
+
2866
+
2867
+ def plot_A_KDP_ZDR(
2868
+ df,
2869
+ a,
2870
+ kdp,
2871
+ zdr,
2872
+ y_lim=(0, 0.05),
2873
+ zdr_lim=(0, 3),
2874
+ cmap=None,
2875
+ norm=None,
2876
+ add_colorbar=True,
2877
+ pol="",
2878
+ title=None,
2879
+ ax=None,
2880
+ figsize=(8, 8),
2881
+ dpi=300,
2882
+ ):
2883
+ """Create a 2D histogram of k/KDP vs ZDR.
2884
+
2885
+ References
2886
+ ----------
2887
+ Ryzhkov, A., P. Zhang, and J. Hu, 2025.
2888
+ Suggested Modifications for the S-Band Polarimetric Radar Rainfall Estimation Algorithm.
2889
+ J. Hydrometeor., 26, 1053-1062. https://doi.org/10.1175/JHM-D-25-0014.1.
2890
+ """
2891
+ # Compute A/KDP
2892
+ df["A/KDP"] = df[a] / df[kdp]
2893
+
2894
+ # Bins & limits
2895
+ y_bins = np.arange(y_lim[0], y_lim[1], (y_lim[1] - y_lim[0]) / 200)
2896
+ x_bins = np.arange(zdr_lim[0], zdr_lim[1] + 0.025, 0.025)
2897
+
2898
+ # Compute 2D histogram
2899
+ ds_stats = compute_2d_histogram(
2900
+ df,
2901
+ x=zdr,
2902
+ y="A/KDP",
2903
+ x_bins=x_bins,
2904
+ y_bins=y_bins,
2905
+ )
2906
+
2907
+ # Colormap & norm
2908
+ if cmap is None:
2909
+ cmap = plt.get_cmap("Spectral_r").copy()
2910
+ cmap.set_under(alpha=0)
2911
+ if norm is None:
2912
+ norm = LogNorm(1, None)
2913
+
2914
+ # Define symbols
2915
+ a_symbol = get_symbol_str("A", pol)
2916
+
2917
+ # Set default title if not provided
2918
+ if title is None:
2919
+ title = rf"${a_symbol} / K_{{\mathrm{{DP}}}}$ vs $Z_{{\mathrm{{DR}}}}$"
2920
+
2921
+ # Create figure if ax is None
2922
+ if ax is None:
2923
+ fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
2924
+
2925
+ # Plot 2D histogram
2926
+ p = ds_stats["count"].plot.pcolormesh(
2927
+ x=zdr,
2928
+ y="A/KDP",
2929
+ ax=ax,
2930
+ cmap=cmap,
2931
+ norm=norm,
2932
+ add_colorbar=add_colorbar,
2933
+ extend="max",
2934
+ cbar_kwargs={"label": "Counts"} if add_colorbar else {},
2935
+ )
2936
+ ax.set_xlabel(r"$Z_{\mathrm{DR}}$ [dB]")
2937
+ ax.set_ylabel(rf"${a_symbol} / K_{{\mathrm{{DP}}}}$ [dB/deg]")
2938
+ ax.set_xlim(*zdr_lim)
2939
+ ax.set_ylim(*y_lim)
2940
+ ax.set_title(title)
2941
+
2942
+ return p
2943
+
2944
+
2945
+ def plot_KDP_Z_ZDR(
2946
+ df,
2947
+ kdp,
2948
+ z,
2949
+ zdr,
2950
+ y_lim=None,
2951
+ zdr_lim=(0, 5),
2952
+ z_linear=True,
2953
+ cmap=None,
2954
+ norm=None,
2955
+ add_colorbar=True,
2956
+ title=None,
2957
+ ax=None,
2958
+ figsize=(8, 8),
2959
+ dpi=300,
2960
+ ):
2961
+ """Create a 2D histogram of (KDP/Z) vs ZDR with log-scale y-axis (no fit)."""
2962
+ # Define y limits and KDP/Z
2963
+ if z_linear:
2964
+ df["KDP/Z"] = df[kdp] / disdrodb.idecibel(df[z])
2965
+ y_lim = (1e-6, 1e-3) if y_lim is None else y_lim
2966
+ y_label = r"$K_{\mathrm{DP}} / Z$ [deg km$^{-1}$ / mm$^6$ m$^{-3}$]"
2967
+
2968
+ else:
2969
+ df["KDP/Z"] = df[kdp] / df[z]
2970
+ y_lim = (1e-5, 1e-1) if y_lim is None else y_lim
2971
+ y_label = r"$K_{\mathrm{DP}} / Z$ [deg km$^{-1}$ / dBZ]"
2972
+
2973
+ # Define bins
2974
+ y_bins = log_arange(y_lim[0], y_lim[1], log_step=0.025, base=10)
2975
+ x_bins = np.arange(zdr_lim[0], zdr_lim[1] + 0.025, 0.025)
2976
+
2977
+ # Compute 2D histogram
2978
+ ds_stats = compute_2d_histogram(
2979
+ df,
2980
+ x=zdr,
2981
+ y="KDP/Z",
2982
+ x_bins=x_bins,
2983
+ y_bins=y_bins,
2984
+ )
2985
+
2986
+ # Colormap & norm
2987
+ if cmap is None:
2988
+ cmap = plt.get_cmap("Spectral_r").copy()
2989
+ cmap.set_under(alpha=0)
2990
+ if norm is None:
2991
+ norm = LogNorm(1, None)
2992
+
2993
+ # Set default title if not provided
2994
+ if title is None:
2995
+ title = r"$K_{\mathrm{DP}}/Z$ vs $Z_{\mathrm{DR}}$"
2996
+
2997
+ # Create figure if ax is None
2998
+ if ax is None:
2999
+ fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
3000
+
3001
+ # Plot 2D histogram
3002
+ p = ds_stats["count"].plot.pcolormesh(
3003
+ x=zdr,
3004
+ y="KDP/Z",
3005
+ ax=ax,
3006
+ cmap=cmap,
3007
+ norm=norm,
3008
+ add_colorbar=add_colorbar,
3009
+ extend="max",
3010
+ yscale="log",
3011
+ cbar_kwargs={"label": "Counts"} if add_colorbar else {},
3012
+ )
3013
+ ax.set_xlabel(r"$Z_{\mathrm{DR}}$ [dB]")
3014
+ ax.set_ylabel(y_label)
3015
+ ax.set_xlim(*zdr_lim)
3016
+ ax.set_ylim(*y_lim)
3017
+ ax.set_title(title)
3018
+ return p
3019
+
3020
+
3021
+ def plot_KED_R(
3022
+ df,
3023
+ log_r=True,
3024
+ log_ked=False,
3025
+ add_fit=True,
3026
+ cmap=None,
3027
+ norm=None,
3028
+ add_colorbar=True,
3029
+ title=None,
3030
+ ax=None,
3031
+ legend_fontsize=14,
3032
+ figsize=(8, 8),
3033
+ dpi=300,
3034
+ ):
3035
+ """Create a 2D histogram of KED vs R."""
3036
+ if log_r:
3037
+ r_bins = log_arange(0.1, 500, log_step=0.05, base=10)
3038
+ r_lims = (0.1, 500)
3039
+ r_ticks = [0.1, 1, 10, 50, 100, 500]
3040
+ xscale = "log"
3041
+ else:
3042
+ r_bins = np.arange(0, 500, step=2)
3043
+ r_lims = (0, 500)
3044
+ r_ticks = None
3045
+ xscale = "linear"
3046
+ if log_ked:
3047
+ ked_bins = log_arange(1, 50, log_step=0.025, base=10)
3048
+ ked_lims = (1, 50)
3049
+ ked_ticks = [1, 10, 50]
3050
+ yscale = "log"
3051
+ else:
3052
+ ked_bins = np.arange(0, 50, step=1)
3053
+ ked_lims = (0, 50)
3054
+ ked_ticks = None
3055
+ yscale = "linear"
3056
+
3057
+ # Compute 2d histogram
3058
+ ds_stats = compute_2d_histogram(
3059
+ df,
3060
+ x="R",
3061
+ y="KED",
3062
+ x_bins=r_bins,
3063
+ y_bins=ked_bins,
3064
+ )
3065
+
3066
+ # Define colormap and norm
3067
+ if cmap is None:
3068
+ cmap = plt.get_cmap("Spectral_r").copy()
3069
+ cmap.set_under(alpha=0)
3070
+ norm = LogNorm(1, None) if norm is None else norm
3071
+
3072
+ # Set default title if not provided
3073
+ if title is None:
3074
+ title = "KED vs R"
3075
+
3076
+ # Create figure if ax is None
3077
+ if ax is None:
3078
+ fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
3079
+
3080
+ # Plot 2D histogram
3081
+ p = ds_stats["count"].plot.pcolormesh(
3082
+ x="R",
3083
+ y="KED",
3084
+ ax=ax,
3085
+ cmap=cmap,
3086
+ norm=norm,
3087
+ add_colorbar=add_colorbar,
3088
+ extend="max",
3089
+ xscale=xscale,
3090
+ yscale=yscale,
3091
+ cbar_kwargs={"label": "Counts"} if add_colorbar else {},
3092
+ )
3093
+ ax.set_xlabel(r"$R$ [mm h$^{-1}$]")
3094
+ ax.set_ylabel(r"KED [J m$^{-2}$ mm$^{-1}$]")
3095
+ ax.set_xlim(*r_lims)
3096
+ ax.set_ylim(*ked_lims)
3097
+ if r_ticks is not None:
3098
+ ax.set_xticks(r_ticks)
3099
+ ax.set_xticklabels([str(v) for v in r_ticks])
3100
+ if ked_ticks is not None:
3101
+ ax.set_yticks(ked_ticks)
3102
+ ax.set_yticklabels([str(v) for v in ked_ticks])
3103
+ ax.set_title("KED vs R")
3104
+ # Fit and plot a powerlaw
3105
+ if add_fit:
3106
+ # Fit a power law KED = a * R**b
3107
+ (a, b), _ = fit_powerlaw(
3108
+ x=df["R"],
3109
+ y=df["KED"],
3110
+ xbins=r_bins,
3111
+ x_in_db=False,
3112
+ )
3113
+ # Invert for R = A * KED**B
3114
+ A, B = inverse_powerlaw_parameters(a, b)
3115
+ # Define legend string
3116
+ a_str = _define_coeff_string(a)
3117
+ A_str = _define_coeff_string(A)
3118
+ legend_str = rf"$\mathrm{{KED}} = {a_str}\,R^{{{b:.2f}}}$" "\n" rf"$R = {A_str}\,\mathrm{{KED}}^{{{B:.2f}}}$"
3119
+ # Get power law predictions
3120
+ x_pred = np.arange(r_lims[0], r_lims[1])
3121
+ y_pred = predict_from_powerlaw(x_pred, a=a, b=b)
3122
+ # Add fitted powerlaw
3123
+ ax.plot(x_pred, y_pred, linestyle="dashed", color="black")
3124
+ # Add legend
3125
+ legend_bbox_dict = {"facecolor": "white", "edgecolor": "black", "alpha": 0.7}
3126
+ ax.text(
3127
+ 0.05,
3128
+ 0.95,
3129
+ legend_str,
3130
+ transform=ax.transAxes,
3131
+ ha="left",
3132
+ va="top",
3133
+ fontsize=legend_fontsize,
3134
+ bbox=legend_bbox_dict,
3135
+ )
3136
+
3137
+ return p
3138
+
3139
+
3140
+ def plot_KEF_R(
3141
+ df,
3142
+ log_r=True,
3143
+ log_kef=True,
3144
+ add_fit=True,
3145
+ cmap=None,
3146
+ norm=None,
3147
+ add_colorbar=True,
3148
+ title=None,
3149
+ ax=None,
3150
+ legend_fontsize=14,
3151
+ figsize=(8, 8),
3152
+ dpi=300,
3153
+ ):
3154
+ """Create a 2D histogram of KEF vs R."""
3155
+ if log_r:
3156
+ r_bins = log_arange(0.1, 500, log_step=0.05, base=10)
3157
+ r_lims = (0.1, 500)
3158
+ r_ticks = [0.1, 1, 10, 50, 100, 500]
3159
+ xscale = "log"
3160
+ else:
3161
+ r_bins = np.arange(0, 500, step=2)
3162
+ r_lims = (0, 500)
3163
+ r_ticks = None
3164
+ xscale = "linear"
3165
+ if log_kef:
3166
+ kef_bins = log_arange(0.1, 10_000, log_step=0.05, base=10)
3167
+ kef_lims = (0.1, 10_000)
3168
+ kef_ticks = [0.1, 1, 10, 100, 1000, 10000]
3169
+ yscale = "log"
3170
+ else:
3171
+ kef_bins = np.arange(0, 5000, step=50)
3172
+ kef_lims = (0, 5000)
3173
+ kef_ticks = None
3174
+ yscale = "linear"
3175
+
3176
+ # Compute 2d histogram
3177
+ ds_stats = compute_2d_histogram(
3178
+ df,
3179
+ x="R",
3180
+ y="KEF",
3181
+ x_bins=r_bins,
3182
+ y_bins=kef_bins,
3183
+ )
3184
+
3185
+ # Define colormap and norm
3186
+ if cmap is None:
3187
+ cmap = plt.get_cmap("Spectral_r").copy()
3188
+ cmap.set_under(alpha=0)
3189
+ norm = LogNorm(1, None) if norm is None else norm
3190
+
3191
+ # Set default title if not provided
3192
+ if title is None:
3193
+ title = "KEF vs R"
3194
+
3195
+ # Create figure if ax is None
3196
+ if ax is None:
3197
+ fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
3198
+
3199
+ # Plot 2D histogram
3200
+ p = ds_stats["count"].plot.pcolormesh(
3201
+ x="R",
3202
+ y="KEF",
3203
+ ax=ax,
3204
+ cmap=cmap,
3205
+ norm=norm,
3206
+ add_colorbar=add_colorbar,
3207
+ extend="max",
3208
+ xscale=xscale,
3209
+ yscale=yscale,
3210
+ cbar_kwargs={"label": "Counts"} if add_colorbar else {},
3211
+ )
3212
+ ax.set_xlabel(r"$R$ [mm h$^{-1}$]")
3213
+ ax.set_ylabel(r"KEF [J m$^{-2}$ h$^{-1}$]")
3214
+ ax.set_xlim(*r_lims)
3215
+ ax.set_ylim(*kef_lims)
3216
+ if r_ticks is not None:
3217
+ ax.set_xticks(r_ticks)
3218
+ ax.set_xticklabels([str(v) for v in r_ticks])
3219
+ if kef_ticks is not None:
3220
+ ax.set_yticks(kef_ticks)
3221
+ ax.set_yticklabels([str(v) for v in kef_ticks])
3222
+ ax.set_title(title)
3223
+
3224
+ # Fit and plot the power law
3225
+ # - Alternative fit model: a + I *(1 - b*exp(c*I)) (a is upper limit)
3226
+ if add_fit:
3227
+ # Fit power law KEF = a * R ** b
3228
+ (a, b), _ = fit_powerlaw(
3229
+ x=df["R"],
3230
+ y=df["KEF"],
3231
+ xbins=r_bins,
3232
+ x_in_db=False,
3233
+ )
3234
+ # Invert parameters for R = A * KEF ** B
3235
+ A, B = inverse_powerlaw_parameters(a, b)
3236
+ # Define legend string
3237
+ a_str = _define_coeff_string(a)
3238
+ A_str = _define_coeff_string(A)
3239
+ legend_str = rf"$\mathrm{{KEF}} = {a_str}\,R^{{{b:.2f}}}$" "\n" rf"$R = {A_str}\,\mathrm{{KEF}}^{{{B:.2f}}}$"
3240
+ # Get power law predictions
3241
+ x_pred = np.arange(*r_lims)
3242
+ kef_pred = predict_from_powerlaw(x_pred, a=a, b=b)
3243
+ # Add fitted powerlaw
3244
+ ax.plot(x_pred, kef_pred, linestyle="dashed", color="black")
3245
+ # Add legend
3246
+ legend_bbox_dict = {"facecolor": "white", "edgecolor": "black", "alpha": 0.7}
3247
+ ax.text(
3248
+ 0.05,
3249
+ 0.95,
3250
+ legend_str,
3251
+ transform=ax.transAxes,
3252
+ ha="left",
3253
+ va="top",
3254
+ fontsize=legend_fontsize,
3255
+ bbox=legend_bbox_dict,
3256
+ )
3257
+ return p
3258
+
3259
+
3260
+ def plot_KEF_Z(
3261
+ df,
3262
+ z="Z",
3263
+ log_kef=True,
3264
+ add_fit=True,
3265
+ pol="",
3266
+ cmap=None,
3267
+ norm=None,
3268
+ add_colorbar=True,
3269
+ title=None,
3270
+ ax=None,
3271
+ legend_fontsize=14,
3272
+ figsize=(8, 8),
3273
+ dpi=300,
3274
+ ):
3275
+ """Create a 2D histogram of KEF vs Z."""
3276
+ # Define limits and bins
3277
+ z_lims = (0, 70)
3278
+ z_bins = np.arange(*z_lims, step=1)
3279
+
3280
+ if log_kef:
3281
+ kef_lims = (0.1, 10_000)
3282
+ kef_bins = log_arange(*kef_lims, log_step=0.05, base=10)
3283
+ kef_ticks = [0.1, 1, 10, 100, 1000, 10000]
3284
+ yscale = "log"
3285
+ else:
3286
+ kef_lims = (0, 5000)
3287
+ kef_bins = np.arange(*kef_lims, step=50)
3288
+ kef_ticks = None
3289
+ yscale = "linear"
3290
+
3291
+ # Compute 2d histogram
3292
+ ds_stats = compute_2d_histogram(
3293
+ df,
3294
+ x=z,
3295
+ y="KEF",
3296
+ x_bins=z_bins,
3297
+ y_bins=kef_bins,
3298
+ )
3299
+
3300
+ # Define colormap and norm
3301
+ if cmap is None:
3302
+ cmap = plt.get_cmap("Spectral_r").copy()
3303
+ cmap.set_under(alpha=0)
3304
+ norm = LogNorm(1, None) if norm is None else norm
3305
+
3306
+ # Define symbols
3307
+ z_symbol = get_symbol_str("Z", pol)
3308
+ z_lower_symbol = get_symbol_str("z", pol)
3309
+
3310
+ # Set default title if not provided
3311
+ if title is None:
3312
+ title = rf"KEF vs ${z_symbol}$"
3313
+
3314
+ # Create figure if ax is None
3315
+ if ax is None:
3316
+ fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
3317
+
3318
+ # Plot 2D histogram
3319
+ p = ds_stats["count"].plot.pcolormesh(
3320
+ x=z,
3321
+ y="KEF",
3322
+ ax=ax,
3323
+ cmap=cmap,
3324
+ norm=norm,
3325
+ add_colorbar=add_colorbar,
3326
+ extend="max",
3327
+ yscale=yscale,
3328
+ cbar_kwargs={"label": "Counts"} if add_colorbar else {},
3329
+ )
3330
+ ax.set_xlabel(rf"${z_symbol}$ [dB]")
3331
+ ax.set_ylabel(r"KEF [$J$ m$^{-2}$ h$^{-1}$]")
3332
+ ax.set_xlim(*z_lims)
3333
+ ax.set_ylim(*kef_lims)
3334
+ if kef_ticks is not None:
3335
+ ax.set_yticks(kef_ticks)
3336
+ ax.set_yticklabels([str(v) for v in kef_ticks])
3337
+ ax.set_title(title)
3338
+
3339
+ # Fit and plot the powerlaw
3340
+ if add_fit:
3341
+ # Fit power law KEF = a * Z ** b
3342
+ (a, b), _ = fit_powerlaw(
3343
+ x=df[z],
3344
+ y=df["KEF"],
3345
+ xbins=z_bins,
3346
+ x_in_db=True,
3347
+ )
3348
+ # Invert parameters for Z = A * KEF ** B
3349
+ A, B = inverse_powerlaw_parameters(a, b)
3350
+ # Define legend string
3351
+ a_str = _define_coeff_string(a)
3352
+ A_str = _define_coeff_string(A)
3353
+ legend_str = (
3354
+ rf"$\mathrm{{KEF}} = {a_str}\;{z_lower_symbol}^{{{b:.2f}}}$"
3355
+ "\n"
3356
+ rf"${z_lower_symbol} = {A_str}\;\mathrm{{KEF}}^{{{B:.2f}}}$"
3357
+ )
3358
+ # Get power law predictions
3359
+ x_pred = np.arange(*z_lims)
3360
+ x_pred_linear = disdrodb.idecibel(x_pred)
3361
+ kef_pred = predict_from_powerlaw(x_pred_linear, a=a, b=b)
3362
+ # Add fitted powerlaw
3363
+ ax.plot(x_pred, kef_pred, linestyle="dashed", color="black")
3364
+ # Add legend
3365
+ legend_bbox_dict = {"facecolor": "white", "edgecolor": "black", "alpha": 0.7}
3366
+ ax.text(
3367
+ 0.05,
3368
+ 0.95,
3369
+ legend_str,
3370
+ transform=ax.transAxes,
3371
+ ha="left",
3372
+ va="top",
3373
+ fontsize=legend_fontsize,
3374
+ bbox=legend_bbox_dict,
3375
+ )
3376
+
3377
+ return p
3378
+
3379
+
3380
+ def plot_TKE_Z(
3381
+ df,
3382
+ z="Z",
3383
+ log_tke=True,
3384
+ add_fit=True,
3385
+ cmap=None,
3386
+ norm=None,
3387
+ add_colorbar=True,
3388
+ title=None,
3389
+ ax=None,
3390
+ legend_fontsize=14,
3391
+ figsize=(8, 8),
3392
+ dpi=300,
3393
+ ):
3394
+ """Create a 2D histogram of TKE vs Z."""
3395
+ z_bins = np.arange(0, 70, step=1)
3396
+ z_lims = (0, 70)
3397
+ if log_tke:
3398
+ tke_bins = log_arange(0.01, 500, log_step=0.05, base=10)
3399
+ tke_lims = (0.01, 200)
3400
+ tke_ticks = [0.01, 0.1, 1, 10, 100, 200]
3401
+ yscale = "log"
3402
+ else:
3403
+ tke_bins = np.arange(0, 200, step=1)
3404
+ tke_lims = (0, 200)
3405
+ tke_ticks = None
3406
+ yscale = "linear"
3407
+
3408
+ # Compute 2d histogram
3409
+ ds_stats = compute_2d_histogram(
3410
+ df,
3411
+ x=z,
3412
+ y="TKE",
3413
+ x_bins=z_bins,
3414
+ y_bins=tke_bins,
3415
+ )
3416
+
3417
+ # Define colormap and norm
3418
+ if cmap is None:
3419
+ cmap = plt.get_cmap("Spectral_r").copy()
3420
+ cmap.set_under(alpha=0)
3421
+ norm = LogNorm(1, None) if norm is None else norm
3422
+
3423
+ # Set default title if not provided
3424
+ if title is None:
3425
+ title = "TKE vs Z"
3426
+
3427
+ # Create figure if ax is None
3428
+ if ax is None:
3429
+ fig, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
3430
+
3431
+ # Plot 2D histogram
3432
+ p = ds_stats["count"].plot.pcolormesh(
3433
+ x=z,
3434
+ y="TKE",
3435
+ ax=ax,
3436
+ cmap=cmap,
3437
+ norm=norm,
3438
+ add_colorbar=add_colorbar,
3439
+ extend="max",
3440
+ yscale=yscale,
3441
+ cbar_kwargs={"label": "Counts"} if add_colorbar else {},
3442
+ )
3443
+ ax.set_xlabel(r"$Z$ [dB]")
3444
+ ax.set_ylabel(r"TKE [$J$ m$^{-2}$]")
3445
+ ax.set_xlim(*z_lims)
3446
+ ax.set_ylim(*tke_lims)
3447
+ if tke_ticks is not None:
3448
+ ax.set_yticks(tke_ticks)
3449
+ ax.set_yticklabels([str(v) for v in tke_ticks])
3450
+ ax.set_title(title)
3451
+
3452
+ # Fit and plot the powerlaw
3453
+ if add_fit:
3454
+ # Fit power law TKE = a * Z ** b
3455
+ (a, b), _ = fit_powerlaw(
3456
+ x=df[z],
3457
+ y=df["TKE"],
3458
+ xbins=z_bins,
3459
+ x_in_db=True,
3460
+ )
3461
+ # Invert parameters for Z = A * KEF ** B
3462
+ A, B = inverse_powerlaw_parameters(a, b)
3463
+ # Define legend string
3464
+ a_str = _define_coeff_string(a)
3465
+ A_str = _define_coeff_string(A)
3466
+ legend_str = rf"$\mathrm{{TKE}} = {a_str}\;z^{{{b:.2f}}}$" "\n" rf"$z = {A_str}\;\mathrm{{TKE}}^{{{B:.2f}}}$"
3467
+ # Get power law predictions
3468
+ x_pred = np.arange(*z_lims)
3469
+ x_pred_linear = disdrodb.idecibel(x_pred)
3470
+ y_pred = predict_from_powerlaw(x_pred_linear, a=a, b=b)
3471
+ # Add fitted powerlaw
3472
+ ax.plot(x_pred, y_pred, linestyle="dashed", color="black")
3473
+ # Add legend
3474
+ legend_bbox_dict = {"facecolor": "white", "edgecolor": "black", "alpha": 0.7}
3475
+ ax.text(
3476
+ 0.05,
3477
+ 0.95,
3478
+ legend_str,
3479
+ transform=ax.transAxes,
3480
+ ha="left",
3481
+ va="top",
3482
+ fontsize=legend_fontsize,
3483
+ bbox=legend_bbox_dict,
3484
+ )
3485
+
3486
+ return p
3487
+
3488
+
3489
+ ####-----------------------------------------------------------------------.
3490
+ #### Radar and Kinetic Energy Summary figures
3491
+
3492
+
3493
+ def plot_radar_relationships(df, band):
3494
+ """Create 3x3 multipanel figure with radar relationships."""
3495
+ # Check band
3496
+ if band not in {"X", "C", "S"}:
3497
+ raise ValueError("Plotting function developed only for bands: 'X', 'C', 'S'.")
3498
+
3499
+ # Define columns
3500
+ z = f"DBZH_{band}"
3501
+ zdr = f"ZDR_{band}"
3502
+ kdp = f"KDP_{band}"
3503
+ a = f"AH_{band}"
3504
+ adp = f"ADP_{band}"
3505
+
3506
+ # Define limits
3507
+ adp_kdp_ylim_dict = {
3508
+ "X": (0.0, 0.05),
3509
+ "C": (0.0, 0.05),
3510
+ "S": (0.0, 0.015),
3511
+ }
3512
+ adp_kdp_ylim = adp_kdp_ylim_dict[band]
3513
+
3514
+ a_ylim_dict = {
3515
+ "S": (0.00001, 1),
3516
+ "C": (0.0001, 10),
3517
+ "X": (0.0001, 10),
3518
+ }
3519
+ a_ylim = a_ylim_dict[band]
3520
+
3521
+ # Define plotting settings
3522
+ add_colorbar = False
3523
+ norm = LogNorm(1, None)
3524
+ legend_fontsize = 12
3525
+
3526
+ # Initialize figure
3527
+ fig = plt.figure(figsize=(10, 12), dpi=300) # Slightly taller to accommodate colorbar
3528
+ # fig.suptitle(f'C-band Polarimetric Radar Variables Relationships', fontsize=16, y=0.96)
3529
+
3530
+ # Create gridspec with space for colorbar at bottom
3531
+ gs = GridSpec(
3532
+ 4,
3533
+ 3,
3534
+ figure=fig,
3535
+ height_ratios=[1, 1, 1, 0.05],
3536
+ hspace=0.35,
3537
+ wspace=0.35,
3538
+ left=0.05,
3539
+ right=0.95,
3540
+ top=0.93,
3541
+ bottom=0.08,
3542
+ )
3543
+
3544
+ # Create subplots using gridspec
3545
+ axes = []
3546
+ for i in range(3):
3547
+ for j in range(3):
3548
+ ax = fig.add_subplot(gs[i, j])
3549
+ axes.append(ax)
3550
+
3551
+ # Flatten axes for easier indexing
3552
+ ax = np.array(axes).flatten()
3553
+
3554
+ # - R vs Z_H
3555
+ p = plot_R_Z(
3556
+ df,
3557
+ z=z,
3558
+ r="R",
3559
+ pol="H",
3560
+ norm=norm,
3561
+ add_colorbar=add_colorbar,
3562
+ legend_fontsize=legend_fontsize,
3563
+ ax=ax[0],
3564
+ )
3565
+
3566
+ # - Define norm for other plots
3567
+ norm = p.norm
3568
+
3569
+ # - R vs K_DP
3570
+ plot_R_KDP(
3571
+ df,
3572
+ kdp=kdp,
3573
+ r="R",
3574
+ log_scale=True,
3575
+ legend_fontsize=legend_fontsize,
3576
+ norm=norm,
3577
+ add_colorbar=add_colorbar,
3578
+ ax=ax[1],
3579
+ )
3580
+
3581
+ # - Z_DR vs Z_H
3582
+ plot_ZDR_Z(
3583
+ df,
3584
+ z=z,
3585
+ zdr=zdr,
3586
+ pol="H",
3587
+ legend_fontsize=legend_fontsize,
3588
+ norm=norm,
3589
+ add_colorbar=add_colorbar,
3590
+ ax=ax[2],
3591
+ )
3592
+
3593
+ # - A_H vs Z_H
3594
+ plot_A_Z(
3595
+ df,
3596
+ a=a,
3597
+ z=z,
3598
+ pol="H",
3599
+ legend_fontsize=legend_fontsize,
3600
+ norm=norm,
3601
+ add_colorbar=add_colorbar,
3602
+ a_lim=a_ylim,
3603
+ ax=ax[3],
3604
+ )
3605
+
3606
+ # - A_H vs K_DP
3607
+ plot_A_KDP(
3608
+ df,
3609
+ a=a,
3610
+ kdp=kdp,
3611
+ pol="H",
3612
+ legend_fontsize=legend_fontsize,
3613
+ norm=norm,
3614
+ add_colorbar=add_colorbar,
3615
+ ax=ax[4],
3616
+ )
3617
+ # plot_A_KDP(df, a=a, kdp=kdp, log_a=True, log_kdp=True,
3618
+ # legend_fontsize=legend_fontsize, norm=norm, add_colorbar=add_colorbar, ax=ax[4]))
3619
+
3620
+ # - A_H vs R
3621
+ plot_A_R(df, a=a, r="R", pol="H", legend_fontsize=legend_fontsize, norm=norm, add_colorbar=add_colorbar, ax=ax[5])
3622
+
3623
+ # - K_DP vs Z_H
3624
+ plot_KDP_Z(
3625
+ df,
3626
+ kdp=kdp,
3627
+ z=z,
3628
+ pol="H",
3629
+ legend_fontsize=legend_fontsize,
3630
+ norm=norm,
3631
+ add_colorbar=add_colorbar,
3632
+ log_kdp=True,
3633
+ ax=ax[6],
3634
+ )
3635
+
3636
+ # - A_DP/K_DP vs Z_DR
3637
+ plot_ADP_KDP_ZDR(df, adp=adp, kdp=kdp, zdr=zdr, norm=norm, add_colorbar=add_colorbar, y_lim=adp_kdp_ylim, ax=ax[7])
3638
+ # plot_A_KDP_ZDR(df, a=a, kdp=kdp, zdr=zdr, y_lim=(0, 0.3), norm=norm, add_colorbar=add_colorbar)
3639
+
3640
+ # - K_DP/Z vs Z_DR
3641
+ p = plot_KDP_Z_ZDR(df, kdp=kdp, z=z, zdr=zdr, norm=norm, add_colorbar=add_colorbar, z_linear=False, ax=ax[8])
3642
+ # plot_KDP_Z_ZDR(df, kdp=kdp, z=z, zdr=zdr, norm=norm, add_colorbar=add_colorbar, z_linear=True, ax=ax[8])
3643
+
3644
+ # - Add colorbar
3645
+ cax = fig.add_subplot(gs[3, :]) # Spans all columns in the bottom row
3646
+ cbar = plt.colorbar(p, cax=cax, orientation="horizontal", extend="max", extendfrac=0.025)
3647
+ cbar.ax.set_aspect(0.1)
3648
+ cbar.set_label("Counts", fontsize=12, labelpad=6)
3649
+ cbar.ax.tick_params(labelsize=12)
3650
+ cbar.ax.xaxis.set_label_position("top")
3651
+ return fig
3652
+
3653
+
3654
+ def plot_kinetic_energy_relationships(df):
3655
+ """Create a 2x2 multipanel figure showing kinetic energy relationships."""
3656
+ # Define plotting settings
3657
+ add_colorbar = False
3658
+ norm = LogNorm(1, None)
3659
+ legend_fontsize = 12
3660
+ # Initialize figure
3661
+ fig = plt.figure(figsize=(9, 10), dpi=300)
3662
+
3663
+ # Create gridspec with space for colorbar at bottom
3664
+ gs = GridSpec(
3665
+ 3,
3666
+ 2,
3667
+ figure=fig,
3668
+ height_ratios=[1, 1, 0.05],
3669
+ hspace=0.3,
3670
+ wspace=0.25,
3671
+ left=0.05,
3672
+ right=0.95,
3673
+ top=0.93,
3674
+ bottom=0.08,
3675
+ )
3676
+
3677
+ # Create subplots using gridspec
3678
+ axes = []
3679
+ for i in range(2):
3680
+ for j in range(2):
3681
+ ax = fig.add_subplot(gs[i, j])
3682
+ axes.append(ax)
3683
+
3684
+ # Flatten axes for easier indexing
3685
+ ax = np.array(axes).flatten()
3686
+
3687
+ # Plot the specific functions you requested:
3688
+
3689
+ # KED vs R (linear KED)
3690
+ p = plot_KED_R(df, norm=norm, legend_fontsize=legend_fontsize, add_colorbar=add_colorbar, ax=ax[0])
3691
+
3692
+ # Define norm for other plots based on first plot
3693
+ norm = p.norm
3694
+
3695
+ # KEF vs R
3696
+ plot_KEF_R(df, norm=norm, legend_fontsize=legend_fontsize, add_colorbar=add_colorbar, ax=ax[1])
3697
+
3698
+ # KEF vs Z_H
3699
+ plot_KEF_Z(df, z="Z", norm=norm, legend_fontsize=legend_fontsize, add_colorbar=add_colorbar, ax=ax[2])
3700
+
3701
+ # TKE vs Z_H
3702
+ p_last = plot_TKE_Z(df, z="Z", norm=norm, legend_fontsize=legend_fontsize, add_colorbar=add_colorbar, ax=ax[3])
3703
+
3704
+ # Add colorbar at the bottom
3705
+ cax = fig.add_subplot(gs[2, :]) # Spans all columns in the bottom row
3706
+ cbar = plt.colorbar(p_last, cax=cax, orientation="horizontal", extend="max", extendfrac=0.025)
3707
+ cbar.ax.set_aspect(0.1)
3708
+ cbar.set_label("Counts", fontsize=12, labelpad=10)
3709
+ cbar.ax.tick_params(labelsize=12)
3710
+ cbar.ax.xaxis.set_label_position("top")
3711
+
3712
+ return fig
3713
+
3714
+
3715
+ ####-----------------------------------------------------------------------.
3716
+ #### Summary routine
3717
+
3718
+
3719
+ def define_filename(prefix, extension, data_source, campaign_name, station_name):
3720
+ """Define filename for summary files."""
3721
+ if extension in ["png", "jpeg"]:
3722
+ filename = f"Figure.{prefix}.{data_source}.{campaign_name}.{station_name}.{extension}"
3723
+ if extension in ["csv", "parquet", "pdf", "yaml", "yml"]:
3724
+ filename = f"Table.{prefix}.{data_source}.{campaign_name}.{station_name}.{extension}"
3725
+ if extension in ["nc"]:
3726
+ filename = f"Dataset.{prefix}.{data_source}.{campaign_name}.{station_name}.{extension}"
3727
+ return filename
3728
+
3729
+
3730
+ def create_l2_dataframe(ds):
3731
+ """Create pandas Dataframe for L2 analysis."""
3732
+ # - Drop array variables and convert to pandas
3733
+ df = ds.drop_dims([DIAMETER_DIMENSION, VELOCITY_DIMENSION]).to_pandas()
3734
+ # - Drop coordinates
3735
+ coords_to_drop = ["velocity_method", "sample_interval", *RADAR_OPTIONS]
3736
+ df = df.drop(columns=coords_to_drop, errors="ignore")
3737
+ # - Drop rows with missing rain
3738
+ df = df[df["R"] > 0]
3739
+ return df
3740
+
3741
+
3742
+ def prepare_summary_dataset(ds, velocity_method="fall_velocity", source="drop_number"):
3743
+ """Prepare the L2E or L2M dataset to be converted to a dataframe."""
3744
+ # Select fall velocity method
3745
+ if "velocity_method" in ds.dims:
3746
+ ds = ds.sel(velocity_method=velocity_method)
3747
+
3748
+ # Select first occurrence of radars options (except frequency)
3749
+ for dim in RADAR_OPTIONS:
3750
+ if dim in ds.dims and dim != "frequency":
3751
+ ds = ds.isel({dim: 0})
3752
+
3753
+ # Unstack frequency dimension
3754
+ ds = unstack_radar_variables(ds)
3755
+
3756
+ # For kinetic energy variables, select source="drop_number"
3757
+ if "source" in ds.dims:
3758
+ ds = ds.sel(source=source)
3759
+
3760
+ # Select only timesteps with R > 0
3761
+ # - We save R with 2 decimals accuracy ... so 0.01 is the smallest value
3762
+ rainy_timesteps = np.logical_and(ds["Rm"].compute() >= 0.01, ds["R"].compute() >= 0.01)
3763
+ ds = ds.isel(time=rainy_timesteps)
3764
+ return ds
3765
+
3766
+
3767
+ def generate_station_summary(ds, summary_dir_path, data_source, campaign_name, station_name):
3768
+ """Generate station summary using L2E dataset."""
3769
+ # Create summary directory if does not exist
3770
+ os.makedirs(summary_dir_path, exist_ok=True)
3771
+
3772
+ ####---------------------------------------------------------------------.
3773
+ #### Prepare dataset
3774
+ ds = prepare_summary_dataset(ds)
3775
+
3776
+ # Ensure all data are in memory
3777
+ ds = ds.compute()
3778
+
3779
+ ####---------------------------------------------------------------------.
3780
+ #### Create drop spectrum figures and statistics
3781
+ # Compute sum of raw and filtered spectrum over time
3782
+
3783
+ raw_drop_number = ds["raw_drop_number"].sum(dim="time")
3784
+ drop_number = ds["drop_number"].sum(dim="time")
3785
+
3786
+ # Define theoretical and measured average velocity
3787
+ theoretical_average_velocity = ds["fall_velocity"].mean(dim="time")
3788
+ measured_average_velocity = get_drop_average_velocity(drop_number)
3789
+
3790
+ # Save raw and filtered spectrum over time & theoretical and measured average fall velocity
3791
+ ds_stats = xr.Dataset()
3792
+ ds_stats["raw_drop_number"] = raw_drop_number
3793
+ ds_stats["drop_number"] = raw_drop_number
3794
+ ds_stats["theoretical_average_velocity"] = theoretical_average_velocity
3795
+ ds_stats["measured_average_velocity"] = measured_average_velocity
3796
+ filename = define_filename(
3797
+ prefix="SpectrumStats",
3798
+ extension="nc",
3799
+ data_source=data_source,
3800
+ campaign_name=campaign_name,
3801
+ station_name=station_name,
3802
+ )
3803
+ ds_stats.to_netcdf(os.path.join(summary_dir_path, filename))
3804
+
3805
+ # Create figures with raw and filtered spectrum
3806
+ # - Raw
3807
+ filename = define_filename(
3808
+ prefix="SpectrumRaw",
3809
+ extension="png",
3810
+ data_source=data_source,
3811
+ campaign_name=campaign_name,
3812
+ station_name=station_name,
3813
+ )
3814
+ p = plot_drop_spectrum(raw_drop_number, title="Raw Drop Spectrum")
3815
+ p.figure.savefig(os.path.join(summary_dir_path, filename))
3816
+ plt.close()
3817
+
3818
+ # - Filtered
3819
+ filename = define_filename(
3820
+ prefix="SpectrumFiltered",
3821
+ extension="png",
3822
+ data_source=data_source,
3823
+ campaign_name=campaign_name,
3824
+ station_name=station_name,
3825
+ )
3826
+ p = plot_drop_spectrum(drop_number, title="Filtered Drop Spectrum")
3827
+ p.figure.savefig(os.path.join(summary_dir_path, filename))
3828
+ plt.close()
3829
+
3830
+ # Create figure comparing raw and filtered spectrum
3831
+ filename = define_filename(
3832
+ prefix="SpectrumSummary",
3833
+ extension="png",
3834
+ data_source=data_source,
3835
+ campaign_name=campaign_name,
3836
+ station_name=station_name,
3837
+ )
3838
+
3839
+ fig = plot_raw_and_filtered_spectrums(
3840
+ raw_drop_number=raw_drop_number,
3841
+ drop_number=drop_number,
3842
+ theoretical_average_velocity=theoretical_average_velocity,
3843
+ measured_average_velocity=measured_average_velocity,
3844
+ )
3845
+ fig.savefig(os.path.join(summary_dir_path, filename))
3846
+ plt.close()
3847
+
3848
+ ####---------------------------------------------------------------------.
3849
+ #### Create L2E 1MIN dataframe
3850
+ df = create_l2_dataframe(ds)
3851
+
3852
+ # Define diameter bin edges
3853
+ diameter_bin_edges = get_diameter_bin_edges(ds)
3854
+
3855
+ # ---------------------------------------------------------------------.
3856
+ #### Save L2E 1MIN Parquet
3857
+ l2e_parquet_filename = f"L2E.1MIN.PARQUET.{data_source}.{campaign_name}.{station_name}.parquet"
3858
+ l2e_parquet_filepath = os.path.join(summary_dir_path, l2e_parquet_filename)
3859
+ df.to_parquet(l2e_parquet_filepath, engine="pyarrow", compression="snappy")
3860
+
3861
+ #### ---------------------------------------------------------------------.
3862
+ #### Create table with rain summary
3863
+ table_rain_summary = create_table_rain_summary(df)
3864
+ table_rain_summary_filename = f"Station_Summary.{data_source}.{campaign_name}.{station_name}.yaml"
3865
+ table_rain_summary_filepath = os.path.join(summary_dir_path, table_rain_summary_filename)
3866
+ write_yaml(table_rain_summary, filepath=table_rain_summary_filepath)
3867
+
3868
+ # ---------------------------------------------------------------------.
3869
+ #### Creata table with events summary
3870
+ table_events_summary = create_table_events_summary(df)
3871
+ # - Save table as csv
3872
+ table_events_summary_csv_filename = f"Events_Summary.{data_source}.{campaign_name}.{station_name}.csv"
3873
+ table_events_summary_csv_filepath = os.path.join(summary_dir_path, table_events_summary_csv_filename)
3874
+ table_events_summary.to_csv(table_events_summary_csv_filepath)
3875
+ # - Save table as pdf
3876
+ if is_latex_engine_available():
3877
+ table_events_summary_pdf_filename = f"Events_Summary.{data_source}.{campaign_name}.{station_name}.pdf"
3878
+ table_events_summary_pdf_filepath = os.path.join(summary_dir_path, table_events_summary_pdf_filename)
3879
+ save_table_to_pdf(
3880
+ df=prepare_latex_table_events_summary(table_events_summary),
3881
+ filepath=table_events_summary_pdf_filepath,
3882
+ index=True,
3883
+ caption="Events Summary",
3884
+ orientation="landscape",
3885
+ )
3886
+
3887
+ # ---------------------------------------------------------------------.
3888
+ #### Create table with integral DSD parameters statistics
3889
+ table_dsd_summary = create_table_dsd_summary(df)
3890
+ # - Save table as csv
3891
+ table_dsd_summary_csv_filename = f"DSD_Summary.{data_source}.{campaign_name}.{station_name}.csv"
3892
+ table_dsd_summary_csv_filepath = os.path.join(summary_dir_path, table_dsd_summary_csv_filename)
3893
+ table_dsd_summary.to_csv(table_dsd_summary_csv_filepath)
3894
+ # - Save table as pdf
3895
+ if is_latex_engine_available():
3896
+ table_dsd_summary_pdf_filename = f"DSD_Summary.{data_source}.{campaign_name}.{station_name}.pdf"
3897
+ table_dsd_summary_pdf_filepath = os.path.join(summary_dir_path, table_dsd_summary_pdf_filename)
3898
+ save_table_to_pdf(
3899
+ df=prepare_latex_table_dsd_summary(table_dsd_summary),
3900
+ index=True,
3901
+ filepath=table_dsd_summary_pdf_filepath,
3902
+ caption="DSD Summary",
3903
+ orientation="portrait", # "landscape",
3904
+ )
3905
+
3906
+ #### ---------------------------------------------------------------------.
3907
+ #### Create L2E RADAR Summary Plots
3908
+ # Summary plots at X, C, S bands
3909
+ if "DBZH_X" in df:
3910
+ filename = define_filename(
3911
+ prefix="Radar_Band_X",
3912
+ extension="png",
3913
+ data_source=data_source,
3914
+ campaign_name=campaign_name,
3915
+ station_name=station_name,
3916
+ )
3917
+ fig = plot_radar_relationships(df, band="X")
3918
+ fig.savefig(os.path.join(summary_dir_path, filename))
3919
+ if "DBZH_C" in df:
3920
+ filename = define_filename(
3921
+ prefix="Radar_Band_C",
3922
+ extension="png",
3923
+ data_source=data_source,
3924
+ campaign_name=campaign_name,
3925
+ station_name=station_name,
3926
+ )
3927
+ fig = plot_radar_relationships(df, band="C")
3928
+ fig.savefig(os.path.join(summary_dir_path, filename))
3929
+ if "DBZH_S" in df:
3930
+ filename = define_filename(
3931
+ prefix="Radar_Band_S",
3932
+ extension="png",
3933
+ data_source=data_source,
3934
+ campaign_name=campaign_name,
3935
+ station_name=station_name,
3936
+ )
3937
+ fig = plot_radar_relationships(df, band="S")
3938
+ fig.savefig(os.path.join(summary_dir_path, filename))
3939
+
3940
+ # ---------------------------------------------------------------------.
3941
+ #### - Create Z-R figure
3942
+ filename = define_filename(
3943
+ prefix="Z-R",
3944
+ extension="png",
3945
+ data_source=data_source,
3946
+ campaign_name=campaign_name,
3947
+ station_name=station_name,
3948
+ )
3949
+
3950
+ p = plot_R_Z(df, z="Z", r="R", title=r"$Z$ vs $R$")
3951
+ p.figure.savefig(os.path.join(summary_dir_path, filename))
3952
+ plt.close()
3953
+
3954
+ #### ---------------------------------------------------------------------.
3955
+ #### Create L2E Kinetic Energy Summary Plots
3956
+ filename = define_filename(
3957
+ prefix="KineticEnergy",
3958
+ extension="png",
3959
+ data_source=data_source,
3960
+ campaign_name=campaign_name,
3961
+ station_name=station_name,
3962
+ )
3963
+ fig = plot_kinetic_energy_relationships(df)
3964
+ fig.savefig(os.path.join(summary_dir_path, filename))
3965
+
3966
+ #### ---------------------------------------------------------------------.
3967
+ #### Create L2E DSD Parameters summary plots
3968
+ #### - Create DSD parameters density figures with LWC
3969
+ filename = define_filename(
3970
+ prefix="DSD_Params_Density_with_LWC_LinearDm_MaxNormalized",
3971
+ extension="png",
3972
+ data_source=data_source,
3973
+ campaign_name=campaign_name,
3974
+ station_name=station_name,
3975
+ )
3976
+ fig = plot_dsd_params_density(df, log_dm=False, lwc=True, log_normalize=False)
3977
+ fig.savefig(os.path.join(summary_dir_path, filename))
3978
+ plt.close()
3979
+
3980
+ filename = define_filename(
3981
+ prefix="DSD_Params_Density_with_LWC_LogDm_MaxNormalized",
3982
+ extension="png",
3983
+ data_source=data_source,
3984
+ campaign_name=campaign_name,
3985
+ station_name=station_name,
3986
+ )
3987
+ fig = plot_dsd_params_density(df, log_dm=True, lwc=True, log_normalize=False)
3988
+ fig.savefig(os.path.join(summary_dir_path, filename))
3989
+ plt.close()
3990
+
3991
+ filename = define_filename(
3992
+ prefix="DSD_Params_Density_with_LWC_LinearDm_LogNormalized",
3993
+ extension="png",
3994
+ data_source=data_source,
3995
+ campaign_name=campaign_name,
3996
+ station_name=station_name,
3997
+ )
3998
+ fig = plot_dsd_params_density(df, log_dm=False, lwc=True, log_normalize=True)
3999
+ fig.savefig(os.path.join(summary_dir_path, filename))
4000
+ plt.close()
4001
+
4002
+ filename = define_filename(
4003
+ prefix="DSD_Params_Density_with_LWC_LogDm_LogNormalized",
4004
+ extension="png",
4005
+ data_source=data_source,
4006
+ campaign_name=campaign_name,
4007
+ station_name=station_name,
4008
+ )
4009
+ fig = plot_dsd_params_density(df, log_dm=True, lwc=True, log_normalize=True)
4010
+ fig.savefig(os.path.join(summary_dir_path, filename))
4011
+ plt.close()
4012
+
4013
+ ###------------------------------------------------------------------------.
4014
+ #### - Create DSD parameters density figures with R
4015
+ filename = define_filename(
4016
+ prefix="DSD_Params_Density_with_R_LinearDm_MaxNormalized",
4017
+ extension="png",
4018
+ data_source=data_source,
4019
+ campaign_name=campaign_name,
4020
+ station_name=station_name,
4021
+ )
4022
+ fig = plot_dsd_params_density(df, log_dm=False, lwc=False, log_normalize=False)
4023
+ fig.savefig(os.path.join(summary_dir_path, filename))
4024
+ plt.close()
4025
+
4026
+ filename = define_filename(
4027
+ prefix="DSD_Params_Density_with_R_LogDm_MaxNormalized",
4028
+ extension="png",
4029
+ data_source=data_source,
4030
+ campaign_name=campaign_name,
4031
+ station_name=station_name,
4032
+ )
4033
+ fig = plot_dsd_params_density(df, log_dm=True, lwc=False, log_normalize=False)
4034
+ fig.savefig(os.path.join(summary_dir_path, filename))
4035
+ plt.close()
4036
+
4037
+ filename = define_filename(
4038
+ prefix="DSD_Params_Density_with_R_LinearDm_LogNormalized",
4039
+ extension="png",
4040
+ data_source=data_source,
4041
+ campaign_name=campaign_name,
4042
+ station_name=station_name,
4043
+ )
4044
+ fig = plot_dsd_params_density(df, log_dm=False, lwc=False, log_normalize=True)
4045
+ fig.savefig(os.path.join(summary_dir_path, filename))
4046
+ plt.close()
4047
+
4048
+ filename = define_filename(
4049
+ prefix="DSD_Params_Density_with_R_LogDm_LogNormalized",
4050
+ extension="png",
4051
+ data_source=data_source,
4052
+ campaign_name=campaign_name,
4053
+ station_name=station_name,
4054
+ )
4055
+ fig = plot_dsd_params_density(df, log_dm=True, lwc=False, log_normalize=True)
4056
+ fig.savefig(os.path.join(summary_dir_path, filename))
4057
+ plt.close()
4058
+
4059
+ ###------------------------------------------------------------------------.
4060
+ #### - Create DSD parameters relationship figures
4061
+ filename = define_filename(
4062
+ prefix="DSD_Params_Relations",
4063
+ extension="png",
4064
+ data_source=data_source,
4065
+ campaign_name=campaign_name,
4066
+ station_name=station_name,
4067
+ )
4068
+ fig = plot_dsd_params_relationships(df, add_nt=True)
4069
+ fig.savefig(os.path.join(summary_dir_path, filename))
4070
+ plt.close()
4071
+
4072
+ ###------------------------------------------------------------------------.
4073
+ #### - Create Dmax relationship figures
4074
+ filename = define_filename(
4075
+ prefix="DSD_Dmax_Relations",
4076
+ extension="png",
4077
+ data_source=data_source,
4078
+ campaign_name=campaign_name,
4079
+ station_name=station_name,
4080
+ )
4081
+ fig = plot_dmax_relationships(df, diameter_bin_edges=diameter_bin_edges, dmax="Dmax", diameter_max=10)
4082
+ fig.savefig(os.path.join(summary_dir_path, filename))
4083
+ plt.close()
4084
+
4085
+ #### ---------------------------------------------------------------------.
4086
+ #### Create L2E QC summary plots
4087
+ # TODO:
4088
+
4089
+ ####------------------------------------------------------------------------.
4090
+ #### Free space - Remove df from memory
4091
+ del df
4092
+ gc.collect()
4093
+
4094
+ ####------------------------------------------------------------------------.
4095
+ #### Create N(D) densities
4096
+ df_nd = create_nd_dataframe(ds)
4097
+
4098
+ #### - Plot N(D) vs D
4099
+ filename = define_filename(
4100
+ prefix="N(D)",
4101
+ extension="png",
4102
+ data_source=data_source,
4103
+ campaign_name=campaign_name,
4104
+ station_name=station_name,
4105
+ )
4106
+ p = plot_dsd_density(df_nd, diameter_bin_edges=diameter_bin_edges)
4107
+ p.figure.savefig(os.path.join(summary_dir_path, filename))
4108
+ plt.close()
4109
+
4110
+ #### - Plot N(D)/Nw vs D/Dm
4111
+ filename = define_filename(
4112
+ prefix="N(D)_Normalized",
4113
+ extension="png",
4114
+ data_source=data_source,
4115
+ campaign_name=campaign_name,
4116
+ station_name=station_name,
4117
+ )
4118
+ p = plot_normalized_dsd_density(df_nd)
4119
+ p.figure.savefig(os.path.join(summary_dir_path, filename))
4120
+ plt.close()
4121
+
4122
+ #### Free space - Remove df_nd from memory
4123
+ del df_nd
4124
+ gc.collect()
4125
+
4126
+ #### - Plot N(D) vs D with DenseLines
4127
+ # Extract required variables and free memory
4128
+ drop_number_concentration = ds["drop_number_concentration"].compute().copy()
4129
+ r = ds["R"].compute().copy()
4130
+ del ds
4131
+ gc.collect()
4132
+
4133
+ # Create figure
4134
+ filename = define_filename(
4135
+ prefix="N(D)_DenseLines",
4136
+ extension="png",
4137
+ data_source=data_source,
4138
+ campaign_name=campaign_name,
4139
+ station_name=station_name,
4140
+ )
4141
+ p = plot_dsd_with_dense_lines(drop_number_concentration=drop_number_concentration, r=r)
4142
+ p.figure.savefig(os.path.join(summary_dir_path, filename))
4143
+ plt.close()
4144
+
4145
+
4146
+ ####------------------------------------------------------------------------.
4147
+ #### Wrappers
4148
+
4149
+
4150
+ def create_station_summary(
4151
+ data_source,
4152
+ campaign_name,
4153
+ station_name,
4154
+ parallel=False,
4155
+ data_archive_dir=None,
4156
+ ):
4157
+ """Create summary figures and tables for a DISDRODB station."""
4158
+ # Print processing info
4159
+ print(f"Creation of station summary for {data_source} {campaign_name} {station_name} has started.")
4160
+
4161
+ # Define station summary directory
4162
+ summary_dir_path = define_station_dir(
4163
+ product="SUMMARY",
4164
+ data_source=data_source,
4165
+ campaign_name=campaign_name,
4166
+ station_name=station_name,
4167
+ data_archive_dir=data_archive_dir,
4168
+ check_exists=False,
4169
+ )
4170
+ os.makedirs(summary_dir_path, exist_ok=True)
4171
+
4172
+ # Load L2E 1MIN dataset
4173
+ ds = disdrodb.open_dataset(
4174
+ data_archive_dir=data_archive_dir,
4175
+ data_source=data_source,
4176
+ campaign_name=campaign_name,
4177
+ station_name=station_name,
4178
+ product="L2E",
4179
+ product_kwargs={"rolling": False, "sample_interval": 60},
4180
+ parallel=parallel,
4181
+ chunks=-1,
4182
+ compute=True,
4183
+ )
4184
+
4185
+ # Generate station summary figures and table
4186
+ generate_station_summary(
4187
+ ds=ds,
4188
+ summary_dir_path=summary_dir_path,
4189
+ data_source=data_source,
4190
+ campaign_name=campaign_name,
4191
+ station_name=station_name,
4192
+ )
4193
+
4194
+ print(f"Creation of station summary for {data_source} {campaign_name} {station_name} has terminated.")
4195
+
4196
+ # -------------------------------------------------------------------------------------------------.