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
cloudnetpy/utils.py CHANGED
@@ -1,12 +1,13 @@
1
- """ This module contains general helper functions. """
1
+ """This module contains general helper functions."""
2
2
  import datetime
3
3
  import logging
4
4
  import os
5
5
  import re
6
6
  import uuid
7
7
  import warnings
8
+ from collections.abc import Iterator
8
9
  from datetime import timezone
9
- from typing import Final, Iterator
10
+ from typing import Literal
10
11
 
11
12
  import netCDF4
12
13
  import numpy as np
@@ -14,31 +15,31 @@ from numpy import ma
14
15
  from scipy import ndimage, stats
15
16
  from scipy.interpolate import RectBivariateSpline, RegularGridInterpolator, griddata
16
17
 
18
+ from cloudnetpy.constants import SEC_IN_DAY, SEC_IN_HOUR, SEC_IN_MINUTE
17
19
  from cloudnetpy.exceptions import ValidTimeStampError
18
20
 
19
21
  Epoch = tuple[int, int, int]
20
22
  Date = tuple[str, str, str]
21
23
 
22
- SECONDS_PER_MINUTE: Final = 60
23
- SECONDS_PER_HOUR: Final = 3600
24
- SECONDS_PER_DAY: Final = 86400
25
-
26
24
 
27
25
  def seconds2hours(time_in_seconds: np.ndarray) -> np.ndarray:
28
26
  """Converts seconds since some epoch to fraction hour.
29
27
 
30
28
  Args:
29
+ ----
31
30
  time_in_seconds: 1-D array of seconds since some epoch that starts on midnight.
32
31
 
33
32
  Returns:
33
+ -------
34
34
  Time as fraction hour.
35
35
 
36
36
  Notes:
37
+ -----
37
38
  Excludes leap seconds.
38
39
 
39
40
  """
40
- seconds_since_midnight = np.mod(time_in_seconds, SECONDS_PER_DAY)
41
- fraction_hour = seconds_since_midnight / SECONDS_PER_HOUR
41
+ seconds_since_midnight = np.mod(time_in_seconds, SEC_IN_DAY)
42
+ fraction_hour = seconds_since_midnight / SEC_IN_HOUR
42
43
  if fraction_hour[-1] == 0:
43
44
  fraction_hour[-1] = 24
44
45
  return fraction_hour
@@ -48,16 +49,18 @@ def seconds2time(time_in_seconds: float) -> list:
48
49
  """Converts seconds since some epoch to time of day.
49
50
 
50
51
  Args:
52
+ ----
51
53
  time_in_seconds: seconds since some epoch.
52
54
 
53
55
  Returns:
56
+ -------
54
57
  list: [hours, minutes, seconds] formatted as '05' etc.
55
58
 
56
59
  """
57
- seconds_since_midnight = np.mod(time_in_seconds, SECONDS_PER_DAY)
58
- hours = seconds_since_midnight // SECONDS_PER_HOUR
59
- minutes = seconds_since_midnight % SECONDS_PER_HOUR // SECONDS_PER_MINUTE
60
- seconds = seconds_since_midnight % SECONDS_PER_MINUTE
60
+ seconds_since_midnight = np.mod(time_in_seconds, SEC_IN_DAY)
61
+ hours = seconds_since_midnight // SEC_IN_HOUR
62
+ minutes = seconds_since_midnight % SEC_IN_HOUR // SEC_IN_MINUTE
63
+ seconds = seconds_since_midnight % SEC_IN_MINUTE
61
64
  time = [hours, minutes, seconds]
62
65
  return [str(t).zfill(2) for t in time]
63
66
 
@@ -66,19 +69,21 @@ def seconds2date(time_in_seconds: float, epoch: Epoch = (2001, 1, 1)) -> list:
66
69
  """Converts seconds since some epoch to datetime (UTC).
67
70
 
68
71
  Args:
72
+ ----
69
73
  time_in_seconds: Seconds since some epoch.
70
74
  epoch: Epoch, default is (2001, 1, 1) (UTC).
71
75
 
72
76
  Returns:
77
+ -------
73
78
  [year, month, day, hours, minutes, seconds] formatted as '05' etc (UTC).
74
79
 
75
80
  """
76
81
  epoch_in_seconds = datetime.datetime.timestamp(
77
- datetime.datetime(*epoch, tzinfo=timezone.utc)
82
+ datetime.datetime(*epoch, tzinfo=timezone.utc),
78
83
  )
79
84
  timestamp = time_in_seconds + epoch_in_seconds
