cloudnetpy 1.49.9__py3-none-any.whl → 1.87.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 (116) hide show
  1. cloudnetpy/categorize/__init__.py +1 -2
  2. cloudnetpy/categorize/atmos_utils.py +297 -67
  3. cloudnetpy/categorize/attenuation.py +31 -0
  4. cloudnetpy/categorize/attenuations/__init__.py +37 -0
  5. cloudnetpy/categorize/attenuations/gas_attenuation.py +30 -0
  6. cloudnetpy/categorize/attenuations/liquid_attenuation.py +84 -0
  7. cloudnetpy/categorize/attenuations/melting_attenuation.py +78 -0
  8. cloudnetpy/categorize/attenuations/rain_attenuation.py +84 -0
  9. cloudnetpy/categorize/categorize.py +332 -156
  10. cloudnetpy/categorize/classify.py +127 -125
  11. cloudnetpy/categorize/containers.py +107 -76
  12. cloudnetpy/categorize/disdrometer.py +40 -0
  13. cloudnetpy/categorize/droplet.py +23 -21
  14. cloudnetpy/categorize/falling.py +53 -24
  15. cloudnetpy/categorize/freezing.py +25 -12
  16. cloudnetpy/categorize/insects.py +35 -23
  17. cloudnetpy/categorize/itu.py +243 -0
  18. cloudnetpy/categorize/lidar.py +36 -41
  19. cloudnetpy/categorize/melting.py +34 -26
  20. cloudnetpy/categorize/model.py +84 -37
  21. cloudnetpy/categorize/mwr.py +18 -14
  22. cloudnetpy/categorize/radar.py +215 -102
  23. cloudnetpy/cli.py +578 -0
  24. cloudnetpy/cloudnetarray.py +43 -89
  25. cloudnetpy/concat_lib.py +218 -78
  26. cloudnetpy/constants.py +28 -10
  27. cloudnetpy/datasource.py +61 -86
  28. cloudnetpy/exceptions.py +49 -20
  29. cloudnetpy/instruments/__init__.py +5 -0
  30. cloudnetpy/instruments/basta.py +29 -12
  31. cloudnetpy/instruments/bowtie.py +135 -0
  32. cloudnetpy/instruments/ceilo.py +138 -115
  33. cloudnetpy/instruments/ceilometer.py +164 -80
  34. cloudnetpy/instruments/cl61d.py +21 -5
  35. cloudnetpy/instruments/cloudnet_instrument.py +74 -36
  36. cloudnetpy/instruments/copernicus.py +108 -30
  37. cloudnetpy/instruments/da10.py +54 -0
  38. cloudnetpy/instruments/disdrometer/common.py +126 -223
  39. cloudnetpy/instruments/disdrometer/parsivel.py +453 -94
  40. cloudnetpy/instruments/disdrometer/thies.py +254 -87
  41. cloudnetpy/instruments/fd12p.py +201 -0
  42. cloudnetpy/instruments/galileo.py +65 -23
  43. cloudnetpy/instruments/hatpro.py +123 -49
  44. cloudnetpy/instruments/instruments.py +113 -1
  45. cloudnetpy/instruments/lufft.py +39 -17
  46. cloudnetpy/instruments/mira.py +268 -61
  47. cloudnetpy/instruments/mrr.py +187 -0
  48. cloudnetpy/instruments/nc_lidar.py +19 -8
  49. cloudnetpy/instruments/nc_radar.py +109 -55
  50. cloudnetpy/instruments/pollyxt.py +135 -51
  51. cloudnetpy/instruments/radiometrics.py +313 -59
  52. cloudnetpy/instruments/rain_e_h3.py +171 -0
  53. cloudnetpy/instruments/rpg.py +321 -189
  54. cloudnetpy/instruments/rpg_reader.py +74 -40
  55. cloudnetpy/instruments/toa5.py +49 -0
  56. cloudnetpy/instruments/vaisala.py +95 -343
  57. cloudnetpy/instruments/weather_station.py +774 -105
  58. cloudnetpy/metadata.py +90 -19
  59. cloudnetpy/model_evaluation/file_handler.py +55 -52
  60. cloudnetpy/model_evaluation/metadata.py +46 -20
  61. cloudnetpy/model_evaluation/model_metadata.py +1 -1
  62. cloudnetpy/model_evaluation/plotting/plot_tools.py +32 -37
  63. cloudnetpy/model_evaluation/plotting/plotting.py +327 -117
  64. cloudnetpy/model_evaluation/products/advance_methods.py +92 -83
  65. cloudnetpy/model_evaluation/products/grid_methods.py +88 -63
  66. cloudnetpy/model_evaluation/products/model_products.py +43 -35
  67. cloudnetpy/model_evaluation/products/observation_products.py +41 -35
  68. cloudnetpy/model_evaluation/products/product_resampling.py +17 -7
  69. cloudnetpy/model_evaluation/products/tools.py +29 -20
  70. cloudnetpy/model_evaluation/statistics/statistical_methods.py +30 -20
  71. cloudnetpy/model_evaluation/tests/e2e/conftest.py +3 -3
  72. cloudnetpy/model_evaluation/tests/e2e/process_cf/main.py +9 -5
  73. cloudnetpy/model_evaluation/tests/e2e/process_cf/tests.py +15 -14
  74. cloudnetpy/model_evaluation/tests/e2e/process_iwc/main.py +9 -5
  75. cloudnetpy/model_evaluation/tests/e2e/process_iwc/tests.py +15 -14
  76. cloudnetpy/model_evaluation/tests/e2e/process_lwc/main.py +9 -5
  77. cloudnetpy/model_evaluation/tests/e2e/process_lwc/tests.py +15 -14
  78. cloudnetpy/model_evaluation/tests/unit/conftest.py +42 -41
  79. cloudnetpy/model_evaluation/tests/unit/test_advance_methods.py +41 -48
  80. cloudnetpy/model_evaluation/tests/unit/test_grid_methods.py +216 -194
  81. cloudnetpy/model_evaluation/tests/unit/test_model_products.py +23 -21
  82. cloudnetpy/model_evaluation/tests/unit/test_observation_products.py +37 -38
  83. cloudnetpy/model_evaluation/tests/unit/test_plot_tools.py +43 -40
  84. cloudnetpy/model_evaluation/tests/unit/test_plotting.py +30 -36
  85. cloudnetpy/model_evaluation/tests/unit/test_statistical_methods.py +68 -31
  86. cloudnetpy/model_evaluation/tests/unit/test_tools.py +33 -26
  87. cloudnetpy/model_evaluation/utils.py +2 -1
  88. cloudnetpy/output.py +170 -111
  89. cloudnetpy/plotting/__init__.py +2 -1
  90. cloudnetpy/plotting/plot_meta.py +562 -822
  91. cloudnetpy/plotting/plotting.py +1142 -704
  92. cloudnetpy/products/__init__.py +1 -0
  93. cloudnetpy/products/classification.py +370 -88
  94. cloudnetpy/products/der.py +85 -55
  95. cloudnetpy/products/drizzle.py +77 -34
  96. cloudnetpy/products/drizzle_error.py +15 -11
  97. cloudnetpy/products/drizzle_tools.py +79 -59
  98. cloudnetpy/products/epsilon.py +211 -0
  99. cloudnetpy/products/ier.py +27 -50
  100. cloudnetpy/products/iwc.py +55 -48
  101. cloudnetpy/products/lwc.py +96 -70
  102. cloudnetpy/products/mwr_tools.py +186 -0
  103. cloudnetpy/products/product_tools.py +170 -128
  104. cloudnetpy/utils.py +455 -240
  105. cloudnetpy/version.py +2 -2
  106. {cloudnetpy-1.49.9.dist-info → cloudnetpy-1.87.3.dist-info}/METADATA +44 -40
  107. cloudnetpy-1.87.3.dist-info/RECORD +127 -0
  108. {cloudnetpy-1.49.9.dist-info → cloudnetpy-1.87.3.dist-info}/WHEEL +1 -1
  109. cloudnetpy-1.87.3.dist-info/entry_points.txt +2 -0
  110. docs/source/conf.py +2 -2
  111. cloudnetpy/categorize/atmos.py +0 -361
  112. cloudnetpy/products/mwr_multi.py +0 -68
  113. cloudnetpy/products/mwr_single.py +0 -75
  114. cloudnetpy-1.49.9.dist-info/RECORD +0 -112
  115. {cloudnetpy-1.49.9.dist-info → cloudnetpy-1.87.3.dist-info/licenses}/LICENSE +0 -0
  116. {cloudnetpy-1.49.9.dist-info → cloudnetpy-1.87.3.dist-info}/top_level.txt +0 -0
@@ -1,39 +1,100 @@
1
1
  """Misc. plotting routines for Cloudnet products."""
2
- import os.path
3
- from datetime import date
2
+
3
+ import re
4
+ import textwrap
5
+ from dataclasses import dataclass
6
+ from datetime import date, datetime, timedelta, timezone
7
+ from os import PathLike
8
+ from typing import Any
4
9
 
5
10
  import matplotlib.pyplot as plt
6
11
  import netCDF4
7
12
  import numpy as np
13
+ import numpy.typing as npt
8
14
  from matplotlib import rcParams
15
+ from matplotlib.axes import Axes
16
+ from matplotlib.colorbar import Colorbar
17
+ from matplotlib.colorizer import ColorizingArtist
9
18
  from matplotlib.colors import ListedColormap
19
+ from matplotlib.pyplot import Figure
20
+ from matplotlib.ticker import AutoMinorLocator
10
21
  from matplotlib.transforms import Affine2D, Bbox
11
22
  from mpl_toolkits.axes_grid1 import make_axes_locatable
12
23
  from numpy import ma, ndarray
13
- from scipy.signal import filtfilt
24
+ from scipy.ndimage import uniform_filter
25
+
26
+ from cloudnetpy import constants as con
27
+ from cloudnetpy.categorize.atmos_utils import calc_altitude
28
+ from cloudnetpy.exceptions import PlottingError
29
+ from cloudnetpy.instruments.ceilometer import calc_sigma_units
30
+ from cloudnetpy.plotting.plot_meta import ATTRIBUTES, PlotMeta
31
+ from cloudnetpy.products.classification import TopStatus
32
+
33
+ EARTHCARE_MAX_X = 517.84
34
+
35
+
36
+ @dataclass
37
+ class PlotParameters:
38
+ """Class representing the parameters for plotting.
39
+
40
+ Attributes:
41
+ dpi: The resolution of the plot in dots per inch.
42
+ max_y: Maximum y-axis value (km) in 2D time / height plots.
43
+ title: Whether to display the title of the plot.
44
+ subtitle: Whether to display the subtitle of the plot.
45
+ mark_data_gaps: Whether to mark data gaps in the plot.
46
+ grid: Whether to display grid lines in the plot.
47
+ edge_tick_labels: Whether to display tick labels on the edges of the plot.
48
+ show_sources: Whether to display the sources of plotted data (i.e.
49
+ instruments and model).
50
+ footer_text: The text to display in the footer of the plot.
51
+ plot_meta: Additional metadata for the plot.
52
+ raise_on_empty: Whether to raise an error if no data is found for a
53
+ plotted variable.
54
+ minor_ticks: Whether to display minor ticks on the x-axis.
55
+ plot_above_ground: Whether to plot above ground instead of above mean sea level.
56
+
57
+ """
14
58
 
15
- import cloudnetpy.products.product_tools as ptools
16
- from cloudnetpy import utils
17
- from cloudnetpy.plotting.plot_meta import ATTRIBUTES, Scale
18
- from cloudnetpy.products.product_tools import CategorizeBits
59
+ dpi: float = 120
60
+ max_y: int = 12
61
+ title: bool = True
62
+ subtitle: bool = True
63
+ mark_data_gaps: bool = True
64
+ grid: bool = False
65
+ edge_tick_labels: bool = False
66
+ show_sources: bool = False
67
+ footer_text: str | None = None
68
+ plot_meta: PlotMeta | None = None
69
+ raise_on_empty: bool = False
70
+ minor_ticks: bool = True
71
+ plot_above_ground: bool = True
19
72
 
