cloudnetpy 1.88.3__tar.gz → 1.89.1__tar.gz

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 (139) hide show
  1. {cloudnetpy-1.88.3/cloudnetpy.egg-info → cloudnetpy-1.89.1}/PKG-INFO +1 -1
  2. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/classify.py +101 -16
  3. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/model.py +22 -1
  4. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/radar.py +66 -63
  5. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/cli.py +136 -22
  6. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/mira.py +22 -0
  7. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/nc_radar.py +13 -13
  8. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/plotting/plot_meta.py +2 -2
  9. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/plotting/plotting.py +2 -0
  10. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/plotting/plot_meta.py +9 -9
  11. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/plotting/plotting.py +28 -12
  12. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/products/classification.py +5 -1
  13. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/utils.py +14 -2
  14. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/version.py +2 -2
  15. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1/cloudnetpy.egg-info}/PKG-INFO +1 -1
  16. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/LICENSE +0 -0
  17. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/MANIFEST.in +0 -0
  18. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/README.md +0 -0
  19. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/__init__.py +0 -0
  20. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/__init__.py +0 -0
  21. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/atmos_utils.py +0 -0
  22. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/attenuation.py +0 -0
  23. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/attenuations/__init__.py +0 -0
  24. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/attenuations/gas_attenuation.py +0 -0
  25. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/attenuations/liquid_attenuation.py +0 -0
  26. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/attenuations/melting_attenuation.py +0 -0
  27. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/attenuations/rain_attenuation.py +0 -0
  28. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/categorize.py +0 -0
  29. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/containers.py +0 -0
  30. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/disdrometer.py +0 -0
  31. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/droplet.py +0 -0
  32. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/falling.py +0 -0
  33. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/freezing.py +0 -0
  34. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/insects.py +0 -0
  35. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/itu.py +0 -0
  36. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/lidar.py +0 -0
  37. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/melting.py +0 -0
  38. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/categorize/mwr.py +0 -0
  39. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/cloudnetarray.py +0 -0
  40. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/concat_lib.py +0 -0
  41. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/constants.py +0 -0
  42. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/datasource.py +0 -0
  43. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/disdronator/__init__.py +0 -0
  44. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/disdronator/lpm.py +0 -0
  45. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/disdronator/parsivel.py +0 -0
  46. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/disdronator/rd80.py +0 -0
  47. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/disdronator/utils.py +0 -0
  48. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/exceptions.py +0 -0
  49. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/__init__.py +0 -0
  50. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/basta.py +0 -0
  51. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/bowtie.py +0 -0
  52. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/ceilo.py +0 -0
  53. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/ceilometer.py +0 -0
  54. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/cl61d.py +0 -0
  55. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/cloudnet_instrument.py +0 -0
  56. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/copernicus.py +0 -0
  57. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/da10.py +0 -0
  58. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/disdrometer/__init__.py +0 -0
  59. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/disdrometer/common.py +0 -0
  60. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/disdrometer/parsivel.py +0 -0
  61. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/disdrometer/rd80.py +0 -0
  62. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/disdrometer/thies.py +0 -0
  63. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/fd12p.py +0 -0
  64. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/galileo.py +0 -0
  65. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/hatpro.py +0 -0
  66. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/instruments.py +0 -0
  67. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/lufft.py +0 -0
  68. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/mrr.py +0 -0
  69. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/nc_lidar.py +0 -0
  70. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/pollyxt.py +0 -0
  71. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/radiometrics.py +0 -0
  72. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/rain_e_h3.py +0 -0
  73. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/rpg.py +0 -0
  74. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/rpg_reader.py +0 -0
  75. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/toa5.py +0 -0
  76. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/vaisala.py +0 -0
  77. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/instruments/weather_station.py +0 -0
  78. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/metadata.py +0 -0
  79. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/__init__.py +0 -0
  80. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/file_handler.py +0 -0
  81. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/metadata.py +0 -0
  82. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/model_metadata.py +0 -0
  83. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/plotting/__init__.py +0 -0
  84. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/plotting/plot_tools.py +0 -0
  85. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/products/__init__.py +0 -0
  86. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/products/advance_methods.py +0 -0
  87. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/products/grid_methods.py +0 -0
  88. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/products/model_products.py +0 -0
  89. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/products/observation_products.py +0 -0
  90. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/products/product_resampling.py +0 -0
  91. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/products/tools.py +0 -0
  92. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/statistics/__init__.py +0 -0
  93. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/statistics/statistical_methods.py +0 -0
  94. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/__init__.py +0 -0
  95. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/e2e/__init__.py +0 -0
  96. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/e2e/conftest.py +0 -0
  97. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/e2e/process_cf/__init__.py +0 -0
  98. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/e2e/process_cf/main.py +0 -0
  99. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/e2e/process_cf/tests.py +0 -0
  100. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/e2e/process_iwc/__init__.py +0 -0
  101. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/e2e/process_iwc/main.py +0 -0
  102. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/e2e/process_iwc/tests.py +0 -0
  103. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/e2e/process_lwc/__init__.py +0 -0
  104. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/e2e/process_lwc/main.py +0 -0
  105. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/e2e/process_lwc/tests.py +0 -0
  106. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/unit/__init__.py +0 -0
  107. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/unit/conftest.py +0 -0
  108. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/unit/test_advance_methods.py +0 -0
  109. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/unit/test_grid_methods.py +0 -0
  110. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/unit/test_model_products.py +0 -0
  111. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/unit/test_observation_products.py +0 -0
  112. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/unit/test_plot_tools.py +0 -0
  113. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/unit/test_plotting.py +0 -0
  114. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/unit/test_statistical_methods.py +0 -0
  115. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/tests/unit/test_tools.py +0 -0
  116. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/model_evaluation/utils.py +0 -0
  117. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/output.py +0 -0
  118. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/plotting/__init__.py +0 -0
  119. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/products/__init__.py +0 -0
  120. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/products/der.py +0 -0
  121. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/products/drizzle.py +0 -0
  122. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/products/drizzle_error.py +0 -0
  123. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/products/drizzle_tools.py +0 -0
  124. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/products/epsilon.py +0 -0
  125. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/products/ier.py +0 -0
  126. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/products/iwc.py +0 -0
  127. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/products/lwc.py +0 -0
  128. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/products/mie_lu_tables.nc +0 -0
  129. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/products/mwr_tools.py +0 -0
  130. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/products/product_tools.py +0 -0
  131. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy/py.typed +0 -0
  132. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy.egg-info/SOURCES.txt +0 -0
  133. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy.egg-info/dependency_links.txt +0 -0
  134. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy.egg-info/entry_points.txt +0 -0
  135. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy.egg-info/requires.txt +0 -0
  136. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/cloudnetpy.egg-info/top_level.txt +0 -0
  137. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/docs/source/conf.py +0 -0
  138. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/pyproject.toml +0 -0
  139. {cloudnetpy-1.88.3 → cloudnetpy-1.89.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudnetpy
3
- Version: 1.88.3
3
+ Version: 1.89.1
4
4
  Summary: Python package for Cloudnet processing
5
5
  Author: Simo Tukiainen
6
6
  License: MIT License
@@ -1,6 +1,7 @@
1
1
  import numpy as np
2
2
  import numpy.typing as npt
3
3
  from numpy import ma
4
+ from scipy import ndimage
4
5
 
5
6
  from cloudnetpy import utils
6
7
  from cloudnetpy.categorize import (
@@ -84,18 +85,21 @@ def classify_measurements(data: Observations) -> ClassificationResult:
84
85
  bits.droplet = droplet.correct_liquid_top(obs, is_liquid, bits.freezing, limit=500)
85
86
  bits.insect, insect_prob = insects.find_insects(obs, bits.melting, bits.droplet)
86
87
  bits.falling = falling.find_falling_hydrometeors(obs, bits.droplet, bits.insect)
87
- filtered_ice = _filter_falling(bits)
88
88
  for _ in range(5):
89
89
  _fix_undetected_melting_layer(bits)
90
90
  _filter_insects(bits)
91
91
  bits.aerosol = _find_aerosols(obs, bits)
92
- bits.aerosol[filtered_ice] = False
93
92
  _fix_super_cold_liquid(obs, bits)
93
+ _reclassify_ice_with_high_ldr(obs, bits)
94
+ _filter_insects_in_falling(bits)
95
+ _filter_falling(bits, obs)
96
+
97
+ is_clutter = _remove_clutter_in_liquid(obs.is_clutter, bits)
94
98
 
95
99
  return ClassificationResult(
96
100
  category_bits=bits,
97
101
  is_rain=obs.is_rain,
98
- is_clutter=obs.is_clutter,
102
+ is_clutter=is_clutter,
99
103
  insect_prob=insect_prob,
100
104
  liquid_prob=liquid_prob,
101
105
  )
@@ -122,6 +126,64 @@ def fetch_quality(
122
126
  )
123
127
 
124
128
 
129
+ def _reclassify_ice_with_high_ldr(obs: ClassData, bits: CategoryBits) -> None:
130
+ """Reclassifies ice pixels with high LDR as insects.
131
+
132
+ Ice particles typically have LDR below -13 dB. Higher values in the
133
+ freezing region (excluding the melting layer) indicate insects, which
134
+ can be present in temperatures down to about -10 C.
135
+ """
136
+ if not hasattr(obs, "ldr"):
137
+ return
138
+ ldr_limit = -13
139
+ temp_limit = T0 - 10
140
+ is_ice = bits.falling & bits.freezing & ~bits.droplet & ~bits.melting
141
+ high_ldr = ~ma.getmaskarray(obs.ldr) & (obs.ldr > ldr_limit)
142
+ warm_enough = obs.tw > temp_limit
143
+ above_melting = utils.ffill(bits.melting)
144
+ reclassify = is_ice & high_ldr & warm_enough & ~above_melting
145
+ bits.insect[reclassify] = True
146
+ bits.falling[reclassify] = False
147
+ has_lidar = ~obs.beta.mask
148
+ bits.aerosol[reclassify & has_lidar] = True
149
+
150
+
151
+ def _filter_insects_in_falling(
152
+ bits: CategoryBits,
153
+ min_falling_size: int = 100,
154
+ dilation: int = 2,
155
+ ) -> None:
156
+ """Reclassifies insect pixels in freezing regions near large falling ice.
157
+
158
+ Insect pixels in freezing temperatures near sufficiently large falling
159
+ hydrometeor regions are almost certainly false positives. Real insects
160
+ cannot survive in freezing conditions, so the freezing constraint
161
+ protects legitimate warm-region insects from being reclassified.
162
+
163
+ Args:
164
+ bits: A :class:`CategoryBits` instance.
165
+ min_falling_size: Minimum size of nearby falling regions required
166
+ to trigger reclassification.
167
+ dilation: Number of pixels to dilate around falling regions.
168
+ """
169
+ structure = ndimage.generate_binary_structure(2, 1)
170
+ falling_labels, n_falling = ndimage.label(bits.falling, structure=structure)
171
+ if n_falling == 0:
172
+ return
173
+ falling_ids = np.arange(1, n_falling + 1)
174
+ falling_sizes = ndimage.sum(bits.falling, falling_labels, falling_ids)
175
+ large_falling = np.isin(
176
+ falling_labels, falling_ids[falling_sizes >= min_falling_size]
177
+ )
178
+ near_large_falling = ndimage.binary_dilation(
179
+ large_falling, structure, iterations=dilation
180
+ )
181
+ reclassify = bits.insect & bits.freezing & near_large_falling
182
+ bits.falling[reclassify] = True
183
+ bits.insect[reclassify] = False
184
+ bits.aerosol[reclassify] = False
185
+
186
+
125
187
  def _fix_super_cold_liquid(obs: ClassData, bits: CategoryBits) -> None:
126
188
  """Supercooled liquid droplets do not exist in atmosphere below around -38 C."""
127
189
  t_limit = T0 - 38
@@ -224,21 +286,44 @@ def _filter_insects(bits: CategoryBits) -> None:
224
286
  bits.insect = is_insects
225
287
 
226
288
 
227
- def _filter_falling(bits: CategoryBits) -> tuple:
289
+ def _remove_clutter_in_liquid(
290
+ is_clutter: npt.NDArray,
291
+ bits: CategoryBits,
292
+ ) -> npt.NDArray:
293
+ """Removes clutter that is inside liquid or falling hydrometeor layers.
294
+
295
+ Clutter detection based on near-zero velocity can produce false positives
296
+ inside cloud and drizzle layers. This function removes clutter flags from
297
+ pixels that are adjacent to droplet or falling hydrometeor pixels, and
298
+ reclassifies them as falling hydrometeors.
299
+ """
300
+ is_clutter = is_clutter.copy()
301
+ liquid_or_falling = bits.droplet | bits.falling
302
+ structure = ndimage.generate_binary_structure(2, 1)
303
+ near_liquid = ndimage.binary_dilation(liquid_or_falling, structure)
304
+ false_clutter = is_clutter & near_liquid
305
+ is_clutter[false_clutter] = False
306
+ bits.falling[false_clutter] = True
307
+ return is_clutter
308
+
309
+
310
+ def _filter_falling(bits: CategoryBits, obs: ClassData) -> None:
228
311
  # filter falling ice speckle noise
312
+ is_beta = ~obs.beta.mask
229
313
  is_freezing = bits.freezing
230
314
  is_falling = bits.falling
231
- is_falling_filtered = utils.remove_small_objects(
232
- is_falling,
233
- max_size=10,
234
- connectivity=1,
315
+ filtered_out = is_falling & ~np.asarray(
316
+ utils.remove_small_objects(
317
+ is_falling,
318
+ max_size=10,
319
+ connectivity=1,
320
+ ),
321
+ dtype=bool,
235
322
  )
236
- is_filtered = is_falling & ~np.array(is_falling_filtered)
237
- ice_ind = np.where(is_freezing & is_filtered)
238
- is_falling[ice_ind] = False
239
- # in warm these are (probably) insects
240
- insect_ind = np.where(~is_freezing & is_filtered)
241
- is_falling[insect_ind] = False
323
+ is_falling[filtered_out] = False
324
+ # In warm conditions, these are likely insects
325
+ bits.insect[filtered_out & ~is_freezing] = True
326
+ bits.aerosol[filtered_out & ~is_freezing & is_beta] = True
327
+ # In cold conditions, classify as aerosol if lidar signal is present
328
+ bits.aerosol[filtered_out & is_freezing & is_beta] = True
242
329
  bits.falling = is_falling
243
- bits.insect[insect_ind] = True
244
- return ice_ind
@@ -16,6 +16,7 @@ from cloudnetpy.categorize.itu import (
16
16
  calc_saturation_vapor_pressure,
17
17
  )
18
18
  from cloudnetpy.cloudnetarray import CloudnetArray
19
+ from cloudnetpy.constants import G
19
20
  from cloudnetpy.datasource import DataSource
20
21
  from cloudnetpy.exceptions import ModelDataError
21
22
 
@@ -115,6 +116,7 @@ class Model(DataSource):
115
116
  array,
116
117
  time_grid,
117
118
  half_height if "atten" in key else height_grid,
119
+ extrapolate=True,
118
120
  )
119
121
  self.height = height_grid
120
122
  return utils.find_masked_profiles_indices(self.data_dense["temperature"])
@@ -144,7 +146,26 @@ class Model(DataSource):
144
146
  except KeyError as err:
145
147
  msg = "No 'height' variable in the model file."
146
148
  raise ModelDataError(msg) from err
147
- return self.to_m(model_heights) + alt_site
149
+ surface_altitude = self._get_model_surface_altitude(alt_site)
150
+ return self.to_m(model_heights) + surface_altitude
151
+
152
+ def _get_model_surface_altitude(self, alt_site: float) -> float | npt.NDArray:
153
+ """Returns model surface altitude from geopotential if available.
154
+
155
+ For sites in complex terrain (e.g. mountains), the model grid cell
156
+ surface height can differ significantly from the actual site altitude.
157
+ Using the model's own surface height ensures that thermodynamic fields
158
+ are placed at their physically correct absolute heights.
159
+
160
+ Note: Model surface altitude might be higher than the site altitude.
161
+ """
162
+ if "sfc_height" in self.dataset.variables:
163
+ sfc_height = self.dataset.variables["sfc_height"][:]
164
+ return sfc_height[:, np.newaxis]
165
+ if "sfc_geopotential" in self.dataset.variables:
166
+ geopotential = self.dataset.variables["sfc_geopotential"][:]
167
+ return geopotential[:, np.newaxis] / G
168
+ return alt_site
148
169
 
149
170
  def calc_attenuations(self, frequency: float) -> None:
150
171
  temperature = self.getvar("temperature")
@@ -123,26 +123,12 @@ class Radar(DataSource):
123
123
  if variable not in self.data:
124
124
  return
125
125
  data = ma.copy(self.data[variable][:])
126
- n_points_in_profiles = ma.count(data, axis=1)
127
- n_profiles_with_data = np.count_nonzero(n_points_in_profiles)
126
+ n_profiles_with_data = np.count_nonzero(ma.count(data, axis=1))
128
127
  if n_profiles_with_data < 300:
129
128
  return
130
- n_vertical = self._filter(
131
- data,
132
- axis=1,
133
- min_coverage=0.5,
134
- z_limit=10,
135
- distance=4,
136
- n_blocks=100,
137
- )
138
- n_horizontal = self._filter(
139
- data,
140
- axis=0,
141
- min_coverage=0.3,
142
- z_limit=-30,
143
- distance=3,
144
- n_blocks=20,
145
- )
129
+
130
+ n_vertical = self._filter_stripes_axis(data, variable, axis=1)
131
+ n_horizontal = self._filter_stripes_axis(data, variable, axis=0)
146
132
  if n_vertical > 0 or n_horizontal > 0:
147
133
  logging.debug(
148
134
  "Filtered %s vertical and %s horizontal stripes "
@@ -152,57 +138,74 @@ class Radar(DataSource):
152
138
  variable,
153
139
  )
154
140
 
155
- def _filter(
141
+ def _filter_stripes_axis(
156
142
  self,
157
143
  data: npt.NDArray,
144
+ variable: str,
158
145
  axis: int,
159
- min_coverage: float,
160
- z_limit: float,
161
- distance: float,
162
- n_blocks: int,
163
146
  ) -> int:
164
- if axis == 0:
165
- data = data.T
166
- echo = self.data["Z"][:].T
167
- else:
168
- echo = self.data["Z"][:]
169
-
170
- len_block = int(np.floor(data.shape[0] / n_blocks))
171
- block_indices = np.arange(len_block)
172
- n_removed_total = 0
173
-
174
- for block_number in range(n_blocks):
175
- data_block = data[block_indices, :]
176
- n_values = ma.count(data_block, axis=1)
177
- try:
178
- q1 = np.quantile(n_values, 0.25)
179
- q3 = np.quantile(n_values, 0.75)
180
- except IndexError:
181
- continue
182
-
183
- if q1 == q3:
184
- continue
185
-
186
- threshold = distance * (q3 - q1) + q3
187
-
188
- indices = np.where(
189
- (n_values > threshold) & (n_values > (min_coverage * data.shape[1])),
190
- )[0]
191
- true_ind = [int(x) for x in (block_number * len_block + indices)]
192
- n_removed = len(indices)
193
-
194
- if n_removed > 5:
147
+ n_values = ma.count(data, axis=axis)
148
+ n = len(n_values)
149
+ margin = 5
150
+
151
+ spike_indices: set[int] = set()
152
+ for i in range(margin, n - margin):
153
+ window = [n_values[i + j] for j in range(-margin, margin + 1) if j != 0]
154
+ q1, q3 = np.percentile(window, [25, 75])
155
+ iqr = q3 - q1
156
+ if iqr == 0 and q3 > 0:
195
157
  continue
158
+ threshold = q3 + 3 * max(iqr, 1)
159
+ if n_values[i] > max(threshold, 20):
160
+ spike_indices.add(i)
161
+
162
+ for ind in sorted(spike_indices):
163
+ below = ind - 1
164
+ while below >= 0 and below in spike_indices:
165
+ below -= 1
166
+ above = ind + 1
167
+ while above < n and above in spike_indices:
168
+ above += 1
169
+ self._mask_stripe_pixels(variable, ind, axis, below, above, n)
170
+
171
+ return len(spike_indices)
172
+
173
+ def _mask_stripe_pixels(
174
+ self,
175
+ variable: str,
176
+ ind: int,
177
+ axis: int,
178
+ below: int,
179
+ above: int,
180
+ dim_size: int,
181
+ ) -> None:
182
+ self._mask_isolated("v", "Z", ind, axis, below, above, dim_size)
183
+ if variable != "v" and variable in self.data:
184
+ self._mask_isolated(variable, variable, ind, axis, below, above, dim_size)
196
185
 
197
- if n_removed > 0:
198
- n_removed_total += n_removed
199
- for ind in true_ind:
200
- ind2 = np.where(echo[ind, :] < z_limit)
201
- bad_indices = (ind, ind2) if axis == 1 else (ind2, ind)
202
- self.data["v"][:][bad_indices] = ma.masked
203
- block_indices += len_block
204
-
205
- return n_removed_total
186
+ def _mask_isolated(
187
+ self,
188
+ target: str,
189
+ source: str,
190
+ ind: int,
191
+ axis: int,
192
+ below: int,
193
+ above: int,
194
+ dim_size: int,
195
+ ) -> None:
196
+ mask = ma.getmaskarray(self.data[source][:])
197
+ has = ~mask[:, ind] if axis == 0 else ~mask[ind, :]
198
+ lo = (mask[:, below] if axis == 0 else mask[below, :]) if below >= 0 else True
199
+ hi = (
200
+ (mask[:, above] if axis == 0 else mask[above, :])
201
+ if above < dim_size
202
+ else True
203
+ )
204
+ isolated = has & lo & hi
205
+ if axis == 0:
206
+ self.data[target][:][isolated, ind] = ma.masked
207
+ else:
208
+ self.data[target][:][ind, isolated] = ma.masked
206
209
 
207
210
  def correct_atten(self, attenuations: RadarAttenuation) -> None:
208
211
  """Corrects radar echo for liquid and gas attenuation.
@@ -1,5 +1,6 @@
1
1
  import argparse
2
2
  import base64
3
+ import concurrent.futures
3
4
  import datetime
4
5
  import gzip
5
6
  import hashlib
@@ -88,21 +89,51 @@ def _process_categorize(
88
89
  input_files: dict, args: argparse.Namespace, client: APIClient
89
90
  ) -> str | None:
90
91
  cat_filepath = _create_categorize_filepath(args)
92
+ instrument_prefs = _parse_instrument_preferences(args.instrument)
93
+
94
+ def fetch_instrument(product: str) -> tuple[str, str | None]:
95
+ source = instrument_prefs.get(product)
96
+ filepath = _fetch_product(args, product, client, source=source)
97
+ if filepath is None and source:
98
+ logging.info(
99
+ "Preferred instrument '%s' not found for %s, using available",
100
+ source,
101
+ product,
102
+ )
103
+ filepath = _fetch_product(args, product, client)
104
+ return product, filepath
91
105
 
92
- input_files["model"] = _fetch_model(args, client)
93
- if input_files["model"] is None:
106
+ def fetch_mwr() -> tuple[str, str | None]:
107
+ try:
108
+ return "mwr", _fetch_mwr(args, client)
109
+ except (CloudnetAPIError, KeyError, ValueError):
110
+ logging.info("LWP data not available, continuing without it")
111
+ return "mwr", None
112
+
113
+ def fetch_model() -> tuple[str, str | None]:
114
+ return "model", _fetch_model(args, client)
115
+
116
+ with concurrent.futures.ThreadPoolExecutor() as executor:
117
+ futures = [executor.submit(fetch_model)]
118
+ for product in ("radar", "lidar", "disdrometer"):
119
+ if product not in input_files:
120
+ futures.append(executor.submit(fetch_instrument, product))
121
+
122
+ for future in concurrent.futures.as_completed(futures):
123
+ product, filepath = future.result()
124
+ if filepath:
125
+ input_files[product] = filepath
126
+
127
+ # MWR runs after other downloads because its fallback chain may
128
+ # try to download the same radar file fetched above.
129
+ mwr_key, mwr_path = fetch_mwr()
130
+ if mwr_path:
131
+ input_files[mwr_key] = mwr_path
132
+
133
+ if "model" not in input_files:
94
134
  logging.info("No model data available for this date.")
95
135
  return None
96
136
 
97
- for product in ("radar", "lidar", "disdrometer"):
98
- if product not in input_files and (
99
- filepath := _fetch_product(args, product, client)
100
- ):
101
- input_files[product] = filepath
102
-
103
- if mwr := _fetch_mwr(args, client):
104
- input_files["mwr"] = mwr
105
-
106
137
  try:
107
138
  logging.info("Processing categorize...")
108
139
  generate_categorize(cast("CategorizeInput", input_files), cat_filepath)
@@ -153,6 +184,9 @@ def _process_instrument_product(
153
184
  fun: Callable
154
185
  match (product, instrument.instrument_id):
155
186
  case ("radar", _id) if "mira" in _id:
187
+ site_meta["range_correction_factor"] = calibration.get(
188
+ "range_correction_factor"
189
+ )
156
190
  fun = instruments.mira2nc
157
191
  case ("radar", _id) if "rpg" in _id:
158
192
  fun = instruments.rpg2nc
@@ -335,6 +369,20 @@ def _get_product_sources(
335
369
  return source_products
336
370
 
337
371
 
372
+ def _parse_instrument_preferences(args: list[str] | None) -> dict[str, str]:
373
+ """Parses instrument preferences like 'radar:mira-35' into a dict."""
374
+ if not args:
375
+ return {}
376
+ prefs = {}
377
+ for arg in args:
378
+ if ":" not in arg:
379
+ msg = f"Invalid instrument format '{arg}', expected 'product:instrument_id'"
380
+ raise argparse.ArgumentTypeError(msg)
381
+ product, instrument_id = arg.split(":", 1)
382
+ prefs[product] = instrument_id
383
+ return prefs
384
+
385
+
338
386
  def _parse_instrument(s: str) -> tuple[str, str | None]:
339
387
  if "[" in s and s.endswith("]"):
340
388
  name = s[: s.index("[")]
@@ -379,7 +427,6 @@ def _fetch_product(
379
427
  if m.instrument is not None and m.instrument.instrument_id == source
380
428
  ]
381
429
  if not meta:
382
- logging.info("No data available for %s", product)
383
430
  return None
384
431
  if len(meta) > 1:
385
432
  logging.info(
@@ -388,16 +435,18 @@ def _fetch_product(
388
435
  meta = [meta[0]]
389
436
  suffix = "geophysical" if "geophysical" in meta[0].product.type else "instrument"
390
437
  folder = _create_output_folder(suffix, args)
391
- return _download_product_file(meta, folder, client)
438
+ return _download_product_file(meta, folder, client, force=args.force_download)
392
439
 
393
440
 
394
441
  def _fetch_model(args: argparse.Namespace, client: APIClient) -> str | None:
395
- files = client.files(product_id="model", date=args.date, site_id=args.site)
442
+ files = client.files(
443
+ product_id="model", model_id=args.model, date=args.date, site_id=args.site
444
+ )
396
445
  if not files:
397
446
  logging.info("No model data available for this date")
398
447
  return None
399
448
  folder = _create_output_folder("instrument", args)
400
- return _download_product_file(files, folder, client)
449
+ return _download_product_file(files, folder, client, force=args.force_download)
401
450
 
402
451
 
403
452
  def _fetch_raw(
@@ -406,19 +455,32 @@ def _fetch_raw(
406
455
  pid = _shorten_pid(metadata[0].instrument.pid)
407
456
  instrument = f"{metadata[0].instrument.instrument_id}_{pid}"
408
457
  folder = _create_input_folder(instrument, args)
458
+ if args.force_download:
459
+ for meta in metadata:
460
+ filepath = folder / meta.download_url.split("/")[-1]
461
+ if filepath.exists():
462
+ logging.info("Removing existing file: %s", filepath)
463
+ filepath.unlink()
409
464
  return client.download(metadata, output_directory=folder)
410
465
 
411
466
 
412
467
  def _download_product_file(
413
- meta: list[ProductMetadata], folder: Path, client: APIClient
468
+ meta: list[ProductMetadata],
469
+ folder: Path,
470
+ client: APIClient,
471
+ *,
472
+ force: bool = False,
414
473
  ) -> str:
415
474
  if len(meta) > 1:
416
475
  msg = "Multiple product files found"
417
476
  raise ValueError(msg)
418
477
  filepath = folder / meta[0].filename
419
- if filepath.exists():
478
+ if filepath.exists() and not force:
420
479
  logging.info("Existing file found: %s", filepath)
421
480
  return str(filepath)
481
+ if filepath.exists():
482
+ logging.info("Removing existing file: %s", filepath)
483
+ filepath.unlink()
422
484
  logging.info("Downloading file: %s", filepath)
423
485
  return str(client.download(meta, output_directory=folder)[0])
424
486
 
@@ -438,10 +500,13 @@ def _plot(
438
500
  ) -> None:
439
501
  if filepath is None or (not args.plot and not args.show):
440
502
  return
441
- res = requests.get(f"{cloudnet_api_url}products/variables", timeout=60)
442
- res.raise_for_status()
443
- variables = next(var["variables"] for var in res.json() if var["id"] == product)
444
- variables = [var["id"].split("-")[-1] for var in variables]
503
+ if args.variables is not None:
504
+ variables = args.variables.split(",")
505
+ else:
506
+ res = requests.get(f"{cloudnet_api_url}products/variables", timeout=60)
507
+ res.raise_for_status()
508
+ variables = next(var["variables"] for var in res.json() if var["id"] == product)
509
+ variables = [var["id"].split("-")[-1] for var in variables]
445
510
  image_name = str(filepath).replace(".nc", ".png") if args.plot else None
446
511
  try:
447
512
  generate_figure(
@@ -486,6 +551,21 @@ def _parse_products(product_argument: str, client: APIClient) -> list[str]:
486
551
  return valid_products
487
552
 
488
553
 
554
+ def _parse_model(model_id: str, client: APIClient) -> str:
555
+ models = client.models()
556
+ found = False
557
+ for model in models:
558
+ if model_id == model.source_model_id and model.id != model.source_model_id:
559
+ msg = f"Use {model.id} or similar instead of {model_id}"
560
+ raise argparse.ArgumentTypeError(msg)
561
+ if model_id == model.id:
562
+ found = True
563
+ if not found:
564
+ msg = f"Invalid model: {model_id}"
565
+ raise argparse.ArgumentTypeError(msg)
566
+ return model_id
567
+
568
+
489
569
  def main() -> None:
490
570
  client = APIClient()
491
571
  parser = argparse.ArgumentParser(
@@ -501,7 +581,11 @@ def main() -> None:
501
581
  metavar="SITE",
502
582
  )
503
583
  parser.add_argument(
504
- "-d", "--date", type=str, help="Date in YYYY-MM-DD", required=True
584
+ "-d",
585
+ "--date",
586
+ type=str,
587
+ help="Date in YYYY-MM-DD (default: today)",
588
+ default=datetime.datetime.now(tz=datetime.timezone.utc).date().isoformat(),
505
589
  )
506
590
  parser.add_argument(
507
591
  "-p",
@@ -514,6 +598,23 @@ def main() -> None:
514
598
  ),
515
599
  required=True,
516
600
  )
601
+ parser.add_argument(
602
+ "-m",
603
+ "--model",
604
+ type=lambda arg: _parse_model(arg, client),
605
+ help="Model to use in categorize.",
606
+ )
607
+ parser.add_argument(
608
+ "-i",
609
+ "--instrument",
610
+ type=str,
611
+ action="append",
612
+ help=(
613
+ "Preferred instrument for categorize input, e.g. "
614
+ "'radar:mira-35' or 'lidar:cl61d'. Can be specified multiple times."
615
+ ),
616
+ default=None,
617
+ )
517
618
  parser.add_argument("--input", type=Path, help="Input path", default="input/")
518
619
  parser.add_argument("--output", type=Path, help="Output path", default="output/")
519
620
  parser.add_argument(
@@ -534,6 +635,19 @@ def main() -> None:
534
635
  default=False,
535
636
  action=argparse.BooleanOptionalAction,
536
637
  )
638
+ parser.add_argument(
639
+ "--force-download",
640
+ help="Force re-download of input files even if they exist locally",
641
+ default=False,
642
+ action=argparse.BooleanOptionalAction,
643
+ )
644
+ parser.add_argument(
645
+ "-v",
646
+ "--variables",
647
+ type=str,
648
+ help="Variables to plot (comma-separated), e.g. 'target_classification'",
649
+ default=None,
650
+ )
537
651
  args = parser.parse_args()
538
652
 
539
653
  logger = logging.getLogger()
@@ -156,6 +156,7 @@ class Mira(NcRadar):
156
156
  if "tpow" not in self.data:
157
157
  logging.warning("Variable tpow is missing")
158
158
  return
159
+ self._correct_fzk_tpow()
159
160
  tpow = self.data["tpow"][:]
160
161
  # Threshold for abnormally low power e.g. Limassol 2024-10-20. Average
161
162
  # power should 30 to 60 W according to MIRA-35 data sheet. Based on a
@@ -169,6 +170,27 @@ class Mira(NcRadar):
169
170
  )
170
171
  self.screen_time_indices(~is_low)
171
172
 
173
+ def _correct_fzk_tpow(self) -> None:
174
+ """Corrects tpow for old FZK-serial instruments missing the FZK100 fix.
175
+
176
+ Some MIRA-35 instruments with serial number 'fzk' had a firmware bug
177
+ where tpow was reported 100x too small. Files produced after the
178
+ firmware fix contain 'FZK100' in the 'hrd' global attribute. Files
179
+ without this field need tpow multiplied by 100.
180
+ """
181
+ hrd = getattr(self.dataset, "hrd", "")
182
+ if "FZK100" in hrd:
183
+ return
184
+ sn_lines = [line for line in hrd.split("\n") if line.strip().startswith("SN:")]
185
+ if not sn_lines:
186
+ return
187
+ sn = sn_lines[0].split(":", 1)[1].strip()
188
+ if sn != "fzk":
189
+ return
190
+ logging.info("Correcting tpow by factor 100 for old FZK instrument firmware")
191
+ self.data["tpow"].data[:] *= 100
192
+ self.data["tpow"].correction_factor = 100
193
+
172
194
  def screen_invalid_ldr(self) -> None:
173
195
  """Masks LDR in MIRA STSR mode data.
174
196
  Is there a better way to identify this mode?