80
85
  return (
81
- datetime.datetime.utcfromtimestamp(timestamp)
86
+ datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc)
82
87
  .strftime("%Y %m %d %H %M %S")
83
88
  .split()
84
89
  )
@@ -89,7 +94,7 @@ def datetime2decimal_hours(data: np.ndarray | list) -> np.ndarray:
89
94
  output = []
90
95
  for timestamp in data:
91
96
  t = timestamp.time()
92
- decimal_hours = t.hour + t.minute / 60 + t.second / 3600
97
+ decimal_hours = t.hour + t.minute / SEC_IN_MINUTE + t.second / SEC_IN_HOUR
93
98
  output.append(decimal_hours)
94
99
  return np.array(output)
95
100
 
@@ -101,18 +106,22 @@ def time_grid(time_step: int = 30) -> np.ndarray:
101
106
  resolution (in seconds).
102
107
 
103
108
  Args:
109
+ ----
104
110
  time_step: Time resolution in seconds, greater than 1. Default is 30.
105
111
 
106
112
  Returns:
113
+ -------
107
114
  Time vector between 0 and 24.
108
115
 
109
116
  Raises:
117
+ ------
110
118
  ValueError: Bad resolution as input.
111
119
 
112
120
  """
113
121
  if time_step < 1:
114
- raise ValueError("Time resolution should be >= 1 seconds")
115
- half_step = time_step / SECONDS_PER_HOUR / 2
122
+ msg = "Time resolution should be >= 1 seconds"
123
+ raise ValueError(msg)
124
+ half_step = time_step / SEC_IN_HOUR / 2
116
125
  return np.arange(half_step, 24 + half_step, half_step * 2)
117
126
 
118
127
 
@@ -120,12 +129,15 @@ def binvec(x: np.ndarray | list) -> np.ndarray:
120
129
  """Converts 1-D center points to bins with even spacing.
121
130
 
122
131
  Args:
132
+ ----
123
133
  x: 1-D array of N real values.
124
134
 
125
135
  Returns:
136
+ -------
126
137
  ndarray: N + 1 edge values.
127
138
 
128
139
  Examples:
140
+ --------
129
141
  >>> binvec([1, 2, 3])
130
142
  [0.5, 1.5, 2.5, 3.5]
131
143
 