20
73
 
21
74
  class Dimensions:
22
- """Dimensions of a generated figure in pixels."""
23
-
24
- width: int
25
- height: int
26
- margin_top: int
27
- margin_right: int
28
- margin_bottom: int
29
- margin_left: int
75
+ """Dimensions of a generated figure in pixels. Elements such as the figure
76
+ title, labels, colorbar and legend are excluded from the margins.
77
+
78
+ Attributes:
79
+ width (int): Figure width in pixels.
80
+ height (int): Figure height in pixels.
81
+ margin_top (int): Space between top edge of image and plotted data in pixels.
82
+ margin_right (int): Space between right edge of image and plotted data
83
+ in pixels.
84
+ margin_bottom (int): Space between bottom edge of image and plotted
85
+ data in pixels.
86
+ margin_left (int): Space between left edge of image and plotted data in pixels.
87
+ """
30
88
 
31
- def __init__(self, fig, axes, pad_inches: float | None = None):
89
+ def __init__(
90
+ self, fig: Figure, axes: list[Axes], pad_inches: float | None = None
91
+ ) -> None:
32
92
  if pad_inches is None:
33
93
  pad_inches = rcParams["savefig.pad_inches"]
34
94
 
95
+ renderer = fig.canvas.get_renderer() # type: ignore[attr-defined]
35
96
  tightbbox = (
36
- fig.get_tightbbox(fig.canvas.get_renderer())
97
+ fig.get_tightbbox(renderer)
37
98
  .padded(pad_inches)
38
99
  .transformed(Affine2D().scale(fig.dpi))
39
100
  )
@@ -43,778 +104,1155 @@ class Dimensions:
43
104
  x0, y0, x1, y1 = (
44
105
  Bbox.union([ax.get_window_extent() for ax in axes])
45
106
  .translated(-tightbbox.x0, -tightbbox.y0)
46
- .extents.round()
107
+ .extents
108
+ )
109
+ self.margin_top = self.height - round(y1)
110
+ self.margin_right = self.width - round(x1) - 1
111
+ self.margin_bottom = round(y0) - 1
112
+ self.margin_left = round(x0)
113
+
114
+
115
+ class FigureData:
116
+ def __init__(
117
+ self,
118
+ file: netCDF4.Dataset,
119
+ requested_variables: list[str],
120
+ options: PlotParameters,
121
+ ) -> None:
122
+ self.file = file
123
+ self.variables, self.indices = self._get_valid_variables_and_indices(
124
+ requested_variables
125
+ )
126
+ self.options = options
127
+ self.file_type = getattr(self.file, "cloudnet_file_type", "")
128
+ self.height = self._get_height()
129
+ self.time = self._get_time()
130
+ self.time_including_gaps = np.array([])
131
+
132
+ def initialize_figure(self) -> tuple[Figure, list[Axes]]:
133
+ n_subplots = len(self)
134
+ fig, axes = plt.subplots(
135
+ n_subplots,
136
+ 1,
137
+ figsize=(16, 4 + (n_subplots - 1) * 4.8),
138
+ dpi=self.options.dpi,
139
+ sharex=True,
140
+ )
141
+ fig.subplots_adjust(left=0.06, right=0.73)
142
+ axes_list = [axes] if isinstance(axes, Axes) else axes.tolist()
143
+ return fig, axes_list
144
+
145
+ def add_subtitle(self, fig: Figure) -> None:
146
+ fig.suptitle(
147
+ self._get_subtitle_text(),
148
+ fontsize=13,
149
+ y=0.885,
150
+ x=0.07,
151
+ horizontalalignment="left",
152
+ verticalalignment="bottom",
47
153
  )
