cloudnetpy 1.55.20__py3-none-any.whl → 1.55.22__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 (95) hide show
  1. cloudnetpy/categorize/atmos.py +46 -14
  2. cloudnetpy/categorize/atmos_utils.py +11 -1
  3. cloudnetpy/categorize/categorize.py +38 -21
  4. cloudnetpy/categorize/classify.py +31 -9
  5. cloudnetpy/categorize/containers.py +19 -7
  6. cloudnetpy/categorize/droplet.py +24 -8
  7. cloudnetpy/categorize/falling.py +17 -7
  8. cloudnetpy/categorize/freezing.py +19 -5
  9. cloudnetpy/categorize/insects.py +27 -14
  10. cloudnetpy/categorize/lidar.py +38 -36
  11. cloudnetpy/categorize/melting.py +19 -9
  12. cloudnetpy/categorize/model.py +28 -9
  13. cloudnetpy/categorize/mwr.py +4 -2
  14. cloudnetpy/categorize/radar.py +58 -22
  15. cloudnetpy/cloudnetarray.py +15 -6
  16. cloudnetpy/concat_lib.py +39 -16
  17. cloudnetpy/constants.py +7 -0
  18. cloudnetpy/datasource.py +39 -19
  19. cloudnetpy/instruments/basta.py +6 -2
  20. cloudnetpy/instruments/campbell_scientific.py +33 -16
  21. cloudnetpy/instruments/ceilo.py +30 -13
  22. cloudnetpy/instruments/ceilometer.py +76 -37
  23. cloudnetpy/instruments/cl61d.py +8 -3
  24. cloudnetpy/instruments/cloudnet_instrument.py +2 -1
  25. cloudnetpy/instruments/copernicus.py +27 -14
  26. cloudnetpy/instruments/disdrometer/common.py +51 -32
  27. cloudnetpy/instruments/disdrometer/parsivel.py +79 -48
  28. cloudnetpy/instruments/disdrometer/thies.py +10 -6
  29. cloudnetpy/instruments/galileo.py +23 -12
  30. cloudnetpy/instruments/hatpro.py +27 -11
  31. cloudnetpy/instruments/instruments.py +4 -1
  32. cloudnetpy/instruments/lufft.py +20 -11
  33. cloudnetpy/instruments/mira.py +60 -49
  34. cloudnetpy/instruments/mrr.py +31 -20
  35. cloudnetpy/instruments/nc_lidar.py +15 -6
  36. cloudnetpy/instruments/nc_radar.py +31 -22
  37. cloudnetpy/instruments/pollyxt.py +36 -21
  38. cloudnetpy/instruments/radiometrics.py +32 -18
  39. cloudnetpy/instruments/rpg.py +48 -22
  40. cloudnetpy/instruments/rpg_reader.py +39 -30
  41. cloudnetpy/instruments/vaisala.py +39 -27
  42. cloudnetpy/instruments/weather_station.py +15 -11
  43. cloudnetpy/metadata.py +3 -1
  44. cloudnetpy/model_evaluation/file_handler.py +31 -21
  45. cloudnetpy/model_evaluation/metadata.py +3 -1
  46. cloudnetpy/model_evaluation/model_metadata.py +1 -1
  47. cloudnetpy/model_evaluation/plotting/plot_tools.py +20 -15
  48. cloudnetpy/model_evaluation/plotting/plotting.py +114 -64
  49. cloudnetpy/model_evaluation/products/advance_methods.py +48 -28
  50. cloudnetpy/model_evaluation/products/grid_methods.py +44 -19
  51. cloudnetpy/model_evaluation/products/model_products.py +22 -18
  52. cloudnetpy/model_evaluation/products/observation_products.py +15 -9
  53. cloudnetpy/model_evaluation/products/product_resampling.py +14 -4
  54. cloudnetpy/model_evaluation/products/tools.py +16 -7
  55. cloudnetpy/model_evaluation/statistics/statistical_methods.py +28 -15
  56. cloudnetpy/model_evaluation/tests/e2e/conftest.py +3 -3
  57. cloudnetpy/model_evaluation/tests/e2e/process_cf/main.py +9 -5
  58. cloudnetpy/model_evaluation/tests/e2e/process_cf/tests.py +14 -13
  59. cloudnetpy/model_evaluation/tests/e2e/process_iwc/main.py +9 -5
  60. cloudnetpy/model_evaluation/tests/e2e/process_iwc/tests.py +14 -13
  61. cloudnetpy/model_evaluation/tests/e2e/process_lwc/main.py +9 -5
  62. cloudnetpy/model_evaluation/tests/e2e/process_lwc/tests.py +14 -13
  63. cloudnetpy/model_evaluation/tests/unit/conftest.py +11 -11
  64. cloudnetpy/model_evaluation/tests/unit/test_advance_methods.py +33 -27
  65. cloudnetpy/model_evaluation/tests/unit/test_grid_methods.py +83 -83
  66. cloudnetpy/model_evaluation/tests/unit/test_model_products.py +23 -21
  67. cloudnetpy/model_evaluation/tests/unit/test_observation_products.py +24 -25
  68. cloudnetpy/model_evaluation/tests/unit/test_plot_tools.py +40 -39
  69. cloudnetpy/model_evaluation/tests/unit/test_plotting.py +12 -11
  70. cloudnetpy/model_evaluation/tests/unit/test_statistical_methods.py +30 -30
  71. cloudnetpy/model_evaluation/tests/unit/test_tools.py +18 -17
  72. cloudnetpy/model_evaluation/utils.py +3 -2
  73. cloudnetpy/output.py +45 -19
  74. cloudnetpy/plotting/plot_meta.py +35 -11
  75. cloudnetpy/plotting/plotting.py +172 -104
  76. cloudnetpy/products/classification.py +20 -8
  77. cloudnetpy/products/der.py +25 -10
  78. cloudnetpy/products/drizzle.py +41 -26
  79. cloudnetpy/products/drizzle_error.py +10 -5
  80. cloudnetpy/products/drizzle_tools.py +43 -24
  81. cloudnetpy/products/ier.py +10 -5
  82. cloudnetpy/products/iwc.py +16 -9
  83. cloudnetpy/products/lwc.py +34 -12
  84. cloudnetpy/products/mwr_multi.py +4 -1
  85. cloudnetpy/products/mwr_single.py +4 -1
  86. cloudnetpy/products/product_tools.py +33 -10
  87. cloudnetpy/utils.py +175 -74
  88. cloudnetpy/version.py +1 -1
  89. {cloudnetpy-1.55.20.dist-info → cloudnetpy-1.55.22.dist-info}/METADATA +11 -10
  90. cloudnetpy-1.55.22.dist-info/RECORD +114 -0
  91. docs/source/conf.py +2 -2
  92. cloudnetpy-1.55.20.dist-info/RECORD +0 -114
  93. {cloudnetpy-1.55.20.dist-info → cloudnetpy-1.55.22.dist-info}/LICENSE +0 -0
  94. {cloudnetpy-1.55.20.dist-info → cloudnetpy-1.55.22.dist-info}/WHEEL +0 -0
  95. {cloudnetpy-1.55.20.dist-info → cloudnetpy-1.55.22.dist-info}/top_level.txt +0 -0
