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.
- cloudnetpy/categorize/__init__.py +1 -2
- cloudnetpy/categorize/atmos_utils.py +297 -67
- cloudnetpy/categorize/attenuation.py +31 -0
- cloudnetpy/categorize/attenuations/__init__.py +37 -0
- cloudnetpy/categorize/attenuations/gas_attenuation.py +30 -0
- cloudnetpy/categorize/attenuations/liquid_attenuation.py +84 -0
- cloudnetpy/categorize/attenuations/melting_attenuation.py +78 -0
- cloudnetpy/categorize/attenuations/rain_attenuation.py +84 -0
- cloudnetpy/categorize/categorize.py +332 -156
- cloudnetpy/categorize/classify.py +127 -125
- cloudnetpy/categorize/containers.py +107 -76
- cloudnetpy/categorize/disdrometer.py +40 -0
- cloudnetpy/categorize/droplet.py +23 -21
- cloudnetpy/categorize/falling.py +53 -24
- cloudnetpy/categorize/freezing.py +25 -12
- cloudnetpy/categorize/insects.py +35 -23
- cloudnetpy/categorize/itu.py +243 -0
- cloudnetpy/categorize/lidar.py +36 -41
- cloudnetpy/categorize/melting.py +34 -26
- cloudnetpy/categorize/model.py +84 -37
- cloudnetpy/categorize/mwr.py +18 -14
- cloudnetpy/categorize/radar.py +215 -102
- cloudnetpy/cli.py +578 -0
- cloudnetpy/cloudnetarray.py +43 -89
- cloudnetpy/concat_lib.py +218 -78
- cloudnetpy/constants.py +28 -10
- cloudnetpy/datasource.py +61 -86
- cloudnetpy/exceptions.py +49 -20
- cloudnetpy/instruments/__init__.py +5 -0
- cloudnetpy/instruments/basta.py +29 -12
- cloudnetpy/instruments/bowtie.py +135 -0
- cloudnetpy/instruments/ceilo.py +138 -115
- cloudnetpy/instruments/ceilometer.py +164 -80
- cloudnetpy/instruments/cl61d.py +21 -5
- cloudnetpy/instruments/cloudnet_instrument.py +74 -36
- cloudnetpy/instruments/copernicus.py +108 -30
- cloudnetpy/instruments/da10.py +54 -0
- cloudnetpy/instruments/disdrometer/common.py +126 -223
- cloudnetpy/instruments/disdrometer/parsivel.py +453 -94
- cloudnetpy/instruments/disdrometer/thies.py +254 -87
- cloudnetpy/instruments/fd12p.py +201 -0
- cloudnetpy/instruments/galileo.py +65 -23
- cloudnetpy/instruments/hatpro.py +123 -49
- cloudnetpy/instruments/instruments.py +113 -1
- cloudnetpy/instruments/lufft.py +39 -17
- cloudnetpy/instruments/mira.py +268 -61
- cloudnetpy/instruments/mrr.py +187 -0
- cloudnetpy/instruments/nc_lidar.py +19 -8
- cloudnetpy/instruments/nc_radar.py +109 -55
- cloudnetpy/instruments/pollyxt.py +135 -51
- cloudnetpy/instruments/radiometrics.py +313 -59
- cloudnetpy/instruments/rain_e_h3.py +171 -0
- cloudnetpy/instruments/rpg.py +321 -189
- cloudnetpy/instruments/rpg_reader.py +74 -40
- cloudnetpy/instruments/toa5.py +49 -0
- cloudnetpy/instruments/vaisala.py +95 -343
- cloudnetpy/instruments/weather_station.py +774 -105
- cloudnetpy/metadata.py +90 -19
- cloudnetpy/model_evaluation/file_handler.py +55 -52
- cloudnetpy/model_evaluation/metadata.py +46 -20
- cloudnetpy/model_evaluation/model_metadata.py +1 -1
- cloudnetpy/model_evaluation/plotting/plot_tools.py +32 -37
- cloudnetpy/model_evaluation/plotting/plotting.py +327 -117
- cloudnetpy/model_evaluation/products/advance_methods.py +92 -83
- cloudnetpy/model_evaluation/products/grid_methods.py +88 -63
- cloudnetpy/model_evaluation/products/model_products.py +43 -35
- cloudnetpy/model_evaluation/products/observation_products.py +41 -35
- cloudnetpy/model_evaluation/products/product_resampling.py +17 -7
- cloudnetpy/model_evaluation/products/tools.py +29 -20
- cloudnetpy/model_evaluation/statistics/statistical_methods.py +30 -20
- cloudnetpy/model_evaluation/tests/e2e/conftest.py +3 -3
- cloudnetpy/model_evaluation/tests/e2e/process_cf/main.py +9 -5
- cloudnetpy/model_evaluation/tests/e2e/process_cf/tests.py +15 -14
- cloudnetpy/model_evaluation/tests/e2e/process_iwc/main.py +9 -5
- cloudnetpy/model_evaluation/tests/e2e/process_iwc/tests.py +15 -14
- cloudnetpy/model_evaluation/tests/e2e/process_lwc/main.py +9 -5
- cloudnetpy/model_evaluation/tests/e2e/process_lwc/tests.py +15 -14
- cloudnetpy/model_evaluation/tests/unit/conftest.py +42 -41
- cloudnetpy/model_evaluation/tests/unit/test_advance_methods.py +41 -48
- cloudnetpy/model_evaluation/tests/unit/test_grid_methods.py +216 -194
- cloudnetpy/model_evaluation/tests/unit/test_model_products.py +23 -21
- cloudnetpy/model_evaluation/tests/unit/test_observation_products.py +37 -38
- cloudnetpy/model_evaluation/tests/unit/test_plot_tools.py +43 -40
- cloudnetpy/model_evaluation/tests/unit/test_plotting.py +30 -36
- cloudnetpy/model_evaluation/tests/unit/test_statistical_methods.py +68 -31
- cloudnetpy/model_evaluation/tests/unit/test_tools.py +33 -26
- cloudnetpy/model_evaluation/utils.py +2 -1
- cloudnetpy/output.py +170 -111
- cloudnetpy/plotting/__init__.py +2 -1
- cloudnetpy/plotting/plot_meta.py +562 -822
- cloudnetpy/plotting/plotting.py +1142 -704
- cloudnetpy/products/__init__.py +1 -0
- cloudnetpy/products/classification.py +370 -88
- cloudnetpy/products/der.py +85 -55
- cloudnetpy/products/drizzle.py +77 -34
- cloudnetpy/products/drizzle_error.py +15 -11
- cloudnetpy/products/drizzle_tools.py +79 -59
- cloudnetpy/products/epsilon.py +211 -0
- cloudnetpy/products/ier.py +27 -50
- cloudnetpy/products/iwc.py +55 -48
- cloudnetpy/products/lwc.py +96 -70
- cloudnetpy/products/mwr_tools.py +186 -0
- cloudnetpy/products/product_tools.py +170 -128
- cloudnetpy/utils.py +455 -240
- cloudnetpy/version.py +2 -2
- {cloudnetpy-1.49.9.dist-info → cloudnetpy-1.87.3.dist-info}/METADATA +44 -40
- cloudnetpy-1.87.3.dist-info/RECORD +127 -0
- {cloudnetpy-1.49.9.dist-info → cloudnetpy-1.87.3.dist-info}/WHEEL +1 -1
- cloudnetpy-1.87.3.dist-info/entry_points.txt +2 -0
- docs/source/conf.py +2 -2
- cloudnetpy/categorize/atmos.py +0 -361
- cloudnetpy/products/mwr_multi.py +0 -68
- cloudnetpy/products/mwr_single.py +0 -75
- cloudnetpy-1.49.9.dist-info/RECORD +0 -112
- {cloudnetpy-1.49.9.dist-info → cloudnetpy-1.87.3.dist-info/licenses}/LICENSE +0 -0
- {cloudnetpy-1.49.9.dist-info → cloudnetpy-1.87.3.dist-info}/top_level.txt +0 -0
cloudnetpy/plotting/plotting.py
CHANGED
|
@@ -1,39 +1,100 @@
|
|
|
1
1
|
"""Misc. plotting routines for Cloudnet products."""
|
|
2
|
-
|
|
3
|
-
|
|
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.
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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__(
|
|
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(
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
222
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
337
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
|
366
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
1114
|
+
with netCDF4.Dataset(filename) as file:
|
|
1115
|
+
figure_data = FigureData(file, variables, options)
|
|
1116
|
+
fig, axes = figure_data.initialize_figure()
|
|
437
1117
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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
|
-
|
|
641
|
-
|
|
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
|
-
|
|
651
|
-
|
|
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
|
-
|
|
660
|
-
|
|
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
|
-
|
|
666
|
-
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
site_name = nc.location
|
|
674
|
-
return site_name
|
|
1167
|
+
finally:
|
|
1168
|
+
if fig:
|
|
1169
|
+
plt.close(fig)
|
|
675
1170
|
|
|
676
1171
|
|
|
677
|
-
def
|
|
678
|
-
|
|
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
|
|
685
|
-
""
|
|
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
|
|
698
|
-
|
|
699
|
-
|
|
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
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
|
720
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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)
|