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