@@ -24,10 +24,12 @@ class ClassData:
24
24
  """Container for observations that are used in the classification.
25
25
 
26
26
  Args:
27
+ ----
27
28
  data: Containing :class:`Radar`, :class:`Lidar`, :class:`Model`
28
29
  and :class:`Mwr` instances.
29
30
 
30
31
  Attributes:
32
+ ----------
31
33
  z (ndarray): 2D radar echo.
32
34
  ldr (ndarray): 2D linear depolarization ratio.
33
35
  v (ndarray): 2D radar velocity.
@@ -52,13 +54,13 @@ class ClassData:
52
54
  self.v = data["radar"].data["v"][:]
53
55
  self.v_sigma = data["radar"].data["v_sigma"][:]
54
56
  for key in ("width", "ldr", "sldr"):
55
- if key in data["radar"].data.keys():
57
+ if key in data["radar"].data:
56
58
  setattr(self, key, data["radar"].data[key][:])
57
59
  self.time = data["radar"].time
58
60
  self.height = data["radar"].height
59
- self.radar_type = data["radar"].type
61
+ self.radar_type = data["radar"].source_type
60
62
  self.tw = data["model"].data["Tw"][:]
61
- self.model_type = data["model"].type
63
+ self.model_type = data["model"].source_type
62
64
  self.beta = data["lidar"].data["beta"][:]
63
65
  self.lwp = data["mwr"].data["lwp"][:]
64
66
  self.is_rain = _find_rain_from_radar_echo(self.z, self.time)
@@ -70,7 +72,9 @@ class ClassData:
70
72
 
71
73
 
72
74
  def _find_rain_from_radar_echo(
73
- z: np.ndarray, time: np.ndarray, time_buffer: int = 5
75
+ z: np.ndarray,
76
+ time: np.ndarray,
77
+ time_buffer: int = 5,
74
78
  ) -> np.ndarray:
75
79
  """Find profiles affected by rain.
76
80
 