@@ -145,6 +157,7 @@ def rebin_2d(
145
157
  """Rebins 2-D data in one dimension.
146
158
 
147
159
  Args:
160
+ ----
148
161
  x_in: 1-D array with shape (n,).
149
162
  array: 2-D input data with shape (n, m).
150
163
  x_new: 1-D target vector (center points) with shape (N,).
@@ -153,9 +166,11 @@ def rebin_2d(
153
166
  n_min: Minimum number of points to have good statistics in a bin. Default is 1.
154
167
 
155
168
  Returns:
169
+ -------
156
170
  tuple: Rebinned data with shape (N, m) and indices of bins without enough data.
157
171
 
158
172
  Notes:
173
+ -----
159
174
  0-values are masked in the returned array.
160
175
 
161
176
  """
@@ -166,7 +181,10 @@ def rebin_2d(
166
181
  mask = ~values.mask
167
182
  if ma.any(values[mask]):
168
183
  result[:, ind], _, _ = stats.binned_statistic(
169
- x_in[mask], values[mask], statistic=statistic, bins=edges
184
+ x_in[mask],
185
+ values[mask],
186
+ statistic=statistic,
187
+ bins=edges,
170
188
  )
171
189
  result[~np.isfinite(result)] = 0
172
190
  masked_result = ma.masked_equal(result, 0)
@@ -179,7 +197,7 @@ def rebin_2d(
179
197
  masked_result[ind, :] = ma.masked
180
198
  empty_indices.append(ind)
181
199
  if len(empty_indices) > 0:
182
- logging.debug(f"No radar data in {len(empty_indices)} bins")
200
+ logging.debug("No radar data in %s bins", len(empty_indices))
183
201
 
184
202
  return masked_result, empty_indices
185
203
 
@@ -193,6 +211,7 @@ def rebin_1d(
193
211
  """Rebins 1D array.
194
212
 
195
213
  Args:
214
+ ----
196
215
  x_in: 1-D array with shape (n,).
197
216
  array: 1-D input data with shape (m,).
198
217
  x_new: 1-D target vector (center points) with shape (N,).
@@ -200,16 +219,20 @@ def rebin_1d(
200
219
  Default is 'mean'.
201
220
 
202
221
  Returns:
203
- Rebinned data with shape (N,).
222
+ -------
223
+ Re-binned data with shape (N,).
204
224
 
205
225
  """
206
226
  edges = binvec(x_new)
207
227
  result = np.zeros(len(x_new))
208
228
  array_screened = ma.masked_invalid(array, copy=True) # data may contain nan-values
209
- mask = ~array_screened.mask # pylint: disable=E1101
229
+ mask = ~array_screened.mask
210
230
  if ma.any(array_screened[mask]):
211
231
  result, _, _ = stats.binned_statistic(
212
- x_in[mask], array_screened[mask], statistic=statistic, bins=edges
232
+ x_in[mask],
233
+ array_screened[mask],
234
+ statistic=statistic,
235
+ bins=edges,
213
236
  )
214
237
  result[~np.isfinite(result)] = 0
215
238
  return ma.masked_equal(result, 0)
@@ -219,12 +242,15 @@ def filter_isolated_pixels(array: np.ndarray) -> np.ndarray:
219
242
  """From a 2D boolean array, remove completely isolated single cells.
220
243
 
221
244
  Args:
245
+ ----
222
246
  array: 2-D boolean array containing isolated values.
223
247
 
224
248
  Returns:
249
+ -------
225
250
  Cleaned array.
226
251
 
227
252
  Examples:
253
+ --------
228
254
  >>> filter_isolated_pixels([[0, 0, 0], [0, 1, 0], [0, 0, 0]])
229
255
  array([[0, 0, 0],
230
256
  [0, 0, 0],
@@ -239,15 +265,19 @@ def filter_x_pixels(array: np.ndarray) -> np.ndarray:
239
265
  """From a 2D boolean array, remove cells isolated in x-direction.
240
266
 
241
267
  Args:
268
+ ----
242
269
  array: 2-D boolean array containing isolated pixels in x-direction.
243
270
 
244
271
  Returns:
272
+ -------
245
273
  Cleaned array.
246
274
 
247
275
  Notes:
276
+ -----
248
277
  Stronger cleaning than `filter_isolated_pixels()`
249
278
 
250
279
  Examples:
280
+ --------
251
281
  >>> filter_x_pixels([[1, 0, 0], [0, 1, 0], [0, 1, 1]])
252
282
  array([[0, 0, 0],
253
283
  [0, 1, 0],
@@ -268,30 +298,36 @@ def _filter(array: np.ndarray, structure: np.ndarray) -> np.ndarray:
268
298
 
269
299
 
270
300
  def isbit(array: np.ndarray, nth_bit: int) -> np.ndarray:
271
- """Tests if nth bit (0,1,2..) is set.
301
+ """Tests if nth bit (0,1,2,...) is set.
272
302
 
273
303
  Args:
304
+ ----
274
305
  array: Integer array.
275
306
  nth_bit: Investigated bit.
276
307
 
277
308
  Returns:
309
+ -------
278
310
  Boolean array denoting values where nth_bit is set.
279
311
 
280
312
  Raises:
313
+ ------
281
314
  ValueError: negative bit as input.
282
315
 
283
316
  Examples:
317
+ --------
284
318
  >>> isbit(4, 1)
285
319
  False
286
320
  >>> isbit(4, 2)
287
321
  True
288
322
 
289
- See also:
323
+ See Also:
324
+ --------
290
325
  utils.setbit()
291
326
 
292
327
  """
293
328
  if nth_bit < 0:
294
- raise ValueError("Negative bit number")
329
+ msg = "Negative bit number"
330
+ raise ValueError(msg)
295
331
  mask = 1 << nth_bit
296
332
  return array & mask > 0
297
333
 
@@ -300,27 +336,33 @@ def setbit(array: np.ndarray, nth_bit: int) -> np.ndarray:
300
336
  """Sets nth bit (0, 1, 2, ...) on number.
301
337
 
302
338
  Args:
339
+ ----
303
340
  array: Integer array.
304
341
  nth_bit: Bit to be set.
305
342
 
306
343
  Returns:
344
+ -------
307
345
  Integer where nth bit is set.
308
346
 
309
347
  Raises:
348
+ ------
310
349
  ValueError: negative bit as input.
311
350
 
312
351
  Examples:
352
+ --------
313
353
  >>> setbit(1, 1)
314
354
  3
315
355
  >>> setbit(0, 2)
316
356
  4
317
357
 
318
- See also:
358
+ See Also:
359
+ --------
319
360
  utils.isbit()
320
361
 
321
362
  """
322
363
  if nth_bit < 0:
323
- raise ValueError("Negative bit number")
364
+ msg = "Negative bit number"
365
+ raise ValueError(msg)
324
366
  mask = 1 << nth_bit
325
367
  array |= mask
326
368
  return array
@@ -336,6 +378,7 @@ def interpolate_2d(
336
378
  """Linear interpolation of gridded 2d data.
337
379
 
338
380
  Args:
381
+ ----
339
382
  x: 1-D array.
340
383
  y: 1-D array.
341
384
  z: 2-D array at points (x, y).
@@ -343,9 +386,11 @@ def interpolate_2d(
343
386
  y_new: 1-D array.
344
387
 
345
388
  Returns:
389
+ -------
346
390
  Interpolated data.
347
391
 
348
392
  Notes:
393
+ -----
349
394
  Does not work with nans. Ignores mask of masked data. Does not extrapolate.
350
395
 
351
396
  """
@@ -363,6 +408,7 @@ def interpolate_2d_mask(
363
408
  """2D linear interpolation preserving the mask.
364
409
 
365
410
  Args:
411
+ ----
366
412
  x: 1D array, x-coordinates.
367
413
  y: 1D array, y-coordinates.
368
414
  z: 2D masked array, data values.
@@ -370,31 +416,35 @@ def interpolate_2d_mask(
370
416
  y_new: 1D array, new y-coordinates.
371
417
 
372
418
  Returns:
419
+ -------
373
420
  Interpolated 2D masked array.
374
421
 
375
422
  Notes:
423
+ -----
376
424
  Points outside the original range will be nans (and masked). Uses linear
377
425
  interpolation. Input data may contain nan-values.
378
426
 
379
427
  """
380
- z = ma.array(ma.masked_invalid(z, copy=True)) # ma.array() to avoid pylint nag
428
+ z = ma.array(ma.masked_invalid(z, copy=True))
381
429
  # Interpolate ignoring masked values:
382
- valid_points = np.logical_not(z.mask) # ~z.mask causes pylint nag
430
+ valid_points = np.logical_not(z.mask)
383
431
  xx, yy = np.meshgrid(y, x)
384
432
  x_valid = xx[valid_points]
385
433
  y_valid = yy[valid_points]
386
434
  z_valid = z[valid_points]
387
435
  xx_new, yy_new = np.meshgrid(y_new, x_new)
388
436
  data = griddata(
389
- (x_valid, y_valid), z_valid.ravel(), (xx_new, yy_new), method="linear"
437
+ (x_valid, y_valid),
438
+ z_valid.ravel(),
439
+ (xx_new, yy_new),
440
+ method="linear",
390
441
  )
391
442
  # Preserve mask:
392
443
  mask_fun = RectBivariateSpline(x, y, z.mask[:], kx=1, ky=1)
393
444
  mask = mask_fun(x_new, y_new)
394
445
  mask[mask < 0.5] = 0
395
446
  masked_array = ma.array(data, mask=mask.astype(bool))
396
- masked_array = ma.masked_invalid(masked_array)
397
- return masked_array
447
+ return ma.masked_invalid(masked_array)
398
448
 
399
449
 
400
450
  def interpolate_2d_nearest(
@@ -407,6 +457,7 @@ def interpolate_2d_nearest(
407
457
  """2D nearest neighbor interpolation preserving mask.
408
458
 
409
459
  Args:
460
+ ----
410
461
  x: 1D array, x-coordinates.
411
462
  y: 1D array, y-coordinates.
412
463
  z: 2D masked array, data values.
@@ -414,9 +465,11 @@ def interpolate_2d_nearest(
414
465
  y_new: 1D array, new y-coordinates.
415
466
 
416
467
  Returns:
468
+ -------
417
469
  Interpolated 2D masked array.
418
470
 
419
471
  Notes:
472
+ -----
420
473
  Points outside the original range will be interpolated but masked.
421
474
 
422
475
  """
@@ -438,7 +491,7 @@ def calc_relative_error(reference: np.ndarray, array: np.ndarray) -> np.ndarray:
438
491
 
439
492
 
440
493
  def db2lin(array: float | np.ndarray, scale: int = 10) -> np.ndarray:
441
- """dB to linear conversion."""
494
+ """DB to linear conversion."""
442
495
  data = array / scale
443
496
  with warnings.catch_warnings():
444
497
  warnings.simplefilter("ignore", category=RuntimeWarning)
@@ -463,9 +516,11 @@ def l2norm(*args) -> ma.MaskedArray:
463
516
  """Returns l2 norm.
464
517
 
465
518
  Args:
519
+ ----
466
520
  *args: Variable number of data (*array_like*) with the same shape.
467
521
 
468
522
  Returns:
523
+ -------
469
524
  The l2 norm.
470
525
 
471
526
  """
@@ -473,16 +528,18 @@ def l2norm(*args) -> ma.MaskedArray:
473
528
  for arg in args:
474
529
  if isinstance(arg, ma.MaskedArray):
475
530
  # Raise only non-masked values, not sure if this is needed...
476
- arg = ma.copy(arg)
477
- arg[~arg.mask] = arg[~arg.mask] ** 2
531
+ arg_cpy = ma.copy(arg)
532
+ arg_cpy[~arg.mask] = arg_cpy[~arg.mask] ** 2
478
533
  else:
479
- arg = arg**2
480
- ss = ss + arg
534
+ arg_cpy = arg**2
535
+ ss = ss + arg_cpy
481
536
  return ma.sqrt(ss)
482
537
 
483
538
 
484
539
  def l2norm_weighted(
485
- values: tuple, overall_scale: float, term_weights: tuple
540
+ values: tuple,
541
+ overall_scale: float,
542
+ term_weights: tuple,
486
543
  ) -> ma.MaskedArray:
487
544
  """Calculates scaled and weighted Euclidean distance.
488
545
 
@@ -491,12 +548,14 @@ def l2norm_weighted(
491
548
  for the terms.
492
549
 
493
550
  Args:
551
+ ----
494
552
  values: Tuple containing the values.
495
553
  overall_scale: Scale factor for the calculated Euclidean distance.
496
554
  term_weights: Weights for the terms. Must be single float or a list of numbers
497
555
  (one per term).
498
556
 
499
557
  Returns:
558
+ -------
500
559
  Scaled and weighted Euclidean distance.
501
560
 
502
561
  TODO: Use masked arrays instead of tuples.
@@ -511,58 +570,68 @@ def cumsumr(array: np.ndarray, axis: int = 0) -> np.ndarray:
511
570
  """Finds cumulative sum that resets on 0.
512
571
 
513
572
  Args:
573
+ ----
514
574
  array: Input array.
515
575
  axis: Axis where the sum is calculated. Default is 0.
516
576
 
517
577
  Returns:
578
+ -------
518
579
  Cumulative sum, restarted at 0.
519
580
 
520
581
  Examples:
582
+ --------
521
583
  >>> x = np.array([0, 0, 1, 1, 0, 0, 0, 1, 1, 1])
522
584
  >>> cumsumr(x)
523
585
  [0, 0, 1, 2, 0, 0, 0, 1, 2, 3]
524
586
 
525
587
  """
526
588
  cums = array.cumsum(axis=axis)
527
- return cums - np.maximum.accumulate(
528
- cums * (array == 0), axis=axis
529
- ) # pylint: disable=E1101
589
+ return cums - np.maximum.accumulate(cums * (array == 0), axis=axis)
530
590
 
531
591
 
532
592
  def ffill(array: np.ndarray, value: int = 0) -> np.ndarray:
533
593
  """Forward fills an array.
534
594
 
535
595
  Args:
596
+ ----
536
597
  array: 1-D or 2-D array.
537
598
  value: Value to be filled. Default is 0.
538
599
 
539
600
  Returns:
601
+ -------
540
602
  ndarray: Forward-filled array.
541
603
 
542
604
  Examples:
605
+ --------
543
606
  >>> x = np.array([0, 5, 0, 0, 2, 0])
544
607
  >>> ffill(x)
545
608
  [0, 5, 5, 5, 2, 2]
546
609
 
547
610
  Notes:
611
+ -----
548
612
  Works only in axis=1 direction.
549
613
 
550
614
  """
551
615
  ndims = len(array.shape)
552
616
  ran = np.arange(array.shape[ndims - 1])
553
617
  idx = np.where((array != value), ran, 0)
554
- idx = np.maximum.accumulate(idx, axis=ndims - 1) # pylint: disable=E1101
618
+ idx = np.maximum.accumulate(idx, axis=ndims - 1)
555
619
  if ndims == 2:
556
620
  return array[np.arange(idx.shape[0])[:, None], idx]
557
621
  return array[idx]
558
622
 
559
623
 
560
624
  def init(
561
- n_vars: int, shape: tuple, dtype: type = float, masked: bool = True
625
+ n_vars: int,
626
+ shape: tuple,
627
+ dtype: type = float,
628
+ *,
629
+ masked: bool = True,
562
630
  ) -> Iterator[np.ndarray | ma.MaskedArray]:
563
631
  """Initializes several numpy arrays.
564
632
 
565
633
  Args:
634
+ ----
566
635
  n_vars: Number of arrays to be generated.
567
636
  shape: Shape of the arrays, e.g. (2, 3).
568
637
  dtype: The desired data-type for the arrays, e.g., int. Default is float.
@@ -570,9 +639,11 @@ def init(
570
639
  Default is True.
571
640
 
572
641
  Yields:
642
+ ------
573
643
  Iterator containing the empty arrays.
574
644
 
575
645
  Examples:
646
+ --------
576
647
  >>> a, b = init(2, (2, 3))
577
648
  >>> a
578
649
  masked_array(
@@ -593,17 +664,20 @@ def n_elements(array: np.ndarray, dist: float, var: str | None = None) -> int:
593
664
  """Returns the number of elements that cover certain distance.
594
665
 
595
666
  Args:
667
+ ----
596
668
  array: Input array with arbitrary units or time in fraction hour. *x* should
597
669
  be evenly spaced or at least close to.
598
670
  dist: Distance to be covered. If x is fraction time, *dist* is in minutes.
599
- Otherwise *x* and *dist* should have the same units.
671
+ Otherwise, *x* and *dist* should have the same units.
600
672
  var: If 'time', input is fraction hour and distance in minutes, else inputs
601
673
  have the same units. Default is None (same units).
602
674
 
603
675
  Returns:
676
+ -------
604
677
  Number of elements in the input array that cover *dist*.
605
678
 
606
679
  Examples:
680
+ --------
607
681
  >>> x = np.array([2, 4, 6, 8, 10])
608
682
  >>> n_elements(x, 6)
609
683
  3
@@ -624,16 +698,17 @@ def n_elements(array: np.ndarray, dist: float, var: str | None = None) -> int:
624
698
  """
625
699
  n = dist / mdiff(array)
626
700
  if var == "time":
627
- n = n / 60
701
+ n = n / SEC_IN_MINUTE
628
702
  return int(np.round(n))
629
703
 
630
704
 
631
- def isscalar(array) -> bool:
705
+ def isscalar(array: np.ndarray | float | list) -> bool:
632
706
  """Tests if input is scalar.
633
707
 
634
708
  By "scalar" we mean that array has a single value.
635
709
 
636
- Examples:
710
+ Examples
711
+ --------
637
712
  >>> isscalar(1)
638
713
  True
639
714
  >>> isscalar([1])
@@ -645,18 +720,19 @@ def isscalar(array) -> bool:
645
720
 
646
721
  """
647
722
  arr = ma.array(array)
648
- if not hasattr(arr, "__len__") or arr.shape == () or len(arr) == 1:
649
- return True
650
- return False
723
+ return not hasattr(arr, "__len__") or arr.shape == () or len(arr) == 1
651
724
 
652
725
 
653
726
  def get_time() -> str:
654
727
  """Returns current UTC-time."""
655
- return f"{datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')} +00:00"
728
+ t_zone = datetime.timezone.utc
729
+ form = "%Y-%m-%d %H:%M:%S"
730
+ return f"{datetime.datetime.now(tz=t_zone).strftime(form)} +00:00"
656
731
 
657
732
 
658
733
  def date_range(
659
- start_date: datetime.date, end_date: datetime.date
734
+ start_date: datetime.date,
735
+ end_date: datetime.date,
660
736
  ) -> Iterator[datetime.date]:
661
737
  """Returns range between two dates (datetimes)."""
662
738
  for n in range(int((end_date - start_date).days)):
@@ -672,9 +748,11 @@ def get_wl_band(radar_frequency: float) -> int:
672
748
  """Returns integer corresponding to radar frequency.
673
749
 
674
750
  Args:
751
+ ----
675
752
  radar_frequency: Radar frequency (GHz).
676
753
 
677
754
  Returns:
755
+ -------
678
756
  0 = 35GHz radar, 1 = 94Ghz radar.
679
757
 
680
758
  """
@@ -689,7 +767,8 @@ def get_frequency(wl_band: int) -> str:
689
767
  def transpose(data: np.ndarray) -> np.ndarray:
690
768
  """Transposes numpy array of (n, ) to (n, 1)."""
691
769
  if data.ndim != 1 or len(data) <= 1:
692
- raise ValueError("Invalid input array shape")
770
+ msg = "Invalid input array shape"
771
+ raise ValueError(msg)
693
772
  return data[:, np.newaxis]
694
773
 
695
774
 
@@ -697,10 +776,12 @@ def del_dict_keys(data: dict, keys: tuple | list) -> dict:
697
776
  """Deletes multiple keys from dictionary.
698
777
 
699
778
  Args:
779
+ ----
700
780
  data: A dictionary.
701
781
  keys: Keys to be deleted.
702
782
 
703
783
  Returns:
784
+ -------
704
785
  Dictionary without the deleted keys.
705
786
 
706
787
  """
@@ -712,11 +793,16 @@ def del_dict_keys(data: dict, keys: tuple | list) -> dict:
712
793
 
713
794
 
714
795
  def array_to_probability(
715
- array: np.ndarray, loc: float, scale: float, invert: bool = False
796
+ array: np.ndarray,
797
+ loc: float,
798
+ scale: float,
799
+ *,
800
+ invert: bool = False,
716
801
  ) -> np.ndarray:
717
802
  """Converts continuous variable into 0-1 probability.
718
803
 
719
804
  Args:
805
+ ----
720
806
  array: Numpy array.
721
807
  loc: Center of the distribution. Values smaller than this will have small
722
808
  probability. Values greater than this will have large probability.
@@ -726,6 +812,7 @@ def array_to_probability(
726
812
  Default is False.
727
813
 
728
814
  Returns:
815
+ -------
729
816
  Probability with the same shape as the input data.
730
817
 
731
818
  """
@@ -743,13 +830,16 @@ def range_to_height(range_los: np.ndarray, tilt_angle: float) -> np.ndarray:
743
830
  """Converts distances from a tilted instrument to height above the ground.
744
831
 
745
832
  Args:
833
+ ----
746
834
  range_los: Distances towards the line of sign from the instrument.
747
835
  tilt_angle: Angle in degrees from the zenith (0 = zenith).
748
836
 
749
837
  Returns:
838
+ -------
750
839
  Altitudes of the LOS points.
751
840
 
752
841
  Notes:
842
+ -----
753
843
  Uses plane parallel Earth approximation.
754
844
 
755
845
  """
@@ -776,9 +866,7 @@ def get_sorted_filenames(file_path: str, extension: str) -> list:
776
866
  extension = extension.lower()
777
867
  all_files = os.listdir(file_path)
778
868
  files = [
779
- "/".join((file_path, file))
780
- for file in all_files
781
- if file.lower().endswith(extension)
869
+ f"{file_path}/{file}" for file in all_files if file.lower().endswith(extension)
782
870
  ]
783
871
  files.sort()
784
872
  return files
@@ -808,7 +896,7 @@ def get_epoch(units: str) -> Epoch:
808
896
  except ValueError:
809
897
  return fallback
810
898
  year, month, day = date_components
811
- current_year = datetime.datetime.today().year
899
+ current_year = datetime.datetime.now(tz=datetime.timezone.utc).year
812
900
  if (1900 < year <= current_year) and (0 < month < 13) and (0 < day < 32):
813
901
  return year, month, day
814
902
  return fallback
@@ -818,14 +906,17 @@ def screen_by_time(data_in: dict, epoch: Epoch, expected_date: str) -> dict:
818
906
  """Screen data by time.
819
907
 
820
908
  Args:
909
+ ----
821
910
  data_in: Dictionary containing at least 'time' key and other numpy arrays.
822
911
  epoch: Epoch of the time array, e.g., (1970, 1, 1)
823
912
  expected_date: Expected date in yyyy-mm-dd
824
913
 
825
914
  Returns:
915
+ -------
826
916
  data: Screened and sorted by the time vector.
827
917
 
828
918
  Notes:
919
+ -----
829
920
  - Requires 'time' key
830
921
  - Works for dimensions 1, 2, 3 (time has to be at 0-axis)
831
922
  - Does nothing for scalars
@@ -855,17 +946,21 @@ def find_valid_time_indices(time: np.ndarray, epoch: Epoch, expected_date: str)
855
946
  """Finds valid time array indices for the given date.
856
947
 
857
948
  Args:
949
+ ----
858
950
  time: Time in seconds from some epoch.
859
951
  epoch: Epoch of the time array, e.g., (1970, 1, 1)
860
952
  expected_date: Expected date in yyyy-mm-dd
861
953
 
862
954
  Returns:
955
+ -------
863
956
  list: Valid indices for the given date in sorted order.
864
957
 
865
958
  Raises:
959
+ ------
866
960
  RuntimeError: No valid timestamps.
867
961
 
868
962
  Examples:
963
+ --------
869
964
  >>> time = [1, 5, 1e6, 3]
870
965
  >>> find_valid_time_indices(time, (1970, 1, 1) '1970-01-01')
871
966
  [0, 3, 2]
@@ -886,6 +981,7 @@ def append_data(data_in: dict, key: str, array: np.ndarray) -> dict:
886
981
  """Appends data to a dictionary field (creates the field if not yet present).
887
982
 
888
983
  Args:
984
+ ----
889
985
  data_in: Dictionary where data will be appended.
890
986
  key: Key of the field.
891
987
  array: Numpy array to be appended to data_in[key].
@@ -899,19 +995,19 @@ def append_data(data_in: dict, key: str, array: np.ndarray) -> dict:
899
995
  return data
900
996
 
901
997
 
902
- def edges2mid(data: np.ndarray, reference: str) -> np.ndarray:
998
+ def edges2mid(data: np.ndarray, reference: Literal["upper", "lower"]) -> np.ndarray:
903
999
  """Shifts values half bin towards up or down.
904
1000
 
905
1001
  Args:
1002
+ ----
906
1003
  data: 1D numpy array (e.g. range)
907
1004
  reference: If 'lower', increase values by half bin. If 'upper', decrease values.
908
1005
 
909
1006
  Returns:
1007
+ -------
910
1008
  Shifted values.
911
1009
 
912
1010
  """
913
- if reference not in ("lower", "upper"):
914
- raise ValueError
915
1011
  gaps = (data[1:] - data[0:-1]) / 2
916
1012
  if reference == "lower":
917
1013
  gaps = np.append(gaps, gaps[-1])
@@ -924,32 +1020,37 @@ def get_file_type(filename: str) -> str:
924
1020
  """Returns cloudnet file type from new and legacy files."""
925
1021
  with netCDF4.Dataset(filename) as nc:
926
1022
  if hasattr(nc, "cloudnet_file_type"):
927
- file_type = nc.cloudnet_file_type
928
- return file_type
1023
+ return nc.cloudnet_file_type
929
1024
  product = filename.split("_")[-1][:-3]
930
1025
  if product in ("categorize", "classification", "drizzle"):
931
1026
  return product
932
1027
  if product[:3] in ("lwc", "iwc"):
933
1028
  return product[:3]
934
- raise ValueError("Unknown file type")
1029
+ msg = "Unknown file type"
1030
+ raise ValueError(msg)
935
1031
 
936
1032
 
937
- def get_files_with_common_range(files: list) -> list:
1033
+ def get_files_with_common_range(filenames: list) -> list:
938
1034
  """Returns files with the same (most common) number of range gates."""
939
1035
  n_range = []
940
- for file in files:
1036
+ for file in filenames:
941
1037
  with netCDF4.Dataset(file) as nc:
942
1038
  n_range.append(len(nc.variables["range"]))
943
1039
  most_common = np.bincount(n_range).argmax()
944
- n_removed = len([n for n in n_range if n != most_common])
945
- if n_removed > 0:
946
- logging.warning(f"Removing {n_removed} files due to inconsistent height vector")
947
- ind = np.where(n_range == most_common)[0]
948
- return [file for i, file in enumerate(files) if i in ind]
1040
+ if n_removed := len(filenames) - n_range.count(int(most_common)) > 0:
1041
+ logging.warning(
1042
+ "Removing %s files due to inconsistent height vector", n_removed
1043
+ )
1044
+ return [file for i, file in enumerate(filenames) if n_range[i] == most_common]
949
1045
 
950
1046
 
951
1047
  def is_all_masked(array: np.ndarray) -> bool:
952
1048
  """Tests if all values are masked."""
953
- if ma.isMaskedArray(array) and hasattr(array, "mask"):
954
- return array.mask.all()
955
- return False
1049
+ return ma.isMaskedArray(array) and hasattr(array, "mask") and array.mask.all()
1050
+
1051
+
1052
+ def find_masked_profiles_indices(array: ma.MaskedArray) -> list:
1053
+ """Finds indices of masked profiles in a 2-D array."""
1054
+ non_masked_counts = np.ma.count(array, axis=1)
1055
+ masked_profiles_indices = np.where(non_masked_counts == 0)[0]
1056
+ return list(masked_profiles_indices)