48
- self.margin_top = int(self.height - y1)
49
- self.margin_right = int(self.width - x1 - 1)
50
- self.margin_bottom = int(y0 - 1)
51
- self.margin_left = int(x0)
52
-
53
-
54
- def generate_figure(
55
- nc_file: str,
56
- field_names: list,
57
- show: bool = True,
58
- save_path: str | None = None,
59
- max_y: int = 12,
60
- dpi: int = 120,
61
- image_name: str | None = None,
62
- sub_title: bool = True,
63
- title: bool = True,
64
- ) -> Dimensions:
65
- """Generates a Cloudnet figure.
66
154
 
67
- Args:
68
- nc_file (str): Input file.
69
- field_names (list): Variable names to be plotted.
70
- show (bool, optional): If True, shows the figure. Default is True.
71
- save_path (str, optional): Setting this path will save the figure (in the
72
- given path). Default is None, when the figure is not saved.
73
- max_y (int, optional): Upper limit in the plots (km). Default is 12.
74
- dpi (int, optional): Figure quality (if saved). Higher value means
75
- more pixels, i.e., better image quality. Default is 120.
76
- image_name (str, optional): Name (and full path) of the output image.
77
- Overrides the *save_path* option. Default is None.
78
- sub_title (bool, optional): Add subtitle to image. Default is True.
79
- title (bool, optional): Add title to image. Default is True.
155
+ def datetime(self) -> datetime:
156
+ return datetime(
157
+ int(self.file.year),
158
+ int(self.file.month),
159
+ int(self.file.day),
160
+ tzinfo=timezone.utc,
161
+ )
80
162
 
81
- Returns:
82
- Dimensions of the generated figure in pixels.
83
-
84
- Examples:
85
- >>> from cloudnetpy.plotting import generate_figure
86
- >>> generate_figure('categorize_file.nc', ['Z', 'v', 'width', 'ldr',
87
- 'beta', 'lwp'])
88
- >>> generate_figure('iwc_file.nc', ['iwc', 'iwc_error',
89
- 'iwc_retrieval_status'])
90
- >>> generate_figure('lwc_file.nc', ['lwc', 'lwc_error',
91
- 'lwc_retrieval_status'], max_y=4)
92
- >>> generate_figure('classification_file.nc', ['target_classification',
93
- 'detection_status'])
94
- >>> generate_figure('drizzle_file.nc', ['Do', 'mu', 'S'], max_y=3)
95
- >>> generate_figure('ier.nc', ['ier', 'ier_error', 'ier_retrieval_status'],
96
- max_y=3)
97
- >>> generate_figure('der.nc', ['der', 'der_scaled'], max_y=12)
98
- """
99
- indices = [name.split("_")[-1] for name in field_names]
100
- with netCDF4.Dataset(nc_file) as nc:
101
- cloudnet_file_type = nc.cloudnet_file_type
102
- if cloudnet_file_type == "mwr-l1c":
103
- field_names = [name.split("_")[0] for name in field_names]
104
- valid_fields, valid_names = _find_valid_fields(nc_file, field_names)
105
- is_height = _is_height_dimension(nc_file)
106
- fig, axes = _initialize_figure(len(valid_fields), dpi)
107
-
108
- for ax, field, name, tb_ind in zip(axes, valid_fields, valid_names, indices):
109
- plot_type = ATTRIBUTES[name].plot_type
110
- if title:
111
- _set_title(ax, name, "")
112
- if not is_height or (
113
- cloudnet_file_type == "mwr-single" and name in ("lwp", "iwv")
163
+ def _get_subtitle_text(self) -> str:
164
+ measurement_date = date(
165
+ int(self.file.year), int(self.file.month), int(self.file.day)
166
+ )
167
+ site_name = self.file.location.replace("-", " ")
168
+ return f"{site_name}, {measurement_date.strftime('%d %b %Y').lstrip('0')}"
169
+
170
+ def _get_valid_variables_and_indices(
171
+ self, requested_variables: list[str]
172
+ ) -> tuple[list[netCDF4.Variable], list[int | None]]:
173
+ valid_variables = []
174
+ variable_indices = []
175
+ for variable_name in requested_variables:
176
+ if variable_name.startswith(("tb_", "irt_")):
177
+ parts = variable_name.split("_")
178
+ extracted_name = parts[0]
179
+ extracted_ind = int(parts[1])
180
+ else:
181
+ extracted_name = variable_name
182
+ extracted_ind = None
183
+ if extracted_name in self.file.variables:
184
+ valid_variables.append(self.file.variables[extracted_name])
185
+ variable_indices.append(extracted_ind)
186
+ if not valid_variables:
187
+ msg = f"None of the variables {requested_variables} found in the file."
188
+ raise PlottingError(msg)
189
+ return valid_variables, variable_indices
190
+
191
+ def _get_time(self) -> ndarray:
192
+ variable_names = [f.name for f in self.variables]
193
+ if self.file_type == "cpr-simulation":
194
+ x_data = self.file.variables["along_track_sat"][:] * con.M_TO_KM
195
+ elif (
196
+ self.file_type == "cpr-validation"
197
+ and ("echo_cpr" in variable_names or "v_cpr" in variable_names)
198
+ ) or (
199
+ self.file_type == "cpr-tc-validation"
200
+ and ("target_classification_cpr" in variable_names)
114
201
  ):
115
- unit = _get_variable_unit(nc_file, name)
116
- source = ATTRIBUTES[name].source
117
- time = _read_time_vector(nc_file)
118
- try:
119
- tb_ind = int(tb_ind)
120
- except ValueError:
121
- tb_ind = None
122
- _plot_instrument_data(ax, field, name, source, time, unit, nc_file, tb_ind)
123
- continue
124
- ax_value = _read_ax_values(nc_file)
125
-
126
- if plot_type not in ("bar", "model"):
127
- time_new, field = _mark_gaps(ax_value[0], field)
128
- ax_value = (time_new, ax_value[1])
129
-
130
- field, ax_value = _screen_high_altitudes(field, ax_value, max_y)
131
- set_ax(ax, max_y, ylabel=None)
132
- if plot_type == "bar":
133
- unit = _get_variable_unit(nc_file, name)
134
- _plot_bar_data(ax, field, ax_value[0], unit)
135
- set_ax(ax, 2, ATTRIBUTES[name].ylabel)
136
-
137
- elif plot_type == "segment":
138
- _plot_segment_data(ax, field, name, ax_value)
139
-
202
+ x_data = self.file.variables["time_cpr"][:]
140
203
  else:
141
- _plot_colormesh_data(ax, field, name, ax_value)
142
- case_date = set_labels(fig, axes[-1], nc_file, sub_title)
143
- handle_saving(image_name, save_path, show, case_date, valid_names)
144
- return Dimensions(fig, axes)
145
-
146
-
147
- def _mark_gaps(
148
- time: np.ndarray, data: ma.MaskedArray, max_allowed_gap: float = 1
149
- ) -> tuple:
150
- assert time[0] >= 0
151
- assert time[-1] <= 24
152
- max_gap = max_allowed_gap / 60
153
- if not ma.is_masked(data):
154
- mask_new = np.zeros(data.shape)
155
- elif ma.all(data.mask) is ma.masked:
156
- mask_new = np.ones(data.shape)
157
- else:
158
- mask_new = np.copy(data.mask)
159
- data_new = ma.copy(data)
160
- time_new = np.copy(time)
161
- gap_indices = np.where(np.diff(time) > max_gap)[0]
162
- temp_array = np.zeros((2, data.shape[1]))
163
- temp_mask = np.ones((2, data.shape[1]))
164
- time_delta = 0.001
165
- for ind in np.sort(gap_indices)[::-1]:
166
- ind += 1
167
- data_new = np.insert(data_new, ind, temp_array, axis=0)
168
- mask_new = np.insert(mask_new, ind, temp_mask, axis=0)
169
- time_new = np.insert(time_new, ind, time[ind] - time_delta)
170
- time_new = np.insert(time_new, ind, time[ind - 1] + time_delta)
171
- if (time[0] - 0) > max_gap:
172
- data_new = np.insert(data_new, 0, temp_array, axis=0)
173
- mask_new = np.insert(mask_new, 0, temp_mask, axis=0)
174
- time_new = np.insert(time_new, 0, time[0] - time_delta)
175
- time_new = np.insert(time_new, 0, time_delta)
176
- if (24 - time[-1]) > max_gap:
177
- ind = mask_new.shape[0]
178
- data_new = np.insert(data_new, ind, temp_array, axis=0)
179
- mask_new = np.insert(mask_new, ind, temp_mask, axis=0)
180
- time_new = np.insert(time_new, ind, 24 - time_delta)
181
- time_new = np.insert(time_new, ind, time[-1] + time_delta)
182
- data_new.mask = mask_new
183
- return time_new, data_new
184
-
185
-
186
- def handle_saving(
187
- image_name: str | None,
188
- save_path: str | None,
189
- show: bool,
190
- case_date: date,
191
- field_names: list,
192
- fix: str = "",
193
- ):
194
- if image_name:
195
- plt.savefig(image_name, bbox_inches="tight")
196
- elif save_path:
197
- file_name = _create_save_name(save_path, case_date, field_names, fix)
198
- plt.savefig(file_name, bbox_inches="tight")
199
- if show:
200
- plt.show()
201
- plt.close()
202
-
203
-
204
- def _get_relative_error(fields: list, ax_values: list, max_y: int) -> tuple:
205
- x, y = ax_values[0]
206
- x_new, y_new = ax_values[1]
207
- old_data_interp = utils.interpolate_2d_mask(x, y, fields[0], x_new, y_new)
208
- error = utils.calc_relative_error(old_data_interp, fields[1])
209
- return _screen_high_altitudes(error, ax_values[1], max_y)
210
-
204
+ x_data = self.file.variables["time"][:]
205
+ return x_data
206
+
207
+ def _get_height(self) -> ndarray | None:
208
+ if self.file_type == "cpr-simulation":
209
+ height = self.file.variables["height_sat"][:]
210
+ if self.options.plot_above_ground:
211
+ height -= ma.median(self.file.variables["altitude"][:])
212
+ return height * con.M_TO_KM
213
+ if self.file_type == "model":
214
+ height = ma.mean(self.file.variables["height"][:], axis=0) # height AGL
215
+ if not self.options.plot_above_ground:
216
+ site_alt = self._calc_ground_altitude()
217
+ height += site_alt
218
+ return height * con.M_TO_KM
219
+ if "height" in self.file.variables:
220
+ height = self.file.variables["height"][:] # height AMSL
221
+ if self.options.plot_above_ground:
222
+ if "altitude" not in self.file.variables:
223
+ msg = "No altitude information in the file."
224
+ raise ValueError(msg)
225
+ height -= ma.median(self.file.variables["altitude"][:])
226
+ return height * con.M_TO_KM
227
+ if "range" in self.file.variables:
228
+ return self.file.variables["range"][:] * con.M_TO_KM
229
+ if "diameter" in self.file.variables:
230
+ return self.file.variables["diameter"][:] * con.M_TO_MM
231
+ return None
232
+
233
+ def _calc_ground_altitude(self) -> float:
234
+ if (
235
+ "sfc_geopotential" in self.file.variables
236
+ and "gdas1" not in self.file.source.lower() # uncertain unit in gdas1
237
+ ):
238
+ return np.mean(self.file.variables["sfc_geopotential"][:]) / con.G
239
+ pressure = ma.mean(self.file.variables["pressure"][:, 0])
240
+ temperature = ma.mean(self.file.variables["temperature"][:, 0])
241
+ return calc_altitude(temperature, pressure)
242
+
243
+ def is_mwrpy_product(self) -> bool:
244
+ return self.file_type in ("mwr-single", "mwr-multi")
245
+
246
+ def __len__(self) -> int:
247
+ return len(self.variables)
248
+
249
+
250
+ class SubPlot:
251
+ def __init__(
252
+ self,
253
+ ax: Axes,
254
+ variable: netCDF4.Variable,
255
+ options: PlotParameters,
256
+ file_type: str | None,
257
+ ) -> None:
258
+ self.ax = ax
259
+ self.variable = variable
260
+ self.options = options
261
+ self.file_type = file_type
262
+ self.plot_meta = self._read_plot_meta()
263
+
264
+ def set_xax(self, figure_data: FigureData) -> None:
265
+ if self.file_type == "cpr-simulation":
266
+ self.ax.set_xlim(0, EARTHCARE_MAX_X) # km
267
+ return
268
+ if (
269
+ self.file_type == "cpr-validation"
270
+ and self.variable.name != "cloud_top_height"
271
+ ) or self.file_type == "cpr-tc-validation":
272
+ time = np.array(figure_data.time, dtype=float)
273
+ self.ax.set_xlim(min(time), max(time))
274
+ self.ax.set_xlabel("Time (UTC)", fontsize=13)
275
+ ticks = np.linspace(min(time), max(time), 8)[1:-1]
276
+ tick_dts = [figure_data.datetime() + timedelta(hours=h) for h in ticks]
277
+ tick_labels = [dt.strftime("%H:%M:%S") for dt in tick_dts]
278
+ self.ax.set_xticks(ticks)
279
+ self.ax.set_xticklabels(tick_labels, fontsize=12)
280
+ self.ax.tick_params(axis="both", which="major", labelsize=11)
281
+ if self.file_type in ("cpr-validation", "cpr-tc-validation"):
282
+ return
283
+ resolution = 4
284
+ x_tick_labels = [
285
+ f"{int(i):02d}:00"
286
+ if (24 >= i >= 0 if self.options.edge_tick_labels else 24 > i > 0)
287
+ else ""
288
+ for i in np.arange(0, 24.01, resolution)
289
+ ]
290
+ self.ax.set_xticks(np.arange(0, 25, resolution, dtype=int))
291
+ self.ax.set_xticklabels(x_tick_labels, fontsize=12)
292
+ if self.options.minor_ticks:
293
+ self.ax.xaxis.set_minor_locator(AutoMinorLocator(4))
294
+ self.ax.tick_params(which="minor", length=2.5)
295
+ self.ax.set_xlim(0, 24)
296
+
297
+ def set_yax(
298
+ self,
299
+ ylabel: str | None = None,
300
+ y_limits: tuple[float, float] | None = None,
301
+ ) -> None:
302
+ height_str = (
303
+ "Height (km AGL)" if self.options.plot_above_ground else "Height (km AMSL)"
304
+ )
211
305
 
212
- def set_labels(fig, ax, nc_file: str, sub_title: bool = True) -> date:
213
- ax.set_xlabel("Time (UTC)", fontsize=13)
214
- case_date = read_date(nc_file)
215
- site_name = read_location(nc_file)
216
- if sub_title:
217
- add_subtitle(fig, case_date, site_name)
218
- return case_date
306
+ label = ylabel if ylabel is not None else height_str
307
+ self.ax.set_ylabel(label, fontsize=13)
308
+ if y_limits is not None:
309
+ self.ax.set_ylim(*y_limits)
310
+
311
+ def add_title(self, ind: int | None) -> None:
312
+ title = self.variable.long_name
313
+ if self.variable.name == "tb" and ind is not None:
314
+ title += f" (channel {ind + 1})"
315
+ self.ax.set_title(title, fontsize=14)
316
+
317
+ def add_grid(self) -> None:
318
+ zorder = _get_zorder("grid")
319
+ self.ax.xaxis.set_minor_locator(AutoMinorLocator(4))
320
+ self.ax.grid(which="major", axis="x", color="k", lw=0.1, zorder=zorder)
321
+ self.ax.grid(which="minor", axis="x", lw=0.1, color="k", ls=":", zorder=zorder)
322
+ self.ax.grid(which="major", axis="y", lw=0.1, color="k", ls=":", zorder=zorder)
323
+
324
+ def add_sources(self, figure_data: FigureData) -> None:
325
+ source = getattr(self.variable, "source", None) or (
326
+ figure_data.file.source if "source" in figure_data.file.ncattrs() else None
327
+ )
328
+ if source is not None:
329
+ source_word = "sources" if "\n" in source else "source"
330
+ text = f"Data {source_word}:\n{source}"
331
+ self.ax.text(
332
+ 0.012,
333
+ 0.96,
334
+ text,
335
+ ha="left",
336
+ va="top",
337
+ fontsize=7,
338
+ transform=self.ax.transAxes,
339
+ bbox={
340
+ "facecolor": "white",
341
+ "alpha": 0.8,
342
+ "edgecolor": "grey",
343
+ "boxstyle": "round",
344
+ "linewidth": 0.5,
345
+ },
346
+ )
219
347
 
348
+ def set_xlabel(self) -> None:
349
+ if self.file_type == "cpr-validation":
350
+ return
351
+ label = (
352
+ "Distance along track (km)"
353
+ if self.file_type == "cpr-simulation"
354
+ else "Time (UTC)"
355
+ )
356
+ self.ax.set_xlabel(label, fontsize=13)
357
+
358
+ def show_footer(self, fig: Figure, ax: Axes) -> None:
359
+ if isinstance(self.options.footer_text, str):
360
+ n = 50
361
+ if len(self.options.footer_text) > n:
362
+ wrapped_text = textwrap.fill(self.options.footer_text, n)
363
+ self.options.footer_text = "\n".join(wrapped_text.splitlines())
364
+
365
+ n_lines = self.options.footer_text.count("\n") + 1
366
+ y0 = ax.get_position().y0
367
+ y1 = ax.get_position().y1
368
+ y = (y1 - y0) * (n_lines * 0.06 + 0.1)
369
+ fig.text(
370
+ 0.06,
371
+ y0 - y,
372
+ self.options.footer_text,
373
+ fontsize=11,
374
+ ha="left",
375
+ va="bottom",
376
+ )
220
377
 
221
- def _set_title(ax, field_name: str, identifier: str = " from CloudnetPy"):
222
- ax.set_title(f"{ATTRIBUTES[field_name].name}{identifier}", fontsize=14)
378
+ def _read_plot_meta(self) -> PlotMeta:
379
+ if self.options.plot_meta is not None:
380
+ plot_meta = self.options.plot_meta
381
+ else:
382
+ fallback = ATTRIBUTES["fallback"].get(self.variable.name, PlotMeta())
383
+ file_attributes = ATTRIBUTES.get(self.file_type or "", {})
384
+ plot_meta = file_attributes.get(self.variable.name, fallback)
385
+ if plot_meta.clabel is None:
386
+ plot_meta = plot_meta._replace(clabel=_reformat_units(self.variable.units))
387
+ return plot_meta
388
+
389
+
390
+ class Plot:
391
+ def __init__(self, sub_plot: SubPlot) -> None:
392
+ self.sub_plot = sub_plot
393
+ self._data = sub_plot.variable[:]
394
+ self._data_orig = self._data.copy()
395
+ self._plot_meta = sub_plot.plot_meta
396
+ self._is_log = sub_plot.plot_meta.log_scale
397
+ self._ax = sub_plot.ax
398
+
399
+ def _mask_zeros(self) -> None:
400
+ self._data = ma.masked_where(self._data == 0, self._data)
401
+ self._data_orig = ma.masked_where(self._data_orig == 0, self._data_orig)
402
+
403
+ def _convert_units(self) -> str:
404
+ multiply, add = "multiply", "add"
405
+ units_conversion = {
406
+ "rainfall_rate": (multiply, 3600000, "mm h$^{-1}$"),
407
+ "snowfall_rate": (multiply, 3600000, "mm h$^{-1}$"),
408
+ "precipitation_rate": (multiply, 3600000, "mm h$^{-1}$"),
409
+ "air_pressure": (multiply, 0.01, "hPa"),
410
+ "relative_humidity": (multiply, 100, "%"),
411
+ "rainfall_amount": (multiply, 1000, "mm"),
412
+ "snowfall_amount": (multiply, 1000, "mm"),
413
+ "precipitation_amount": (multiply, 1000, "mm"),
414
+ "air_temperature": (add, -273.15, "\u00b0C"),
415
+ "r_accum_RT": (multiply, 1000, "mm"),
416
+ "r_accum_NRT": (multiply, 1000, "mm"),
417
+ "cloud_top_height_agl": (multiply, con.M_TO_KM, "Height (km AGL)"),
418
+ }
419
+ conversion_method, conversion, units = units_conversion.get(
420
+ self.sub_plot.variable.name, (multiply, 1, None)
421
+ )
422
+ if conversion_method == multiply:
423
+ self._data *= conversion
424
+ self._data_orig *= conversion
425
+ elif conversion_method == add:
426
+ self._data += conversion
427
+ self._data_orig += conversion
428
+ if units is not None:
429
+ self._plot_meta = self._plot_meta._replace(clabel=units)
430
+ return units
431
+ units = getattr(self.sub_plot.variable, "units", "")
432
+ return _reformat_units(units)
433
+
434
+ def _get_y_limits(self) -> tuple[float, float]:
435
+ return 0, self.sub_plot.options.max_y
436
+
437
+ def _init_colorbar(self, plot: ColorizingArtist) -> Colorbar:
438
+ divider = make_axes_locatable(self._ax)
439
+ cax = divider.append_axes("right", size="1%", pad=0.25)
440
+ return plt.colorbar(plot, fraction=1.0, ax=self._ax, cax=cax)
441
+
442
+ def _fill_between_data_gaps(self, figure_data: FigureData) -> None:
443
+ gap_times = list(set(figure_data.time_including_gaps) - set(figure_data.time))
444
+ gap_times.sort()
445
+ batches = [gap_times[i : i + 2] for i in range(0, len(gap_times), 2)]
446
+ for batch in batches:
447
+ self._ax.fill_between(
448
+ batch,
449
+ *self._get_y_limits(),
450
+ hatch="//",
451
+ facecolor="whitesmoke",
452
+ edgecolor="lightgrey",
453
+ label="_nolegend_",
454
+ zorder=_get_zorder("data_gap"),
455
+ )
223
456
 
457
+ def _mark_gaps(
458
+ self, figure_data: FigureData, min_x: float = 0, max_x: float = 24
459
+ ) -> None:
460
+ time = figure_data.time
224
461
 
225
- def _find_valid_fields(nc_file: str, names: list) -> tuple[list, list]:
226
- """Returns valid field names and corresponding data."""
227
- valid_names, valid_data = names[:], []
228
- try:
229
- bits = CategorizeBits(nc_file)
230
- except KeyError:
231
- bits = None
232
- with netCDF4.Dataset(nc_file) as nc:
233
- for name in names:
234
- if name in nc.variables:
235
- valid_data.append(nc.variables[name][:])
236
- elif bits and name in CategorizeBits.category_keys:
237
- valid_data.append(bits.category_bits[name])
238
- elif bits and name in CategorizeBits.quality_keys:
239
- valid_data.append(bits.quality_bits[name])
240
- else:
241
- valid_names.remove(name)
242
- if not valid_names:
243
- raise ValueError("No fields to be plotted")
244
- return valid_data, valid_names
245
-
246
-
247
- def _is_height_dimension(full_path: str) -> bool:
248
- with netCDF4.Dataset(full_path) as nc:
249
- is_height = any(key in nc.variables for key in ("height", "range"))
250
- return is_height
251
-
252
-
253
- def _get_variable_unit(full_path: str, name: str) -> str:
254
- with netCDF4.Dataset(full_path) as nc:
255
- var = nc.variables[name]
256
- unit = var.units
257
- return unit
258
-
259
-
260
- def _initialize_figure(n_subplots: int, dpi) -> tuple:
261
- """Creates an empty figure according to the number of subplots."""
262
- fig, axes = plt.subplots(
263
- n_subplots, 1, figsize=(16, 4 + (n_subplots - 1) * 4.8), dpi=dpi
264
- )
265
- fig.subplots_adjust(left=0.06, right=0.73)
266
- if n_subplots == 1:
267
- axes = [axes]
268
- return fig, axes
269
-
270
-
271
- def _read_ax_values(full_path: str) -> tuple[ndarray, ndarray]:
272
- """Returns time and height arrays."""
273
- file_type = utils.get_file_type(full_path)
274
- with netCDF4.Dataset(full_path) as nc:
275
- is_height = "height" in nc.variables
276
- if is_height is not True:
277
- fields = ["time", "range"]
278
- else:
279
- fields = ["time", "height"]
280
- time, height = ptools.read_nc_fields(full_path, fields)
281
- if file_type == "model":
282
- height = ma.mean(height, axis=0)
283
- height_km = height / 1000
284
- return time, height_km
462
+ if time[0] < min_x or time[-1] > max_x:
463
+ msg = f"x-axis values outside the range {min_x}-{max_x}."
464
+ raise ValueError(msg)
465
+ max_gap_fraction_hour = _get_max_gap_in_minutes(figure_data) / 60
285
466
 
467
+ data = self._data
286
468
 
287
- def _read_time_vector(nc_file: str) -> ndarray:
288
- """Converts time vector to fraction hour."""
289
- with netCDF4.Dataset(nc_file) as nc:
290
- time = nc.variables["time"][:]
291
- if max(time) < 24:
292
- return time
293
- return utils.seconds2hours(time)
469
+ if self.sub_plot.file_type == "model":
470
+ time, data = screen_completely_masked_profiles(time, data)
294
471
 
472
+ gap_indices = np.where(np.diff(time) > max_gap_fraction_hour)[0]
295
473
 
296
- def _screen_high_altitudes(data_field: ndarray, ax_values: tuple, max_y: int) -> tuple:
297
- """Removes altitudes from 2D data that are not visible in the figure.
474
+ if not ma.is_masked(data):
475
+ mask_new = np.zeros(data.shape)
476
+ elif ma.all(data.mask) is ma.masked:
477
+ mask_new = np.ones(data.shape)
478
+ else:
479
+ mask_new = np.copy(data.mask)
480
+ data_new = ma.copy(data)
481
+ time_new = np.copy(time)
482
+ if self._data.ndim == 2:
483
+ temp_array = np.zeros((2, data.shape[1]))
484
+ temp_mask = np.ones((2, data.shape[1]))
485
+ else:
486
+ temp_array = np.zeros(2)
487
+ temp_mask = np.ones(2)
488
+ time_delta = 0.001
489
+ for ind in np.sort(gap_indices)[::-1]:
490
+ ind_gap = ind + 1
491
+ data_new = np.insert(data_new, ind_gap, temp_array, axis=0)
492
+ mask_new = np.insert(mask_new, ind_gap, temp_mask, axis=0)
493
+ time_new = np.insert(time_new, ind_gap, time[ind_gap] - time_delta)
494
+ time_new = np.insert(time_new, ind_gap, time[ind_gap - 1] + time_delta)
495
+ if (time[0] - min_x) > max_gap_fraction_hour:
496
+ data_new = np.insert(data_new, 0, temp_array, axis=0)
497
+ mask_new = np.insert(mask_new, 0, temp_mask, axis=0)
498
+ time_new = np.insert(time_new, 0, time[0] - time_delta)
499
+ time_new = np.insert(time_new, 0, time_delta)
500
+ if (max_x - time[-1]) > max_gap_fraction_hour:
501
+ ind_gap = mask_new.shape[0]
502
+ data_new = np.insert(data_new, ind_gap, temp_array, axis=0)
503
+ mask_new = np.insert(mask_new, ind_gap, temp_mask, axis=0)
504
+ time_new = np.insert(time_new, ind_gap, max_x - time_delta)
505
+ time_new = np.insert(time_new, ind_gap, time[-1] + time_delta)
506
+ data_new.mask = mask_new
507
+ self._data = data_new
508
+ figure_data.time_including_gaps = time_new
509
+
510
+ def _read_cloud_top_flags(
511
+ self, figure_data: FigureData, flag_value: int | tuple[int, ...]
512
+ ) -> ndarray:
513
+ status = figure_data.file.variables["cloud_top_height_status"][:]
514
+ return np.isin(status, flag_value)
515
+
516
+ def _read_flagged_data(self, figure_data: FigureData) -> ndarray:
517
+ flag_names = [
518
+ f"{self.sub_plot.variable.name}_quality_flag",
519
+ "temperature_quality_flag",
520
+ ]
521
+ if self.sub_plot.variable.name != "irt":
522
+ flag_names.append("quality_flag")
523
+ for flag_name in flag_names:
524
+ if flag_name in figure_data.file.variables:
525
+ return figure_data.file.variables[flag_name][:] > 0
526
+ return np.array([])
527
+
528
+
529
+ class Plot2D(Plot):
530
+ def plot_ec_scene(self, figure_data: FigureData) -> None:
531
+ variables = figure_data.file.variables
532
+ lat = variables["latitude_msi"][:]
533
+ lon = variables["longitude_msi"][:]
534
+ data = variables["cloud_top_height"][:] / 1000
535
+ valid_ind = ~data.mask
536
+
537
+ # Gridded cloud top height
538
+ if np.sum(valid_ind) > 10:
539
+ nbins = 200
540
+ lon_bins = np.linspace(lon.min(), lon.max(), nbins)
541
+ lat_bins = np.linspace(lat.min(), lat.max(), nbins)
542
+ lon = lon[valid_ind]
543
+ lat = lat[valid_ind]
544
+ data = data[valid_ind].data
545
+ grid, _, _ = np.histogram2d(
546
+ lon, lat, bins=[lon_bins, lat_bins], weights=data
547
+ )
548
+ counts, _, _ = np.histogram2d(lon, lat, bins=[lon_bins, lat_bins])
549
+ with np.errstate(divide="ignore", invalid="ignore"):
550
+ grid_mean = np.where(counts > 0, grid / counts, np.nan)
551
+ vmin = np.nanpercentile(grid_mean, 2)
552
+ vmax = np.nanpercentile(grid_mean, 98)
553
+ im = self._ax.pcolorfast(
554
+ lon_bins,
555
+ lat_bins,
556
+ grid_mean.T,
557
+ cmap="Blues_r",
558
+ vmin=vmin,
559
+ vmax=vmax,
560
+ )
561
+ cbar = self._init_colorbar(im)
562
+ cbar.set_label("km", fontsize=13)
563
+
564
+ # CPR ground track
565
+ lat_cpr = variables["latitude_cpr"][:]
566
+ lon_cpr = variables["longitude_cpr"][:]
567
+ self._ax.plot(
568
+ lon_cpr[::4],
569
+ lat_cpr[::4],
570
+ markeredgecolor="grey",
571
+ markerfacecolor="lightgreen",
572
+ linewidth=0,
573
+ marker=".",
574
+ markersize=10,
575
+ label="CPR ground track",
576
+ )
577
+ # Ground station
578
+ site_lat = np.mean(variables["latitude"][:])
579
+ site_lon = np.mean(variables["longitude"][:])
580
+ self._ax.plot(
581
+ site_lon,
582
+ site_lat,
583
+ marker="+",
584
+ color="red",
585
+ markersize=10,
586
+ label="Ground station",
587
+ )
298
588
 
299
- Bug in pcolorfast causing effect to axis not noticing limitation while
300
- saving fig. This fixes that bug till pcolorfast does fixing themselves.
589
+ # Zoom to region
590
+ lat_range = 1
591
+ lon_range = lat_range / np.cos(np.deg2rad(site_lat))
592
+ self._ax.set_xlim(site_lon - lon_range, site_lon + lon_range)
593
+ self._ax.set_ylim(site_lat - lat_range, site_lat + lat_range)
594
+
595
+ # Scale bar
596
+ scale_km = 10
597
+ km_per_deg_lon = 111.32 * np.cos(np.deg2rad(site_lat))
598
+ deg_lon_10km = scale_km / km_per_deg_lon
599
+ x0 = site_lon - lon_range * 0.9
600
+ y0 = site_lat - lat_range * 0.9
601
+ self._ax.plot(
602
+ [x0, x0 + deg_lon_10km], [y0, y0], color="k", lw=2, solid_capstyle="butt"
603
+ )
604
+ self._ax.text(
605
+ x0 + deg_lon_10km / 2,
606
+ y0 + lat_range * 0.02,
607
+ f"{scale_km} km",
608
+ ha="center",
609
+ va="bottom",
610
+ fontsize=10,
611
+ )
301
612
 
302
- Args:
303
- data_field (ndarray): 2D data array.
304
- ax_values (tuple): Time and height 1D arrays.
305
- max_y (int): Upper limit in the plots (km).
613
+ legend = self._ax.legend(loc="upper right")
614
+ legend.get_frame().set_edgecolor("white")
615
+ self._ax.set_xlabel("Longitude°", fontsize=13)
616
+ self._ax.set_ylabel("Latitude°", fontsize=13)
617
+
618
+ def plot(self, figure_data: FigureData) -> None:
619
+ self._convert_units()
620
+ if self._plot_meta.mask_zeros:
621
+ self._mask_zeros()
622
+ if figure_data.file_type == "cpr-simulation":
623
+ min_x, max_x = 0, EARTHCARE_MAX_X
624
+ else:
625
+ min_x, max_x = 0, 24
626
+ self._mark_gaps(figure_data, min_x=min_x, max_x=max_x)
627
+ if self.sub_plot.variable.name == "cloud_fraction":
628
+ self._data[self._data == 0] = ma.masked
629
+ if any(
630
+ key in self.sub_plot.variable.name for key in ("status", "classification")
631
+ ):
632
+ self._plot_segment_data(figure_data)
633
+ else:
634
+ self._plot_mesh_data(figure_data)
635
+
636
+ if figure_data.options.mark_data_gaps:
637
+ self._fill_between_data_gaps(figure_data)
638
+
639
+ if figure_data.is_mwrpy_product():
640
+ self._fill_flagged_data(figure_data)
641
+
642
+ if figure_data.variables[0].name == "signal_source_status":
643
+ self._indicate_rainy_profiles(figure_data)
644
+
645
+ def _indicate_rainy_profiles(self, figure_data: FigureData) -> None:
646
+ if "rain_detected" not in figure_data.file.variables:
647
+ return
648
+ rain = figure_data.file.variables["rain_detected"][:]
649
+ is_rain: ma.MaskedArray = ma.masked_array(np.zeros_like(rain), mask=(rain == 0))
650
+ if is_rain.mask.all():
651
+ return
652
+ self._ax.plot(
653
+ figure_data.time,
654
+ is_rain,
655
+ color="red",
656
+ marker="|",
657
+ linestyle="None",
658
+ markersize=10,
659
+ zorder=-999,
660
+ label="Rain",
661
+ )
662
+ self._ax.legend(
663
+ markerscale=0.75,
664
+ numpoints=1,
665
+ frameon=False,
666
+ )
306
667
 
307
- """
308
- alt = ax_values[-1]
309
- if data_field.ndim > 1:
310
- ind = int((np.argmax(alt > max_y) or len(alt)) + 1)
311
- data_field = data_field[:, :ind]
312
- alt = alt[:ind]
313
- return data_field, (ax_values[0], alt)
668
+ def _fill_flagged_data(self, figure_data: FigureData) -> None:
669
+ flags = self._read_flagged_data(figure_data)
670
+ batches = find_batches_of_ones(flags)
671
+ for batch in batches:
672
+ if batch[0] == batch[1]:
673
+ continue
674
+ time_batch = figure_data.time[batch[0]], figure_data.time[batch[1]]
675
+ self._ax.fill_between(
676
+ time_batch,
677
+ *self._get_y_limits(),
678
+ facecolor="white",
679
+ alpha=0.7,
680
+ label="_nolegend_",
681
+ zorder=_get_zorder("flags"),
682
+ )
314
683
 
684
+ def _plot_segment_data(self, figure_data: FigureData) -> None:
685
+ def _hide_segments() -> tuple[list, list]:
686
+ if self._plot_meta.clabel is None:
687
+ msg = "Missing clabel"
688
+ raise ValueError(msg)
689
+ labels = [x[0] for x in self._plot_meta.clabel]
690
+ colors = [x[1] for x in self._plot_meta.clabel]
691
+ segments_to_hide = np.char.startswith(labels, "_")
692
+ indices = np.where(segments_to_hide)[0]
693
+ for ind in np.flip(indices):
694
+ del labels[ind], colors[ind]
695
+ self._data[self._data == ind] = ma.masked
696
+ self._data[self._data > ind] -= 1
697
+ return colors, labels
698
+
699
+ cbar, clabel = _hide_segments()
700
+ alt = self._screen_data_by_max_y(figure_data)
701
+ image = self._ax.pcolorfast(
702
+ figure_data.time_including_gaps,
703
+ alt,
704
+ self._data.T[:-1, :-1],
705
+ cmap=ListedColormap(cbar),
706
+ vmin=-0.5,
707
+ vmax=len(cbar) - 0.5,
708
+ zorder=_get_zorder("data"),
709
+ )
710
+ colorbar = self._init_colorbar(image)
711
+ colorbar.set_ticks(np.arange(len(clabel)).tolist())
712
+ colorbar.ax.set_yticklabels(clabel, fontsize=13)
315
713
 
316
- def set_ax(ax, max_y: float, ylabel: str | None, min_y: float = 0.0):
317
- """Sets ticks and tick labels for plt.imshow()."""
318
- ticks_x_labels = _get_standard_time_ticks()
319
- ax.set_ylim(min_y, max_y)
320
- ax.set_xticks(np.arange(0, 25, 4, dtype=int))
321
- ax.set_xticklabels(ticks_x_labels, fontsize=12)
322
- ax.set_ylabel("Height (km)", fontsize=13)
323
- ax.set_xlim(0, 24)
324
- if ylabel is not None:
325
- ax.set_ylabel(ylabel, fontsize=13)
714
+ def _plot_mesh_data(self, figure_data: FigureData) -> None:
715
+ if self._plot_meta.plot_range is None:
716
+ vmin, vmax = self._data.min(), self._data.max()
717
+ else:
718
+ vmin, vmax = self._plot_meta.plot_range
719
+ if self._is_log:
720
+ self._data = np.maximum(self._data, vmin)
721
+ self._data, vmin, vmax = lin2log(self._data, vmin, vmax)
326
722
 
723
+ alt = self._screen_data_by_max_y(figure_data)
327
724
 
328
- def _get_standard_time_ticks(resolution: int = 4) -> list:
329
- """Returns typical ticks / labels for a time vector between 0-24h."""
330
- return [
331
- f"{int(i):02d}:00" if 24 > i > 0 else ""
332
- for i in np.arange(0, 24.01, resolution)
333
- ]
725
+ if (duration := self._plot_meta.time_smoothing_duration) > 0:
726
+ sigma_units = calc_sigma_units(
727
+ figure_data.time, alt * 1e3, sigma_minutes=duration, sigma_metres=0
728
+ )
729
+ valid_time_ind = ~np.all(self._data.mask, axis=1)
730
+ smoothed_data = uniform_filter(self._data[valid_time_ind, :], sigma_units)
731
+ self._data[valid_time_ind, :] = smoothed_data
732
+
733
+ if self._data.mask.all() and figure_data.options.raise_on_empty:
734
+ msg = "All data is masked"
735
+ raise PlottingError(msg)
736
+
737
+ pcolor_kwargs = {
738
+ "cmap": plt.get_cmap(str(self._plot_meta.cmap)),
739
+ "vmin": vmin,
740
+ "vmax": vmax,
741
+ "zorder": _get_zorder("data"),
742
+ }
743
+ image: Any
744
+ if getattr(figure_data.file, "cloudnet_file_type", "") == "model":
745
+ image = self._ax.pcolor(
746
+ figure_data.time_including_gaps,
747
+ alt,
748
+ self._data.T,
749
+ **pcolor_kwargs,
750
+ shading="nearest",
751
+ )
752
+ else:
753
+ image = self._ax.pcolorfast(
754
+ figure_data.time_including_gaps,
755
+ alt,
756
+ self._data.T[:-1, :-1],
757
+ **pcolor_kwargs,
758
+ )
759
+ cbar = self._init_colorbar(image)
760
+ cbar.set_label(str(self._plot_meta.clabel), fontsize=13)
761
+
762
+ if self._is_log:
763
+ cbar.set_ticks(np.arange(vmin, vmax + 1).tolist()) # type: ignore[arg-type]
764
+ tick_labels = get_log_cbar_tick_labels(vmin, vmax)
765
+ cbar.ax.set_yticklabels(tick_labels)
766
+
767
+ if self._plot_meta.contour:
768
+ self._plot_contour(
769
+ figure_data,
770
+ alt,
771
+ levels=np.linspace(vmin, vmax, num=10),
772
+ colors="black",
773
+ linewidths=0.5,
774
+ )
334
775
 
776
+ if self.sub_plot.variable.name == "Tw":
777
+ self._plot_contour(
778
+ figure_data,
779
+ alt,
780
+ levels=np.array([con.T0]),
781
+ colors="gray",
782
+ linewidths=1.25,
783
+ linestyles="dashed",
784
+ )
335
785
 
336
- def _plot_bar_data(ax, data: np.ndarray, time: ndarray, unit: str):
337
- """Plots 1D variable as bar plot.
786
+ def _plot_contour(
787
+ self,
788
+ figure_data: FigureData,
789
+ alt: npt.NDArray,
790
+ **options, # noqa: ANN003
791
+ ) -> None:
792
+ time_length = len(figure_data.time_including_gaps)
793
+ step = max(1, time_length // 200)
794
+ ind_time = np.arange(0, time_length, step)
795
+ self._ax.contour(
796
+ figure_data.time_including_gaps[ind_time],
797
+ alt,
798
+ self._data[ind_time, :].T,
799
+ **options,
800
+ zorder=_get_zorder("contour"),
801
+ )
338
802
 
339
- Args:
340
- ax (obj): Axes object.
341
- data (maskedArray): 1D data array.
342
- time (ndarray): 1D time array.
803
+ def _screen_data_by_max_y(self, figure_data: FigureData) -> ndarray:
804
+ if figure_data.height is None:
805
+ msg = "No height information in the file."
806
+ raise ValueError(msg)
807
+ if self._data.ndim < 2:
808
+ msg = "Data has to be 2D."
809
+ raise PlottingError(msg)
810
+ alt = figure_data.height
811
+ if figure_data.options.max_y is None:
812
+ return alt
813
+ ind = int((np.argmax(alt > figure_data.options.max_y) or len(alt)) + 1)
814
+ self._data = self._data[:, :ind]
815
+ return alt[:ind]
816
+
817
+
818
+ class Plot1D(Plot):
819
+ def plot(self, figure_data: FigureData, hacky_freq_ind: int | None = None) -> None:
820
+ if self._data.mask.all() and figure_data.options.raise_on_empty:
821
+ msg = "All data is masked"
822
+ raise PlottingError(msg)
823
+ units = self._convert_units()
824
+ if self._plot_meta.mask_zeros:
825
+ self._mask_zeros()
826
+ self._mark_gaps(figure_data)
827
+ self._ax.plot(
828
+ figure_data.time_including_gaps,
829
+ self._data,
830
+ label="_nolegend_",
831
+ **self._get_plot_options(),
832
+ zorder=_get_zorder("data"),
833
+ )
834
+ if self._plot_meta.moving_average:
835
+ self._plot_moving_average(figure_data, hacky_freq_ind)
836
+ if self._plot_meta.zero_line:
837
+ self._ax.axhline(0, color="black", alpha=0.5, label="_nolegend_")
838
+ self._fill_between_data_gaps(figure_data)
839
+ self.sub_plot.set_yax(ylabel=units, y_limits=self._get_y_limits())
840
+ pos = self._ax.get_position()
841
+ self._ax.set_position((pos.x0, pos.y0, pos.width * 0.965, pos.height))
842
+ self._plot_flags(figure_data)
843
+
844
+ def _plot_flags(self, figure_data: FigureData) -> None:
845
+ if figure_data.is_mwrpy_product():
846
+ flags = self._read_flagged_data(figure_data)
847
+ if np.any(flags):
848
+ self._plot_flag_data(figure_data.time[flags], self._data_orig[flags])
849
+ self._add_legend()
850
+ if (
851
+ figure_data.variables[0].name == "cloud_top_height_agl"
852
+ and "cloud_top_height_status" in figure_data.file.variables
853
+ ):
854
+ legend: tuple = ()
855
+ flag_value = (TopStatus.MODERATE_ATT, TopStatus.UNCORR_ATT)
856
+ flags = self._read_cloud_top_flags(figure_data, flag_value)
857
+ if np.any(flags):
858
+ self._plot_flag_data(
859
+ figure_data.time[flags], self._data_orig[flags], color="orange"
860
+ )
861
+ legend += ("Suspicious",)
862
+ flag_value = (TopStatus.SEVERE_ATT, TopStatus.ABOVE_RANGE)
863
+ flags = self._read_cloud_top_flags(figure_data, flag_value)
864
+ if np.any(flags):
865
+ self._plot_flag_data(
866
+ figure_data.time[flags], self._data_orig[flags], color="red"
867
+ )
868
+ legend += ("Unreliable",)
869
+ if legend:
870
+ self._add_legend(name=legend)
871
+
872
+ def plot_tb(self, figure_data: FigureData, freq_ind: int) -> None:
873
+ if len(self._data.shape) != 2 or freq_ind >= self._data.shape[1]:
874
+ msg = "Frequency index not found in data"
875
+ raise PlottingError(msg)
876
+ self._data = self._data[:, freq_ind]
877
+ self._data[np.isnan(self._data)] = ma.masked
878
+ if self._data.mask.all() and figure_data.options.raise_on_empty:
879
+ msg = "All data is masked"
880
+ raise PlottingError(msg)
881
+ self._data_orig = self._data_orig[:, freq_ind]
882
+ if self.sub_plot.variable.name == "tb":
883
+ is_bad_zenith = self._get_bad_zenith_profiles(figure_data)
884
+ self._data[is_bad_zenith] = ma.masked
885
+ self._data_orig[is_bad_zenith] = ma.masked
886
+ flags = self._read_flagged_data(figure_data)[:, freq_ind]
887
+ flags[is_bad_zenith] = False
888
+ if np.any(flags):
889
+ self._plot_flag_data(figure_data.time[flags], self._data_orig[flags])
890
+ self._add_legend()
891
+ self._show_frequency(figure_data, freq_ind)
892
+ self.plot(figure_data, freq_ind)
893
+
894
+ def _show_frequency(self, figure_data: FigureData, freq_ind: int) -> None:
895
+ if self.sub_plot.variable.name == "tb":
896
+ label = "Freq"
897
+ value = figure_data.file.variables["frequency"][freq_ind]
898
+ unit = "GHz"
899
+ elif "ir_wavelength" in figure_data.file.variables:
900
+ label = "WL"
901
+ variable = figure_data.file.variables["ir_wavelength"]
902
+ # `ir_wavelength` is scalar in old files
903
+ value = variable[:] if len(variable.shape) == 0 else variable[freq_ind]
904
+ value /= 1e-6 # m to μm
905
+ unit = "μm"
906
+ else:
907
+ return
908
+ self._ax.text(
909
+ 0.0,
910
+ -0.13,
911
+ f"{label}: {value:.2f} {unit}",
912
+ transform=self._ax.transAxes,
913
+ fontsize=12,
914
+ color="dimgrey",
915
+ bbox={
916
+ "facecolor": "white",
917
+ "linewidth": 0,
918
+ "boxstyle": "round",
919
+ },
920
+ )
343
921
 
344
- """
345
- data = _convert_to_kg(data, unit)
346
- ax.plot(time, data, color="navy")
922
+ def _plot_flag_data(
923
+ self, time: ndarray, values: ndarray, color: str = "salmon"
924
+ ) -> None:
925
+ self._ax.plot(
926
+ time,
927
+ values,
928
+ color=color,
929
+ marker=".",
930
+ lw=0,
931
+ markersize=3,
932
+ zorder=_get_zorder("flags"),
933
+ )
347
934
 
348
- if isinstance(data, ma.MaskedArray):
349
- data_filled = data.filled(0)
350
- else:
351
- data_filled = data
935
+ def _add_legend(self, name: str | tuple = ("Flagged data",)) -> None:
936
+ self._ax.legend(
937
+ name,
938
+ markerscale=3,
939
+ numpoints=1,
940
+ frameon=False,
941
+ )
352
942
 
353
- ax.bar(
354
- time,
355
- data_filled,
356
- width=1 / 120,
357
- align="center",
358
- alpha=0.5,
359
- color="royalblue",
360
- )
361
- pos = ax.get_position()
362
- ax.set_position([pos.x0, pos.y0, pos.width * 0.965, pos.height])
943
+ def _get_y_limits(self) -> tuple[float, float]:
944
+ percent_gap = 0.05
945
+ fallback = (-percent_gap, percent_gap)
946
+ if ma.all(self._data.mask):
947
+ return fallback
948
+ min_data = self._data.min()
949
+ max_data = self._data.max()
950
+ range_val = max_data - min_data
951
+ gap = percent_gap * range_val
952
+ min_y = min_data - gap
953
+ max_y = max_data + gap
954
+ if min_y == 0 and max_y == 0:
955
+ return fallback
956
+ if min_y == max_y:
957
+ gap = np.abs(min_y) * percent_gap
958
+ return min_y - gap, max_y + gap
959
+ return min_y, max_y
960
+
961
+ def _get_plot_options(self) -> dict:
962
+ default_options = {
963
+ "color": "lightblue",
964
+ "lw": 0,
965
+ "marker": ".",
966
+ "markersize": 3,
967
+ }
968
+ custom_options = {
969
+ "tb": {
970
+ "color": "lightblue",
971
+ },
972
+ "cloud_top_height_agl": {
973
+ "color": "steelblue",
974
+ },
975
+ }
976
+
977
+ variable_name = self.sub_plot.variable.name
978
+ if variable_name in custom_options:
979
+ default_options.update(custom_options[variable_name])
980
+
981
+ return default_options
982
+
983
+ def _plot_moving_average(
984
+ self, figure_data: FigureData, hacky_freq_ind: int | None = None
985
+ ) -> None:
986
+ time = figure_data.time.copy()
987
+ data = self._data_orig.copy()
988
+
989
+ if figure_data.is_mwrpy_product() or self.sub_plot.variable.name in (
990
+ "tb",
991
+ "irt",
992
+ ):
993
+ flags = self._read_flagged_data(figure_data)
994
+ else:
995
+ flags = np.array([])
996
+
997
+ if hacky_freq_ind is not None and np.any(flags):
998
+ flags = flags[:, hacky_freq_ind]
999
+ is_invalid = ma.getmaskarray(data)
1000
+ if np.any(flags):
1001
+ is_invalid |= flags
1002
+
1003
+ is_wind_direction = self.sub_plot.variable.name == "wind_direction"
1004
+ if is_wind_direction:
1005
+ wind_speed = figure_data.file["wind_speed"]
1006
+ data = np.stack([wind_speed, data], axis=1)
1007
+
1008
+ block_ind = np.where(np.diff(is_invalid))[0] + 1
1009
+ valid_time_blocks = np.split(time, block_ind)[int(is_invalid[0]) :: 2]
1010
+ valid_data_blocks = np.split(data, block_ind)[int(is_invalid[0]) :: 2]
1011
+
1012
+ for time1, data1 in zip(valid_time_blocks, valid_data_blocks, strict=False):
1013
+ if is_wind_direction:
1014
+ sma = self._calculate_average_wind_direction(
1015
+ data1[:, 0], data1[:, 1], time1, window=15
1016
+ )
1017
+ else:
1018
+ sma = self._calculate_moving_average(data1, time1, window=5)
1019
+ gap_time = _get_max_gap_in_minutes(figure_data)
1020
+ gaps = self._find_time_gap_indices(time1, max_gap_min=gap_time) + 1
1021
+
1022
+ for time2, data2 in zip(
1023
+ np.split(time1, gaps), np.split(sma, gaps), strict=False
1024
+ ):
1025
+ self._ax.plot(
1026
+ time2,
1027
+ data2,
1028
+ color="slateblue",
1029
+ lw=2,
1030
+ label="_nolegend_",
1031
+ zorder=_get_zorder("mean_curve"),
1032
+ )
1033
+
1034
+ @staticmethod
1035
+ def _get_bad_zenith_profiles(figure_data: FigureData) -> ndarray:
1036
+ zenith_limit = 5
1037
+ valid_pointing_status = 0
1038
+ if "pointing_flag" in figure_data.file.variables:
1039
+ pointing_flag = figure_data.file.variables["pointing_flag"][:]
1040
+ zenith_angle = figure_data.file.variables["zenith_angle"][:]
1041
+ is_bad_zenith = np.abs(zenith_angle) > zenith_limit
1042
+ is_bad_pointing = pointing_flag != valid_pointing_status
1043
+ return is_bad_zenith | is_bad_pointing
1044
+ return np.zeros_like(figure_data.time, dtype=bool)
1045
+
1046
+ @staticmethod
1047
+ def _find_time_gap_indices(time: ndarray, max_gap_min: float) -> ndarray:
1048
+ gap_decimal_hour = max_gap_min / 60
1049
+ return np.where(np.diff(time) > gap_decimal_hour)[0]
1050
+
1051
+ @staticmethod
1052
+ def _calculate_moving_average(
1053
+ data: ndarray, time: ndarray, window: float = 5
1054
+ ) -> ndarray:
1055
+ if len(data) == 0:
1056
+ return np.array([])
1057
+ if len(data) == 1:
1058
+ return data
1059
+ time_delta_hours = ma.median(np.diff(time))
1060
+ window_size = int(window / 60 / time_delta_hours)
1061
+ if window_size < 1:
1062
+ window_size = 1
1063
+ if window_size % 2 == 0:
1064
+ window_size += 1
1065
+ weights = np.repeat(1 / window_size, window_size)
1066
+ padded_data = np.pad(data, window_size // 2, mode="edge")
1067
+ return np.convolve(padded_data, weights, "valid")
1068
+
1069
+ @classmethod
1070
+ def _calculate_average_wind_direction(
1071
+ cls,
1072
+ wind_speed: ndarray,
1073
+ wind_direction: ndarray,
1074
+ time: ndarray,
1075
+ window: float = 5,
1076
+ ) -> ndarray:
1077
+ angle = np.deg2rad(wind_direction)
1078
+ u = wind_speed * np.cos(angle)
1079
+ v = wind_speed * np.sin(angle)
1080
+ avg_u = cls._calculate_moving_average(u, time, window)
1081
+ avg_v = cls._calculate_moving_average(v, time, window)
1082
+ data = np.rad2deg(np.arctan2(avg_v, avg_u)) % 360
1083
+ wrap = np.where(np.abs(np.diff(data)) > 300)[0]
1084
+ data[wrap] = np.nan
1085
+ return data
363
1086
 
364
1087
 
365
- def _plot_segment_data(ax, data: ma.MaskedArray, name: str, axes: tuple):
366
- """Plots categorical 2D variable.
1088
+ def generate_figure(
1089
+ filename: PathLike | str,
1090
+ variables: list[str],
1091
+ *,
1092
+ show: bool = True,
1093
+ output_filename: PathLike | str | None = None,
1094
+ options: PlotParameters | None = None,
1095
+ ) -> Dimensions:
1096
+ """Generate a figure based on the given filename and variables.
367
1097
 
368
1098
  Args:
369
- ax (obj): Axes object of subplot (1,2,3,.. [1,1,],[1,2]... etc.)
370
- data (ndarray): 2D data array.
371
- name (string): Name of plotted data.
372
- axes (tuple): Time and height 1D arrays.
373
-
374
- """
1099
+ filename: The path to the input file.
1100
+ variables: A list of variable names to plot.
1101
+ show: Whether to display the figure. Defaults to True.
1102
+ output_filename: The path to save the figure. Defaults to None.
1103
+ options: Additional plot parameters. Defaults to None.
375
1104
 
376
- def _hide_segments(
377
- data_in: ma.MaskedArray,
378
- ) -> tuple[ma.MaskedArray, list, list]:
379
- assert variables.clabel is not None
380
- labels = [x[0] for x in variables.clabel]
381
- colors = [x[1] for x in variables.clabel]
382
- segments_to_hide = np.char.startswith(labels, "_")
383
- indices = np.where(segments_to_hide)[0]
384
- for ind in np.flip(indices):
385
- del labels[ind], colors[ind]
386
- data_in[data_in == ind] = ma.masked
387
- data_in[data_in > ind] -= 1
388
- return data_in, colors, labels
389
-
390
- variables = ATTRIBUTES[name]
391
- original_mask = np.copy(data.mask)
392
- data, cbar, clabel = _hide_segments(data)
393
- cmap = ListedColormap(cbar)
394
- data[original_mask] = 99
395
- pl = ax.pcolorfast(
396
- *axes, data[:-1, :-1].T, cmap=cmap, vmin=-0.5, vmax=len(cbar) - 0.5
397
- )
398
- colorbar = _init_colorbar(pl, ax)
399
- colorbar.set_ticks(np.arange(len(clabel)))
400
- colorbar.ax.set_yticklabels(clabel, fontsize=13)
401
-
402
-
403
- def _plot_colormesh_data(ax, data: ndarray, name: str, axes: tuple):
404
- """Plots continuous 2D variable.
405
-
406
- Creates only one plot, so can be used both one plot and subplot type of figs.
1105
+ Returns:
1106
+ Dimensions: Dimensions of a generated figure in pixels.
407
1107
 
408
- Args:
409
- ax (obj): Axes object of subplot (1,2,3,.. [1,1,],[1,2]... etc.)
410
- data (ndarray): 2D data array.
411
- name (string): Name of plotted data.
412
- axes (tuple): Time and height 1D arrays.
413
1108
  """
414
- variables = ATTRIBUTES[name]
415
- assert variables.plot_range is not None
416
-
417
- if name == "cloud_fraction":
418
- data[data < 0.1] = ma.masked
419
-
420
- if variables.plot_type == "bit":
421
- cmap = ListedColormap(variables.cbar)
422
- pos = ax.get_position()
423
- ax.set_position([pos.x0, pos.y0, pos.width * 0.965, pos.height])
424
- else:
425
- cmap = plt.get_cmap(variables.cbar, 22)
426
-
427
- vmin, vmax = variables.plot_range
428
-
429
- if variables.plot_scale == Scale.LOGARITHMIC:
430
- data, vmin, vmax = lin2log(data, vmin, vmax)
431
-
432
- pl = ax.pcolorfast(*axes, data[:-1, :-1].T, vmin=vmin, vmax=vmax, cmap=cmap)
1109
+ fig = None
1110
+ try:
1111
+ if options is None:
1112
+ options = PlotParameters()
433
1113
 
434
- if variables.plot_type != "bit":
435
- colorbar = _init_colorbar(pl, ax)
436
- colorbar.set_label(variables.clabel, fontsize=13)
1114
+ with netCDF4.Dataset(filename) as file:
1115
+ figure_data = FigureData(file, variables, options)
1116
+ fig, axes = figure_data.initialize_figure()
437
1117
 
438
- if variables.plot_scale == Scale.LOGARITHMIC:
439
- tick_labels = generate_log_cbar_ticklabel_list(vmin, vmax)
440
- colorbar.set_ticks(np.arange(vmin, vmax + 1))
441
- colorbar.ax.set_yticklabels(tick_labels)
1118
+ for ax, variable, ind in zip(
1119
+ axes, figure_data.variables, figure_data.indices, strict=True
1120
+ ):
1121
+ file_type = getattr(file, "cloudnet_file_type", None)
1122
+ subplot = SubPlot(ax, variable, options, file_type)
442
1123
 
1124
+ if variable.name in ("tb", "irt") and ind is not None:
1125
+ Plot1D(subplot).plot_tb(figure_data, ind)
1126
+ elif (
1127
+ figure_data.file_type == "cpr-validation"
1128
+ and variable.name == "cloud_top_height"
1129
+ ):
1130
+ Plot2D(subplot).plot_ec_scene(figure_data)
1131
+ elif variable.ndim == 1:
1132
+ Plot1D(subplot).plot(figure_data)
1133
+ elif variable.name in ("number_concentration", "fall_velocity"):
1134
+ Plot2D(subplot).plot(figure_data)
1135
+ subplot.set_yax(ylabel="Diameter (mm)", y_limits=(0, 10))
1136
+ else:
1137
+ Plot2D(subplot).plot(figure_data)
1138
+ subplot.set_yax(y_limits=(0, figure_data.options.max_y))
443
1139
 
444
- def _plot_instrument_data(
445
- ax,
446
- data: ma.MaskedArray,
447
- name: str,
448
- product: str | None,
449
- time: ndarray,
450
- unit: str,
451
- full_path: str | None = None,
452
- tb_ind: int | None = None,
453
- ):
454
- if product in ("mwr", "mwr-single"):
455
- _plot_mwr(ax, data, name, time, unit)
456
- if product == "disdrometer":
457
- _plot_disdrometer(ax, data, time, name, unit)
458
- if product == "weather-station":
459
- _plot_weather_station(ax, data, time, name)
460
- if full_path is not None and tb_ind is not None:
461
- quality_flag_array = ptools.read_nc_fields(full_path, "quality_flag")
462
- assert isinstance(quality_flag_array, ndarray)
463
- quality_flag = quality_flag_array[:, tb_ind]
464
- data = data[:, tb_ind]
465
- data_dict = {"tb": data, "quality_flag": quality_flag, "time": time}
466
- _plot_hatpro(ax, data_dict, full_path)
467
- pos = ax.get_position()
468
- ax.set_position([pos.x0, pos.y0, pos.width * 0.965, pos.height])
469
-
470
-
471
- def _plot_disdrometer(ax, data: ndarray, time: ndarray, name: str, unit: str):
472
- if name == "rainfall_rate":
473
- if unit == "m s-1":
474
- data *= 1000 * 3600
475
- ax.plot(time, data, color="royalblue")
476
- ylim = max((np.max(data) * 1.05, 0.1))
477
- set_ax(ax, ylim, "mm h$^{-1}$")
478
- if name == "n_particles":
479
- ax.plot(time, data, color="royalblue")
480
- ylim = max((np.max(data) * 1.05, 1))
481
- set_ax(ax, ylim, "")
482
-
483
-
484
- def _plot_hatpro(ax, data: dict, full_path: str):
485
- tb = _pointing_filter(full_path, data["tb"])
486
- time = _pointing_filter(full_path, data["time"])
487
- ax.plot(time, tb, color="royalblue", linestyle="-", linewidth=1)
488
- set_ax(
489
- ax,
490
- max_y=np.max(tb) + 0.5,
491
- min_y=np.min(tb) - 0.5,
492
- ylabel="Brightness temperature [K]",
493
- )
494
-
495
-
496
- def _elevation_filter(full_path: str, data_field: ndarray, ele_range: tuple) -> ndarray:
497
- """Filters data for specified range of elevation angles."""
498
- with netCDF4.Dataset(full_path) as nc:
499
- if "ele" in nc.variables:
500
- elevation = ptools.read_nc_fields(full_path, "ele")
501
- if data_field.ndim > 1:
502
- data_field = data_field[
503
- (elevation >= ele_range[0]) & (elevation <= ele_range[1]), :
504
- ]
505
- else:
506
- data_field = data_field[
507
- (elevation >= ele_range[0]) & (elevation <= ele_range[1])
508
- ]
509
- return data_field
510
-
511
-
512
- def _pointing_filter(
513
- full_path: str, data_field: ndarray, ele_range: tuple = (0, 91), status: int = 0
514
- ) -> ndarray:
515
- """Filters data according to pointing flag."""
516
- with netCDF4.Dataset(full_path) as nc:
517
- if "pointing_flag" in nc.variables:
518
- pointing = ptools.read_nc_fields(full_path, "pointing_flag")
519
- assert isinstance(pointing, ndarray)
520
- pointing_screened = _elevation_filter(full_path, pointing, ele_range)
521
- if data_field.ndim > 1:
522
- data_field = data_field[pointing_screened == status, :]
523
- else:
524
- data_field = data_field[pointing_screened == status]
525
- return data_field
526
-
527
-
528
- def _plot_weather_station(ax, data: ndarray, time: ndarray, name: str):
529
- match name:
530
- case "air_temperature":
531
- unit = "K"
532
- min_y = np.min(data) - 1
533
- max_y = np.max(data) + 1
534
- ax.plot(time, data, color="royalblue")
535
- set_ax(ax, min_y=min_y, max_y=max_y, ylabel=unit)
536
- case "wind_speed":
537
- unit = "m s$^{-1}$"
538
- min_y = np.min(data) - 1
539
- max_y = np.max(data) + 1
540
- ax.plot(time, data, color="royalblue")
541
- set_ax(ax, min_y=min_y, max_y=max_y, ylabel=unit)
542
- case "wind_direction":
543
- unit = "degree"
544
- ax.plot(
545
- time, data, color="royalblue", marker=".", linewidth=0, markersize=3
546
- )
547
- set_ax(ax, min_y=0, max_y=360, ylabel=unit)
548
- case "relative_humidity":
549
- data *= 100
550
- unit = "%"
551
- min_y = np.min(data) - 1
552
- max_y = np.max(data) + 1
553
- ax.plot(time, data, color="royalblue")
554
- set_ax(ax, min_y=min_y, max_y=max_y, ylabel=unit)
555
- case "air_pressure":
556
- data /= 100
557
- unit = "hPa"
558
- min_y = np.min(data) - 1
559
- max_y = np.max(data) + 1
560
- ax.plot(time, data, color="royalblue")
561
- set_ax(ax, min_y=min_y, max_y=max_y, ylabel=unit)
562
- case "rainfall_amount":
563
- data *= 1000
564
- unit = "mm"
565
- min_y = 0
566
- max_y = np.max(data) + 1
567
- ax.plot(time, data, color="royalblue")
568
- set_ax(ax, min_y=min_y, max_y=max_y, ylabel=unit)
569
- case unknown:
570
- raise NotImplementedError(f"Not implemented for {unknown}")
571
-
572
-
573
- def _plot_mwr(ax, data_in: ma.MaskedArray, name: str, time: ndarray, unit: str):
574
- data, time = _get_unmasked_values(data_in, time)
575
- data = _convert_to_kg(data, unit)
576
- rolling_mean, width = _calculate_rolling_mean(time, data)
577
- gaps = _find_time_gap_indices(time)
578
- n, line_width = _get_plot_parameters(data)
579
- data_filtered = _filter_noise(data, n)
580
- time[gaps] = np.nan
581
- ax.plot(time, data_filtered, color="royalblue", lw=line_width)
582
- ax.axhline(linewidth=0.8, color="k")
583
- ax.plot(
584
- time[int(width / 2 - 1) : int(-width / 2)],
585
- rolling_mean,
586
- color="sienna",
587
- linewidth=2.0,
588
- )
589
- ax.plot(
590
- time[int(width / 2 - 1) : int(-width / 2)],
591
- rolling_mean,
592
- color="wheat",
593
- linewidth=0.6,
594
- )
595
- set_ax(
596
- ax,
597
- round(np.max(data), 3) + 0.0005,
598
- ATTRIBUTES[name].ylabel,
599
- min_y=round(np.min(data), 3) - 0.0005,
600
- )
601
-
602
-
603
- def _get_unmasked_values(
604
- data: ma.MaskedArray, time: ndarray
605
- ) -> tuple[np.ndarray, np.ndarray]:
606
- if ma.is_masked(data) is False:
607
- return data, time
608
- good_values = ~data.mask
609
- return data[good_values], time[good_values]
610
-
611
-
612
- def _convert_to_kg(data: np.ndarray, unit: str) -> np.ndarray:
613
- if "kg" in unit:
614
- return data
615
- return data / 1000
616
-
617
-
618
- def _find_time_gap_indices(time: ndarray) -> ndarray:
619
- """Finds time gaps bigger than 5min."""
620
- time_diff = np.diff(time)
621
- dec_hour_5min = 0.085
622
- gaps = np.where(time_diff > dec_hour_5min)[0]
623
- return gaps
624
-
625
-
626
- def _get_plot_parameters(data: ndarray) -> tuple[int, float]:
627
- length = len(data)
628
- n = np.rint(np.nextafter((length / 10000), (length / 10000) + 1))
629
- if length < 10000:
630
- line_width = 0.9
631
- elif 10000 <= length < 38000:
632
- line_width = 0.7
633
- elif 38000 <= length < 55000:
634
- line_width = 0.3
635
- else:
636
- line_width = 0.25
637
- return int(n), line_width
1140
+ subplot.set_xax(figure_data)
638
1141
 
1142
+ if options.title:
1143
+ subplot.add_title(ind)
639
1144
 
640
- def _calculate_rolling_mean(time: ndarray, data: ndarray) -> tuple[ndarray, int]:
641
- width = len(time[time <= time[0] + 0.3])
642
- if (width % 2) != 0:
643
- width = width + 1
644
- rolling_window = np.blackman(width)
645
- rolling_mean = np.convolve(data, rolling_window, "valid")
646
- rolling_mean = rolling_mean / np.sum(rolling_window)
647
- return rolling_mean, width
1145
+ if options.grid:
1146
+ subplot.add_grid()
648
1147
 
1148
+ if options.show_sources:
1149
+ subplot.add_sources(figure_data)
649
1150
 
650
- def _filter_noise(data: ndarray, n: int) -> ndarray:
651
- """IIR filter"""
652
- if n <= 1:
653
- n = 2
654
- b = [1.0 / n] * n
655
- a = 1
656
- return filtfilt(b, a, data)
1151
+ if options.subtitle and variable == figure_data.variables[-1]:
1152
+ figure_data.add_subtitle(fig)
657
1153
 
1154
+ subplot.set_xlabel()
658
1155
 
659
- def _init_colorbar(plot, axis):
660
- divider = make_axes_locatable(axis)
661
- cax = divider.append_axes("right", size="1%", pad=0.25)
662
- return plt.colorbar(plot, fraction=1.0, ax=axis, cax=cax)
1156
+ if options.footer_text is not None:
1157
+ subplot.show_footer(fig, ax)
663
1158
 
1159
+ if output_filename:
1160
+ plt.savefig(output_filename, bbox_inches="tight")
664
1161
 
665
- def generate_log_cbar_ticklabel_list(vmin: float, vmax: float) -> list:
666
- """Create list of log format colorbar label ticks as string"""
667
- return [f"10$^{{{int(i)}}}$" for i in np.arange(vmin, vmax + 1)]
1162
+ if show:
1163
+ plt.show()
668
1164
 
1165
+ return Dimensions(fig, axes)
669
1166
 
670
- def read_location(nc_file: str) -> str:
671
- """Returns site name."""
672
- with netCDF4.Dataset(nc_file) as nc:
673
- site_name = nc.location
674
- return site_name
1167
+ finally:
1168
+ if fig:
1169
+ plt.close(fig)
675
1170
 
676
1171
 
677
- def read_date(nc_file: str) -> date:
678
- """Returns measurement date."""
679
- with netCDF4.Dataset(nc_file) as nc:
680
- case_date = date(int(nc.year), int(nc.month), int(nc.day))
681
- return case_date
1172
+ def lin2log(*args: npt.ArrayLike) -> list[ma.MaskedArray]:
1173
+ return [ma.log10(x) for x in args]
682
1174
 
683
1175
 
684
- def add_subtitle(fig, case_date: date, site_name: str):
685
- """Adds subtitle into figure."""
686
- text = _get_subtitle_text(case_date, site_name)
687
- fig.suptitle(
688
- text,
689
- fontsize=13,
690
- y=0.885,
691
- x=0.07,
692
- horizontalalignment="left",
693
- verticalalignment="bottom",
694
- )
1176
+ def get_log_cbar_tick_labels(value_min: float, value_max: float) -> list[str]:
1177
+ return [f"10$^{{{int(i)}}}$" for i in np.arange(value_min, value_max + 1)]
695
1178
 
696
1179
 
697
- def _get_subtitle_text(case_date: date, site_name: str) -> str:
698
- site_name = site_name.replace("-", " ")
699
- return f"{site_name}, {case_date.strftime('%d %b %Y').lstrip('0')}"
1180
+ def _reformat_units(unit: str) -> str:
1181
+ if unit == "1":
1182
+ return ""
1183
+ return re.sub(r"([a-z])(-?\d+)", r"\1$^{\2}$", unit, flags=re.IGNORECASE)
700
1184
 
701
1185
 
702
- def _create_save_name(
703
- save_path: str, case_date: date, field_names: list, fix: str = ""
704
- ) -> str:
705
- """Creates file name for saved images."""
706
- date_string = case_date.strftime("%Y%m%d")
707
- return f"{save_path}{date_string}_{'_'.join(field_names)}{fix}.png"
1186
+ def _get_max_gap_in_minutes(figure_data: FigureData) -> float:
1187
+ source = getattr(figure_data.file, "source", "").lower()
1188
+ file_type = getattr(figure_data.file, "cloudnet_file_type", "")
1189
+ max_allowed_gap = {
1190
+ "model": 181 if "gdas1" in source or "ecmwf open" in source else 61,
1191
+ "mwr-multi": 35,
1192
+ "weather-station": 12,
1193
+ "doppler-lidar-wind": 75,
1194
+ "doppler-lidar": 75,
1195
+ "radar": 5,
1196
+ "cpr-simulation": 60,
1197
+ }
1198
+ return max_allowed_gap.get(file_type, 10)
708
1199
 
709
1200
 
710
- def _plot_relative_error(ax, error: ma.MaskedArray, ax_values: tuple):
711
- pl = ax.pcolorfast(*ax_values, error[:-1, :-1].T, cmap="RdBu", vmin=-30, vmax=30)
712
- colorbar = _init_colorbar(pl, ax)
713
- colorbar.set_label("%", fontsize=13)
714
- median_error = ma.median(error.compressed())
715
- median_error = np.round(median_error, 3)
716
- ax.set_title(f"Median relative error: {median_error} %", fontsize=14)
1201
+ def _get_zorder(name: str) -> int:
1202
+ zorder = {
1203
+ "contour": 2,
1204
+ "data_gap": 2,
1205
+ "flags": 2,
1206
+ }
1207
+ return zorder.get(name, -1)
717
1208
 
718
1209
 
719
- def lin2log(*args) -> list:
720
- return [ma.log10(x) for x in args]
1210
+ def find_batches_of_ones(array: ndarray) -> list[tuple[int, int]]:
1211
+ """Find batches of ones in a binary array."""
1212
+ starts = np.where(np.diff(np.hstack(([0], array))) == 1)[0]
1213
+ stops = np.where(np.diff(np.hstack((array, [0]))) == -1)[0]
1214
+ return list(zip(starts, stops, strict=True))
721
1215
 
722
1216
 
723
- # Misc plotting routines:
1217
+ def screen_completely_masked_profiles(time: ndarray, data: ma.MaskedArray) -> tuple:
1218
+ if not ma.is_masked(data):
1219
+ return time, data
1220
+ good_ind = np.where(np.any(~data.mask, axis=1))[0]
1221
+ if len(good_ind) == 0:
1222
+ msg = "All values masked in the file."
1223
+ raise PlottingError(msg)
1224
+ good_ind = np.append(good_ind, good_ind[-1] + 1)
1225
+ good_ind = np.clip(good_ind, 0, len(time) - 1)
1226
+ return time[good_ind], data[good_ind, :]
724
1227
 
725
1228
 
726
1229
  def plot_2d(
727
1230
  data: ma.MaskedArray,
728
- cbar: bool = True,
729
1231
  cmap: str = "viridis",
730
1232
  ncolors: int = 50,
731
1233
  clim: tuple | None = None,
732
1234
  ylim: tuple | None = None,
733
1235
  xlim: tuple | None = None,
734
- ):
1236
+ *,
1237
+ cbar: bool = True,
1238
+ ) -> None:
735
1239
  """Simple plot of 2d variable."""
736
1240
  plt.close()
737
1241
  if cbar:
738
- cmap = plt.get_cmap(cmap, ncolors)
1242
+ color_map = plt.get_cmap(cmap, ncolors)
739
1243
  plt.imshow(
740
1244
  ma.masked_equal(data, 0).T,
741
1245
  aspect="auto",
742
1246
  origin="lower",
743
- cmap=cmap,
1247
+ cmap=color_map,
744
1248
  )
745
1249
  plt.colorbar()
746
1250
  else:
747
1251
  plt.imshow(ma.masked_equal(data, 0).T, aspect="auto", origin="lower")
748
1252
  if clim:
749
- plt.clim(clim)
1253
+ plt.clim(clim[0], clim[1])
750
1254
  if ylim is not None:
751
1255
  plt.ylim(ylim)
752
1256
  if xlim is not None:
753
1257
  plt.xlim(xlim)
754
1258
  plt.show()
755
-
756
-
757
- def compare_files(
758
- nc_files: list,
759
- field_name: str,
760
- show: bool = True,
761
- relative_err: bool = False,
762
- save_path: str | None = None,
763
- max_y: int = 12,
764
- dpi: int = 120,
765
- image_name: str | None = None,
766
- ) -> Dimensions:
767
- """Plots one particular field from two Cloudnet files.
768
-
769
- Args:
770
- nc_files (tuple): Filenames of the two files to be compared.
771
- field_name (str): Name of variable to be plotted.
772
- show (bool, optional): If True, shows the plot.
773
- relative_err (bool, optional): If True, plots also relative error. Makes
774
- sense only for continuous variables. Default is False.
775
- save_path (str, optional): If defined, saves the image to this path.
776
- Default is None.
777
- max_y (int, optional): Upper limit of images (km). Default is 12.
778
- dpi (int, optional): Quality of plots. Default is 120.
779
- image_name (str, optional): Name (and full path) of the output image.
780
- Overrides the *save_path* option. Default is None.
781
-
782
- Returns:
783
- Dimensions of the generated figure in pixels.
784
-
785
- """
786
- plot_type = ATTRIBUTES[field_name].plot_type
787
- fields = [_find_valid_fields(file, [field_name])[0][0] for file in nc_files]
788
- nc = netCDF4.Dataset(nc_files[0])
789
- nc.close()
790
- ax_values = [_read_ax_values(nc_file) for nc_file in nc_files]
791
- subtitle = (
792
- f" - {os.path.basename(nc_files[0])}",
793
- f" - {os.path.basename(nc_files[0])}",
794
- )
795
- n_subs = 3 if relative_err is True else 2
796
- fig, axes = _initialize_figure(n_subs, dpi)
797
-
798
- for ii, ax in enumerate(axes[:2]):
799
- field, ax_value = _screen_high_altitudes(fields[ii], ax_values[ii], max_y)
800
- set_ax(ax, max_y, ylabel=None)
801
- _set_title(ax, field_name, subtitle[ii])
802
-
803
- if plot_type == "model":
804
- _plot_colormesh_data(ax, field, field_name, ax_value)
805
- elif plot_type == "bar":
806
- unit = _get_variable_unit(nc_files[ii], field_name)
807
- _plot_bar_data(ax, field, ax_value[0], unit)
808
- set_ax(ax, 2, ATTRIBUTES[field_name].ylabel)
809
- elif plot_type == "segment":
810
- _plot_segment_data(ax, field, field_name, ax_value)
811
- else:
812
- _plot_colormesh_data(ax, field, field_name, ax_value)
813
- if relative_err is True and ii == 1:
814
- set_ax(axes[-1], max_y, ylabel=None)
815
- error, ax_value = _get_relative_error(fields, ax_values, max_y)
816
- _plot_relative_error(axes[-1], error, ax_value)
817
-
818
- case_date = set_labels(fig, axes[-1], nc_files[0], sub_title=False)
819
- handle_saving(image_name, save_path, show, case_date, [field_name], "_comparison")
820
- return Dimensions(fig, axes)