@@ -80,17 +84,22 @@ def _find_rain_from_radar_echo(
80
84
  detections as raining.
81
85
 
82
86
  Args:
87
+ ----
83
88
  z: Radar echo.
84
89
  time: Time vector.
85
90
  time_buffer: Time in minutes.
86
91
 
87
92
  Returns:
93
+ -------
88
94
  1D Boolean array denoting profiles with rain.
89
95
 
90
96
  """
91
- is_rain = ma.array(z[:, 3] > 0, dtype=bool).filled(False)
97
+ filled = False
98
+ is_rain = ma.array(z[:, 3] > 0, dtype=bool).filled(filled)
92
99
  is_rain = skimage.morphology.remove_small_objects(
93
- is_rain, 2, connectivity=1
100
+ is_rain,
101
+ 2,
102
+ connectivity=1,
94
103
  ) # Filter hot pixels
95
104
  n_profiles = len(time)
96
105
  n_steps = utils.n_elements(time, time_buffer, "time")
@@ -122,16 +131,19 @@ def _find_clutter(
122
131
  """Estimates clutter from doppler velocity.
123
132
 
124
133
  Args:
134
+ ----
125
135
  n_gates: Number of range gates from the ground where clutter is expected
126
136
  to be found. Default is 10.
127
137
  v_lim: Velocity threshold. Smaller values are classified as clutter.
128
138
  Default is 0.05 (m/s).
129
139
 
130
140
  Returns:
141
+ -------
131
142
  2-D boolean array denoting pixels contaminated by clutter.
132
143
 
133
144
  """
134
145
  is_clutter = np.zeros(v.shape, dtype=bool)
135
- tiny_velocity = (np.abs(v[:, :n_gates]) < v_lim).filled(False)
146
+ filled = False
147
+ tiny_velocity = (np.abs(v[:, :n_gates]) < v_lim).filled(filled)
136
148
  is_clutter[:, :n_gates] = tiny_velocity * utils.transpose(~is_rain)
137
149
  return is_clutter
@@ -1,4 +1,4 @@
1
- """ This module has functions for liquid layer detection.
1
+ """This module has functions for liquid layer detection.
2
2
  """
3
3
  import numpy as np
4
4
  import scipy.signal
@@ -18,6 +18,7 @@ def correct_liquid_top(
18
18
  """Corrects lidar detected liquid cloud top using radar data.
19
19
 
20
20
  Args:
21
+ ----
21
22
  obs: The :class:`ClassData` instance.
22
23
  is_liquid: 2-D boolean array denoting liquid clouds from lidar data.
23
24
  is_freezing: 2-D boolean array of sub-zero temperature, derived from the model
@@ -25,16 +26,18 @@ def correct_liquid_top(
25
26
  limit: The maximum correction distance (m) above liquid cloud top.
26
27
 
27
28
  Returns:
29
+ -------
28
30
  Corrected liquid cloud array.
29
31
 
30
32
  References:
33
+ ----------
31
34
  Hogan R. and O'Connor E., 2004, https://bit.ly/2Yjz9DZ.
32
35
 
33
36
  """
34
37
  is_liquid_corrected = np.copy(is_liquid)
35
38
  liquid_tops = cloudnetpy.categorize.atmos.find_cloud_tops(is_liquid)
36
39
  top_above = utils.n_elements(obs.height, limit)
37
- for prof, top in zip(*np.where(liquid_tops)):
40
+ for prof, top in zip(*np.where(liquid_tops), strict=True):
38
41
  ind = _find_ind_above_top(is_freezing[prof, top:], top_above)
39
42
  rad = obs.z[prof, top : top + ind + 1]
40
43
  if not (rad.mask.all() or ~rad.mask.any()):
@@ -61,6 +64,7 @@ def find_liquid(
61
64
  """Estimate liquid layers from SNR-screened attenuated backscatter.
62
65
 
63
66
  Args:
67
+ ----
64
68
  obs: The :class:`ClassData` instance.
65
69
  peak_amp: Minimum value of peak. Default is 1e-6.
66
70
  max_width: Maximum width of peak. Default is 300 (m).
@@ -72,15 +76,17 @@ def find_liquid(
72
76
  min_alt: Minimum altitude of the peak from the ground. Default is 100 (m).
73
77
 
74
78
  Returns:
79
+ -------
75
80
  2-D boolean array denoting liquid layers.
76
81
 
77
82
  References:
83
+ ----------
78
84
  The method is based on Tuononen, M. et.al, 2019,
79
85
  https://acp.copernicus.org/articles/19/1985/2019/.
80
86
 
81
87
  """
82
88
 
83
- def _is_proper_peak():
89
+ def _is_proper_peak() -> bool:
84
90
  conditions = (
85
91
  npoints >= min_points,
86
92
  peak_width < max_width,
@@ -97,13 +103,12 @@ def find_liquid(
97
103
  is_liquid = np.zeros(beta.shape, dtype=bool)
98
104
  base_below_peak = utils.n_elements(height, 200)
99
105
  top_above_peak = utils.n_elements(height, 150)
100
- difference = np.diff(beta, axis=1)
101
- assert isinstance(difference, ma.MaskedArray)
106
+ difference = ma.array(np.diff(beta, axis=1))
102
107
  beta_diff = difference.filled(0)
103
108
  beta = beta.filled(0)
104
109
  peak_indices = _find_strong_peaks(beta, peak_amp)
105
110
 
106
- for n, peak in zip(*peak_indices):
111
+ for n, peak in zip(*peak_indices, strict=True):
107
112
  lprof = beta[n, :]
108
113
  dprof = beta_diff[n, :]
109
114
  try:
@@ -129,6 +134,7 @@ def ind_base(dprof: np.ndarray, ind_peak: int, dist: int, lim: float) -> int:
129
134
  below the peak exceed a threshold value.
130
135
 
131
136
  Args:
137
+ ----
132
138
  dprof: 1-D array of 1st discrete difference. Masked values should
133
139
  be 0, e.g. dprof = np.diff(masked_prof).filled(0)
134
140
  ind_peak: Index of (possibly local) peak in the original profile.
@@ -143,13 +149,16 @@ def ind_base(dprof: np.ndarray, ind_peak: int, dist: int, lim: float) -> int:
143
149
  in the profile.
144
150
 
145
151
  Returns:
152
+ -------
146
153
  Base index of the peak.
147
154
 
148
155
  Raises:
156
+ ------
149
157
  IndexError: Can't find proper base index (probably too many masked
150
158
  values in the profile).
151
159
 
152
160
  Examples:
161
+ --------
153
162
  Consider a profile
154
163
 
155
164
  >>> x = np.array([0, 0.5, 1, -99, 4, 8, 5])
@@ -177,7 +186,8 @@ def ind_base(dprof: np.ndarray, ind_peak: int, dist: int, lim: float) -> int:
177
186
  >>> ind_base(dx, 5, 4, 10)
178
187
  1
179
188
 
180
- See also:
189
+ See Also:
190
+ --------
181
191
  droplet.ind_top()
182
192
 
183
193
  """
@@ -194,6 +204,7 @@ def ind_top(dprof: np.ndarray, ind_peak: int, nprof: int, dist: int, lim: float)
194
204
  above the peak exceed a threshold value.
195
205
 
196
206
  Args:
207
+ ----
197
208
  dprof: 1-D array of 1st discrete difference. Masked values should be 0, e.g.
198
209
  dprof = np.diff(masked_prof).filled(0)
199
210
  nprof: Length of the profile. Top index can't be higher than this.
@@ -207,13 +218,16 @@ def ind_top(dprof: np.ndarray, ind_peak: int, nprof: int, dist: int, lim: float)
207
218
  likely accept some other point, higher in the profile.
208
219
 
209
220
  Returns:
221
+ -------
210
222
  Top index of the peak.
211
223
 
212
224
  Raises:
225
+ ------
213
226
  IndexError: Can not find proper top index (probably too many masked
214
227
  values in the profile).
215
228
 
216
- See also:
229
+ See Also:
230
+ --------
217
231
  droplet.ind_base()
218
232
 
219
233
  """
@@ -227,9 +241,11 @@ def interpolate_lwp(obs: ClassData) -> np.ndarray:
227
241
  """Linear interpolation of liquid water path to fill masked values.
228
242
 
229
243
  Args:
244
+ ----
230
245
  obs: The :class:`ClassData` instance.
231
246
 
232
247
  Returns:
248
+ -------
233
249
  Liquid water path where the masked values are filled by interpolation.
234
250
 
235
251
  """
@@ -8,7 +8,9 @@ from cloudnetpy.constants import T0
8
8
 
9
9
 
10
10
  def find_falling_hydrometeors(
11
- obs: ClassData, is_liquid: np.ndarray, is_insects: np.ndarray
11
+ obs: ClassData,
12
+ is_liquid: np.ndarray,
13
+ is_insects: np.ndarray,
12
14
  ) -> np.ndarray:
13
15
  """Finds falling hydrometeors.
14
16
 
@@ -19,21 +21,25 @@ def find_falling_hydrometeors(
19
21
  temperatures.
20
22
 
21
23
  Args:
24
+ ----
22
25
  obs: The :class:`ClassData` instance.
23
26
  is_liquid: 2-D boolean array of liquid droplets.
24
27
  is_insects: 2-D boolean array of insects.
25
28
 
26
29
  Returns:
30
+ -------
27
31
  2-D boolean array containing falling hydrometeors.
28
32
 
29
33
  References:
34
+ ----------
30
35
  Hogan R. and O'Connor E., 2004, https://bit.ly/2Yjz9DZ.
31
36
 
32
37
  """
33
-
34
38
  falling_from_radar = _find_falling_from_radar(obs, is_insects)
35
39
  falling_from_radar_fixed = _fix_liquid_dominated_radar(
36
- obs, falling_from_radar, is_liquid
40
+ obs,
41
+ falling_from_radar,
42
+ is_liquid,
37
43
  )
38
44
  cold_aerosols = _find_cold_aerosols(obs, is_liquid)
39
45
  return falling_from_radar_fixed | cold_aerosols
@@ -60,7 +66,7 @@ def _find_cold_aerosols(obs: ClassData, is_liquid: np.ndarray) -> np.ndarray:
60
66
  cold_aerosol_min_altitude = 2000
61
67
  is_beta = ~obs.beta.mask
62
68
  lidar_ice_indices = np.where(
63
- (obs.tw.data < cold_aerosol_temperature_limit) & is_beta & ~is_liquid
69
+ (obs.tw.data < cold_aerosol_temperature_limit) & is_beta & ~is_liquid,
64
70
  )
65
71
  cold_aerosols[lidar_ice_indices] = True
66
72
  low_range_indices = np.where(lidar_range < cold_aerosol_min_altitude)
@@ -85,7 +91,9 @@ def _find_cold_aerosols(obs: ClassData, is_liquid: np.ndarray) -> np.ndarray:
85
91
 
86
92
 
87
93
  def _fix_liquid_dominated_radar(
88
- obs: ClassData, falling_from_radar: np.ndarray, is_liquid: np.ndarray
94
+ obs: ClassData,
95
+ falling_from_radar: np.ndarray,
96
+ is_liquid: np.ndarray,
89
97
  ) -> np.ndarray:
90
98
  """Radar signals inside liquid clouds are NOT ice if Z is
91
99
  increasing in height inside the cloud.
@@ -95,10 +103,12 @@ def _fix_liquid_dominated_radar(
95
103
  base_indices = np.where(liquid_bases)
96
104
  top_indices = np.where(liquid_tops)
97
105
 
98
- for n, base, _, top in zip(*base_indices, *top_indices):
106
+ for n, base, _, top in zip(*base_indices, *top_indices, strict=True):
99
107
  z_prof = obs.z[n, :]
100
108
  if _is_z_missing_above_liquid(z_prof, top) and _is_z_increasing(
101
- z_prof, base, top
109
+ z_prof,
110
+ base,
111
+ top,
102
112
  ):
103
113
  falling_from_radar[n, base : top + 1] = False
104
114
 
@@ -20,13 +20,16 @@ def find_freezing_region(obs: ClassData, melting_layer: np.ndarray) -> np.ndarra
20
20
  interpolated for all profiles.
21
21
 
22
22
  Args:
23
+ ----
23
24
  obs: The :class:`ClassData` instance.
24
25
  melting_layer: 2-D boolean array denoting melting layer.
25
26
 
26
27
  Returns:
28
+ -------
27
29
  2-D boolean array denoting the sub-zero region.
28
30
 
29
31
  Notes:
32
+ -----
30
33
  It is not clear how model temperature and melting layer should be
31
34
  ideally combined to determine the sub-zero region. This current
32
35
  method differs slightly from the original Matlab code and should
@@ -59,7 +62,9 @@ def find_freezing_region(obs: ClassData, melting_layer: np.ndarray) -> np.ndarra
59
62
 
60
63
 
61
64
  def _is_all_freezing(
62
- mean_melting_alt: np.ndarray, t0_alt: np.ndarray, height: np.ndarray
65
+ mean_melting_alt: np.ndarray,
66
+ t0_alt: np.ndarray,
67
+ height: np.ndarray,
63
68
  ) -> bool:
64
69
  no_detected_melting = mean_melting_alt.all() is ma.masked
65
70
  all_temperatures_below_freezing = (t0_alt <= height[0]).all()
@@ -67,7 +72,9 @@ def _is_all_freezing(
67
72
 
68
73
 
69
74
  def _find_mean_melting_alt(obs: ClassData, melting_layer: np.ndarray) -> ma.MaskedArray:
70
- assert melting_layer.dtype == bool
75
+ if melting_layer.dtype != bool:
76
+ msg = "melting_layer data type should be boolean"
77
+ raise ValueError(msg)
71
78
  alt_array = np.tile(obs.height, (len(obs.time), 1))
72
79
  melting_alts = ma.array(alt_array, mask=~melting_layer)
73
80
  return ma.median(melting_alts, axis=1)
@@ -77,10 +84,12 @@ def _find_t0_alt(temperature: np.ndarray, height: np.ndarray) -> np.ndarray:
77
84
  """Interpolates altitudes where temperature goes below freezing.
78
85
 
79
86
  Args:
87
+ ----
80
88
  temperature: 2-D temperature (K).
81
89
  height: 1-D altitude grid (m).
82
90
 
83
91
  Returns:
92
+ -------
84
93
  1-D array denoting altitudes where the temperature drops below 0 deg C.
85
94
 
86
95
  """
@@ -90,8 +99,13 @@ def _find_t0_alt(temperature: np.ndarray, height: np.ndarray) -> np.ndarray:
90
99
  if ind == 0:
91
100
  alt = np.append(alt, height[0])
92
101
  else:
93
- x = prof[ind - 1 : ind + 1]
94
- y = height[ind - 1 : ind + 1]
95
- x, y = zip(*sorted(zip(x, y)))
102
+ x, y = zip(
103
+ *sorted(
104
+ zip(
105
+ prof[ind - 1 : ind + 1], height[ind - 1 : ind + 1], strict=True
106
+ ),
107
+ ),
108
+ strict=True,
109
+ )
96
110
  alt = np.append(alt, np.interp(T0, x, y))
97
111
  return alt
@@ -29,6 +29,7 @@ def find_insects(
29
29
  above melting layer.
30
30
 
31
31
  Args:
32
+ ----
32
33
  obs: The :class:`ClassData` instance.
33
34
  melting_layer: 2D array denoting melting layer.
34
35
  liquid_layers: 2D array denoting liquid layers.
@@ -36,12 +37,14 @@ def find_insects(
36
37
  Default is 0.8.
37
38
 
38
39
  Returns:
40
+ -------
39
41
  tuple: 2-element tuple containing
40
42
 
41
43
  - 2-D boolean flag of insects presence.
42
44
  - 2-D probability of pixel containing insects.
43
45
 
44
46
  Notes:
47
+ -----
45
48
  This insect detection method is novel and needs to be validated.
46
49
 
47
50
  """
@@ -65,9 +68,9 @@ def _get_probabilities(obs: ClassData) -> dict:
65
68
  lwp_interp = droplet.interpolate_lwp(obs)
66
69
  fun = utils.array_to_probability
67
70
  return {
68
- "width": fun(obs.width, 1, 0.3, True) if hasattr(obs, "width") else 1,
69
- "z_strong": fun(obs.z, 0, 8, True),
70
- "z_weak": fun(obs.z, -20, 8, True),
71
+ "width": fun(obs.width, 1, 0.3, invert=True) if hasattr(obs, "width") else 1,
72
+ "z_strong": fun(obs.z, 0, 8, invert=True),
73
+ "z_weak": fun(obs.z, -20, 8, invert=True),
71
74
  "ldr": fun(obs.ldr, -25, 5) if hasattr(obs, "ldr") else None,
72
75
  "sldr": fun(obs.sldr, -25, 5) if hasattr(obs, "sldr") else None,
73
76
  "temp_loose": fun(obs.tw, 268, 2),
@@ -79,11 +82,11 @@ def _get_probabilities(obs: ClassData) -> dict:
79
82
 
80
83
 
81
84
  def _get_smoothed_v(
82
- obs: ClassData, sigma: tuple[float, float] = (5, 5)
85
+ obs: ClassData,
86
+ sigma: tuple[float, float] = (5, 5),
83
87
  ) -> ma.MaskedArray:
84
88
  smoothed_v = gaussian_filter(obs.v, sigma)
85
- smoothed_v = ma.masked_where(obs.v.mask, smoothed_v)
86
- return smoothed_v
89
+ return ma.masked_where(obs.v.mask, smoothed_v)
87
90
 
88
91
 
89
92
  def _calc_prob_from_ldr(prob: dict) -> np.ndarray:
@@ -102,12 +105,15 @@ def _calc_prob_from_ldr(prob: dict) -> np.ndarray:
102
105
  def _calc_prob_from_all(prob: dict) -> np.ndarray:
103
106
  """This can be tried when LDR is not available. To detect insects without LDR
104
107
  unambiguously is difficult and might result in many false positives and/or false
105
- negatives."""
108
+ negatives.
109
+ """
106
110
  return prob["z_weak"] * prob["temp_strict"] * prob["width"] * prob["v"]
107
111
 
108
112
 
109
113
  def _adjust_for_radar(
110
- obs: ClassData, prob: dict, prob_from_others: np.ndarray
114
+ obs: ClassData,
115
+ prob: dict,
116
+ prob_from_others: np.ndarray,
111
117
  ) -> np.ndarray:
112
118
  """Adds radar-specific weighting to insect probabilities."""
113
119
  if "mira" in obs.radar_type.lower():
@@ -116,7 +122,8 @@ def _adjust_for_radar(
116
122
 
117
123
 
118
124
  def _fill_missing_pixels(
119
- prob_from_ldr: np.ndarray, prob_from_others: np.ndarray
125
+ prob_from_ldr: np.ndarray,
126
+ prob_from_others: np.ndarray,
120
127
  ) -> np.ndarray:
121
128
  prob_combined = np.copy(prob_from_ldr)
122
129
  no_ldr = np.where(prob_from_ldr == 0)
@@ -124,19 +131,25 @@ def _fill_missing_pixels(
124
131
  return prob_combined
125
132
 
126
133
 
127
- def _screen_insects(insect_prob, insect_prob_no_ldr, melting_layer, liquid_layers, obs):
128
- def _screen_liquid_layers():
134
+ def _screen_insects(
135
+ insect_prob,
136
+ insect_prob_no_ldr,
137
+ melting_layer,
138
+ liquid_layers,
139
+ obs,
140
+ ) -> np.ndarray:
141
+ def _screen_liquid_layers() -> None:
129
142
  prob[liquid_layers == 1] = 0
130
143
 
131
- def _screen_above_melting():
144
+ def _screen_above_melting() -> None:
132
145
  above_melting = utils.ffill(melting_layer)
133
146
  prob[above_melting == 1] = 0
134
147
 
135
- def _screen_above_liquid():
148
+ def _screen_above_liquid() -> None:
136
149
  above_liquid = utils.ffill(liquid_layers)
137
150
  prob[(above_liquid == 1) & (insect_prob_no_ldr > 0)] = 0
138
151
 
139
- def _screen_rainy_profiles():
152
+ def _screen_rainy_profiles() -> None:
140
153
  prob[obs.is_rain == 1, :] = 0
141
154
 
142
155
  prob = np.copy(insect_prob)
@@ -1,5 +1,6 @@
1
1
  """Lidar module, containing the :class:`Lidar` class."""
2
2
  import logging
3
+ from typing import Literal
3
4
 
4
5
  import numpy as np
5
6
  from numpy import ma
@@ -12,6 +13,7 @@ class Lidar(DataSource):
12
13
  """Lidar class, child of DataSource.
13
14
 
14
15
  Args:
16
+ ----
15
17
  full_path: Cloudnet Level 1 lidar netCDF file.
16
18
 
17
19
  """
@@ -21,42 +23,46 @@ class Lidar(DataSource):
21
23
  self.append_data(self.getvar("beta"), "beta")
22
24
  self._add_meta()
23
25
 
24
- def interpolate_to_grid(self, time_new: np.ndarray, height_new: np.ndarray) -> list:
26
+ def interpolate_to_grid(
27
+ self, time_new: np.ndarray, height_new: np.ndarray
28
+ ) -> list[int]:
25
29
  """Interpolate beta using nearest neighbor."""
26
- max_height = 100.0 # m
27
- max_time = 1.0 # min
30
+ max_height = 100 # m
31
+ max_time = 1 / 60 # min -> fraction hour
28
32
 
29
- # Remove completely masked profiles from the interpolation
33
+ if self.height is None:
34
+ msg = "Unable to interpolate lidar: no height information"
35
+ raise RuntimeError(msg)
36
+
37
+ # Interpolate beta to new grid but ignore profiles that are completely masked
30
38
  beta = self.data["beta"][:]
31
- indices = []
32
- for ind, b in enumerate(beta):
33
- if not ma.all(b) is ma.masked:
34
- indices.append(ind)
35
- assert self.height is not None
36
- beta_interpolated = interpolate_2d_nearest(
39
+ indices = [ind for ind, b in enumerate(beta) if ma.all(b) is not ma.masked]
40
+ beta_interp = interpolate_2d_nearest(
37
41
  self.time[indices],
38
42
  self.height,
39
43
  beta[indices, :],
40
44
  time_new,
41
45
  height_new,
42
46
  )
47
+ # Mask data points that are too far from the original grid
48
+ time_gap_ind = _get_gap_ind(self.time[indices], time_new, max_time)
49
+ height_gap_ind = _get_gap_ind(self.height, height_new, max_height)
50
+ self._mask_profiles(beta_interp, time_gap_ind, "time")
51
+ self._mask_profiles(beta_interp, height_gap_ind, "height")
52
+ self.data["beta"].data = beta_interp
53
+ return time_gap_ind
43
54
 
44
- # Filter profiles and range gates having data gap
45
- max_time /= 60 # to fraction hour
46
- bad_time_indices = _get_bad_indices(self.time[indices], time_new, max_time)
47
- bad_height_indices = _get_bad_indices(self.height, height_new, max_height)
48
- if bad_time_indices:
49
- logging.warning(
50
- f"Unable to interpolate lidar for {len(bad_time_indices)} time steps"
51
- )
52
- beta_interpolated[bad_time_indices, :] = ma.masked
53
- if bad_height_indices:
54
- logging.warning(
55
- f"Unable to interpolate lidar for {len(bad_height_indices)} altitudes"
56
- )
57
- beta_interpolated[:, bad_height_indices] = ma.masked
58
- self.data["beta"].data = beta_interpolated
59
- return bad_time_indices
55
+ @staticmethod
56
+ def _mask_profiles(
57
+ data: ma.MaskedArray, ind: list[int], dim: Literal["time", "height"]
58
+ ) -> None:
59
+ prefix = f"Unable to interpolate lidar for {len(ind)}"
60
+ if dim == "time" and ind:
61
+ logging.warning("%s time steps", prefix)
62
+ data[ind, :] = ma.masked
63
+ elif dim == "height" and ind:
64
+ logging.warning("%s altitudes", prefix)
65
+ data[:, ind] = ma.masked
60
66
 
61
67
  def _add_meta(self) -> None:
62
68
  self.append_data(float(self.getvar("wavelength")), "lidar_wavelength")
@@ -64,13 +70,9 @@ class Lidar(DataSource):
64
70
  self.append_data(3.0, "beta_bias")
65
71
 
66
72
 
67
- def _get_bad_indices(
68
- original_grid: np.ndarray, new_grid: np.ndarray, threshold: float
69
- ) -> list:
70
- indices = []
71
- for ind, value in enumerate(new_grid):
72
- diffu = np.abs(original_grid - value)
73
- distance = diffu[diffu.argmin()]
74
- if distance > threshold:
75
- indices.append(ind)
76
- return indices
73
+ def _get_gap_ind(grid: np.ndarray, new_grid: np.ndarray, threshold: float) -> list[int]:
74
+ return [
75
+ ind
76
+ for ind, value in enumerate(new_grid)
77
+ if np.min(np.abs(grid - value)) > threshold
78
+ ]
@@ -9,7 +9,7 @@ from cloudnetpy.categorize.containers import ClassData
9
9
  from cloudnetpy.constants import T0
10
10
 
11
11
 
12
- def find_melting_layer(obs: ClassData, smooth: bool = True) -> np.ndarray:
12
+ def find_melting_layer(obs: ClassData, *, smooth: bool = True) -> np.ndarray:
13
13
  """Finds melting layer from model temperature, ldr, and velocity.
14
14
 
15
15
  Melting layer is detected using linear depolarization ratio, *ldr*,
@@ -32,17 +32,20 @@ def find_melting_layer(obs: ClassData, smooth: bool = True) -> np.ndarray:
32
32
  the rest -8..+6.
33
33
 
34
34
  Notes:
35
+ -----
35
36
  This melting layer detection method is novel and needs to be validated.
36
37
  Also note that there might be some detection problems with strong
37
38
  updrafts of air. In these cases the absolute values for speed do not
38
39
  make sense (rain drops can even move upwards instead of down).
39
40
 
40
41
  Args:
42
+ ----
41
43
  obs: The :class:`ClassData` instance.
42
44
  smooth: If True, apply a small Gaussian smoother to the
43
45
  melting layer. Default is True.
44
46
 
45
47
  Returns:
48
+ -------
46
49
  2-D boolean array denoting the melting layer.
47
50
 
48
51
  """
@@ -55,8 +58,7 @@ def find_melting_layer(obs: ClassData, smooth: bool = True) -> np.ndarray:
55
58
 
56
59
  if hasattr(obs, "ldr"):
57
60
  # Required for peak detection
58
- diffu = np.diff(obs.ldr, axis=1)
59
- assert isinstance(diffu, ma.MaskedArray)
61
+ diffu = ma.array(np.diff(obs.ldr, axis=1))
60
62
  ldr_diff = diffu.filled(0)
61
63
 
62
64
  t_range = _find_model_temperature_range(obs.model_type)
@@ -69,15 +71,22 @@ def find_melting_layer(obs: ClassData, smooth: bool = True) -> np.ndarray:
69
71
  v_prof = obs.v[ind, temp_indices]
70
72
 
71
73
  if ldr_diff is not None:
72
- assert hasattr(obs, "ldr")
74
+ if not hasattr(obs, "ldr"):
75
+ msg = "ldr_diff is not None but obs.ldr does not exist"
76
+ raise RuntimeError(msg)
73
77
  ldr_prof = obs.ldr[ind, temp_indices]
74
78
  ldr_dprof = ldr_diff[ind, temp_indices]
75
79
 
76
80
  if ma.count(ldr_prof) > 3 or ma.count(v_prof) > 3:
77
81
  try:
78
- assert ldr_prof is not None and ldr_dprof is not None
82
+ if ldr_prof is None or ldr_dprof is None:
83
+ msg = "ldr_prof or ldr_dprof is None"
84
+ raise AssertionError(msg) # noqa: TRY301
79
85
  indices = _find_melting_layer_from_ldr(
80
- ldr_prof, ldr_dprof, v_prof, z_prof
86
+ ldr_prof,
87
+ ldr_dprof,
88
+ v_prof,
89
+ z_prof,
81
90
  )
82
91
  except (ValueError, IndexError, AssertionError):
83
92
  height = obs.height[temp_indices]
@@ -114,13 +123,14 @@ def _find_melting_layer_from_ldr(
114
123
 
115
124
  if all(conditions):
116
125
  base = int(np.floor(base + (peak - base) / 2))
117
- indices = np.arange(base, top)
118
- return indices
126
+ return np.arange(base, top)
119
127
  return None
120
128
 
121
129
 
122
130
  def _find_melting_layer_from_v(
123
- v_prof: np.ndarray, width_prof: np.ndarray | None, height: np.ndarray
131
+ v_prof: np.ndarray,
132
+ width_prof: np.ndarray | None,
133
+ height: np.ndarray,
124
134
  ) -> np.ndarray | None:
125
135
  v = np.copy(v_prof[:-1])
126
136
  v_diff = np.diff(v_prof)