cloudnetpy 1.49.9__py3-none-any.whl → 1.87.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. cloudnetpy/categorize/__init__.py +1 -2
  2. cloudnetpy/categorize/atmos_utils.py +297 -67
  3. cloudnetpy/categorize/attenuation.py +31 -0
  4. cloudnetpy/categorize/attenuations/__init__.py +37 -0
  5. cloudnetpy/categorize/attenuations/gas_attenuation.py +30 -0
  6. cloudnetpy/categorize/attenuations/liquid_attenuation.py +84 -0
  7. cloudnetpy/categorize/attenuations/melting_attenuation.py +78 -0
  8. cloudnetpy/categorize/attenuations/rain_attenuation.py +84 -0
  9. cloudnetpy/categorize/categorize.py +332 -156
  10. cloudnetpy/categorize/classify.py +127 -125
  11. cloudnetpy/categorize/containers.py +107 -76
  12. cloudnetpy/categorize/disdrometer.py +40 -0
  13. cloudnetpy/categorize/droplet.py +23 -21
  14. cloudnetpy/categorize/falling.py +53 -24
  15. cloudnetpy/categorize/freezing.py +25 -12
  16. cloudnetpy/categorize/insects.py +35 -23
  17. cloudnetpy/categorize/itu.py +243 -0
  18. cloudnetpy/categorize/lidar.py +36 -41
  19. cloudnetpy/categorize/melting.py +34 -26
  20. cloudnetpy/categorize/model.py +84 -37
  21. cloudnetpy/categorize/mwr.py +18 -14
  22. cloudnetpy/categorize/radar.py +215 -102
  23. cloudnetpy/cli.py +578 -0
  24. cloudnetpy/cloudnetarray.py +43 -89
  25. cloudnetpy/concat_lib.py +218 -78
  26. cloudnetpy/constants.py +28 -10
  27. cloudnetpy/datasource.py +61 -86
  28. cloudnetpy/exceptions.py +49 -20
  29. cloudnetpy/instruments/__init__.py +5 -0
  30. cloudnetpy/instruments/basta.py +29 -12
  31. cloudnetpy/instruments/bowtie.py +135 -0
  32. cloudnetpy/instruments/ceilo.py +138 -115
  33. cloudnetpy/instruments/ceilometer.py +164 -80
  34. cloudnetpy/instruments/cl61d.py +21 -5
  35. cloudnetpy/instruments/cloudnet_instrument.py +74 -36
  36. cloudnetpy/instruments/copernicus.py +108 -30
  37. cloudnetpy/instruments/da10.py +54 -0
  38. cloudnetpy/instruments/disdrometer/common.py +126 -223
  39. cloudnetpy/instruments/disdrometer/parsivel.py +453 -94
  40. cloudnetpy/instruments/disdrometer/thies.py +254 -87
  41. cloudnetpy/instruments/fd12p.py +201 -0
  42. cloudnetpy/instruments/galileo.py +65 -23
  43. cloudnetpy/instruments/hatpro.py +123 -49
  44. cloudnetpy/instruments/instruments.py +113 -1
  45. cloudnetpy/instruments/lufft.py +39 -17
  46. cloudnetpy/instruments/mira.py +268 -61
  47. cloudnetpy/instruments/mrr.py +187 -0
  48. cloudnetpy/instruments/nc_lidar.py +19 -8
  49. cloudnetpy/instruments/nc_radar.py +109 -55
  50. cloudnetpy/instruments/pollyxt.py +135 -51
  51. cloudnetpy/instruments/radiometrics.py +313 -59
  52. cloudnetpy/instruments/rain_e_h3.py +171 -0
  53. cloudnetpy/instruments/rpg.py +321 -189
  54. cloudnetpy/instruments/rpg_reader.py +74 -40
  55. cloudnetpy/instruments/toa5.py +49 -0
  56. cloudnetpy/instruments/vaisala.py +95 -343
  57. cloudnetpy/instruments/weather_station.py +774 -105
  58. cloudnetpy/metadata.py +90 -19
  59. cloudnetpy/model_evaluation/file_handler.py +55 -52
  60. cloudnetpy/model_evaluation/metadata.py +46 -20
  61. cloudnetpy/model_evaluation/model_metadata.py +1 -1
  62. cloudnetpy/model_evaluation/plotting/plot_tools.py +32 -37
  63. cloudnetpy/model_evaluation/plotting/plotting.py +327 -117
  64. cloudnetpy/model_evaluation/products/advance_methods.py +92 -83
  65. cloudnetpy/model_evaluation/products/grid_methods.py +88 -63
  66. cloudnetpy/model_evaluation/products/model_products.py +43 -35
  67. cloudnetpy/model_evaluation/products/observation_products.py +41 -35
  68. cloudnetpy/model_evaluation/products/product_resampling.py +17 -7
  69. cloudnetpy/model_evaluation/products/tools.py +29 -20
  70. cloudnetpy/model_evaluation/statistics/statistical_methods.py +30 -20
  71. cloudnetpy/model_evaluation/tests/e2e/conftest.py +3 -3
  72. cloudnetpy/model_evaluation/tests/e2e/process_cf/main.py +9 -5
  73. cloudnetpy/model_evaluation/tests/e2e/process_cf/tests.py +15 -14
  74. cloudnetpy/model_evaluation/tests/e2e/process_iwc/main.py +9 -5
  75. cloudnetpy/model_evaluation/tests/e2e/process_iwc/tests.py +15 -14
  76. cloudnetpy/model_evaluation/tests/e2e/process_lwc/main.py +9 -5
  77. cloudnetpy/model_evaluation/tests/e2e/process_lwc/tests.py +15 -14
  78. cloudnetpy/model_evaluation/tests/unit/conftest.py +42 -41
  79. cloudnetpy/model_evaluation/tests/unit/test_advance_methods.py +41 -48
  80. cloudnetpy/model_evaluation/tests/unit/test_grid_methods.py +216 -194
  81. cloudnetpy/model_evaluation/tests/unit/test_model_products.py +23 -21
  82. cloudnetpy/model_evaluation/tests/unit/test_observation_products.py +37 -38
  83. cloudnetpy/model_evaluation/tests/unit/test_plot_tools.py +43 -40
  84. cloudnetpy/model_evaluation/tests/unit/test_plotting.py +30 -36
  85. cloudnetpy/model_evaluation/tests/unit/test_statistical_methods.py +68 -31
  86. cloudnetpy/model_evaluation/tests/unit/test_tools.py +33 -26
  87. cloudnetpy/model_evaluation/utils.py +2 -1
  88. cloudnetpy/output.py +170 -111
  89. cloudnetpy/plotting/__init__.py +2 -1
  90. cloudnetpy/plotting/plot_meta.py +562 -822
  91. cloudnetpy/plotting/plotting.py +1142 -704
  92. cloudnetpy/products/__init__.py +1 -0
  93. cloudnetpy/products/classification.py +370 -88
  94. cloudnetpy/products/der.py +85 -55
  95. cloudnetpy/products/drizzle.py +77 -34
  96. cloudnetpy/products/drizzle_error.py +15 -11
  97. cloudnetpy/products/drizzle_tools.py +79 -59
  98. cloudnetpy/products/epsilon.py +211 -0
  99. cloudnetpy/products/ier.py +27 -50
  100. cloudnetpy/products/iwc.py +55 -48
  101. cloudnetpy/products/lwc.py +96 -70
  102. cloudnetpy/products/mwr_tools.py +186 -0
  103. cloudnetpy/products/product_tools.py +170 -128
  104. cloudnetpy/utils.py +455 -240
  105. cloudnetpy/version.py +2 -2
  106. {cloudnetpy-1.49.9.dist-info → cloudnetpy-1.87.3.dist-info}/METADATA +44 -40
  107. cloudnetpy-1.87.3.dist-info/RECORD +127 -0
  108. {cloudnetpy-1.49.9.dist-info → cloudnetpy-1.87.3.dist-info}/WHEEL +1 -1
  109. cloudnetpy-1.87.3.dist-info/entry_points.txt +2 -0
  110. docs/source/conf.py +2 -2
  111. cloudnetpy/categorize/atmos.py +0 -361
  112. cloudnetpy/products/mwr_multi.py +0 -68
  113. cloudnetpy/products/mwr_single.py +0 -75
  114. cloudnetpy-1.49.9.dist-info/RECORD +0 -112
  115. {cloudnetpy-1.49.9.dist-info → cloudnetpy-1.87.3.dist-info/licenses}/LICENSE +0 -0
  116. {cloudnetpy-1.49.9.dist-info → cloudnetpy-1.87.3.dist-info}/top_level.txt +0 -0
@@ -1,26 +1,35 @@
1
1
  """Module for reading Radiometrics MP3014 microwave radiometer data."""
2
+
2
3
  import csv
3
4
  import datetime
4
5
  import logging
6
+ import math
5
7
  import os
8
+ import re
6
9
  from operator import attrgetter
10
+ from os import PathLike
11
+ from pathlib import Path
7
12
  from typing import Any, NamedTuple
13
+ from uuid import UUID
8
14
 
9
15
  import numpy as np
16
+ import numpy.typing as npt
17
+ from numpy import ma
10
18
 
11
19
  from cloudnetpy import output, utils
12
20
  from cloudnetpy.cloudnetarray import CloudnetArray
13
- from cloudnetpy.exceptions import ValidTimeStampError
21
+ from cloudnetpy.exceptions import InconsistentDataError, ValidTimeStampError
14
22
  from cloudnetpy.instruments import instruments
23
+ from cloudnetpy.metadata import MetaData
15
24
 
16
25
 
17
26
  def radiometrics2nc(
18
- full_path: str,
19
- output_file: str,
27
+ full_path: str | PathLike,
28
+ output_file: str | PathLike,
20
29
  site_meta: dict,
21
- uuid: str | None = None,
30
+ uuid: str | UUID | None = None,
22
31
  date: str | datetime.date | None = None,
23
- ) -> str:
32
+ ) -> UUID:
24
33
  """Converts Radiometrics .csv file into Cloudnet Level 1b netCDF file.
25
34
 
26
35
  Args:
@@ -43,28 +52,24 @@ def radiometrics2nc(
43
52
  """
44
53
  if isinstance(date, str):
45
54
  date = datetime.date.fromisoformat(date)
46
-
55
+ uuid = utils.get_uuid(uuid)
47
56
  if os.path.isdir(full_path):
48
- valid_filenames = utils.get_sorted_filenames(full_path, ".csv")
57
+ valid_filenames = list(Path(full_path).iterdir())
49
58
  else:
50
- valid_filenames = [full_path]
51
-
52
- objs = []
53
- for filename in valid_filenames:
54
- obj = Radiometrics(filename)
55
- obj.read_raw_data()
56
- obj.read_data()
57
- objs.append(obj)
58
-
59
+ valid_filenames = [Path(full_path)]
60
+ objs = [_read_file(filename) for filename in valid_filenames]
59
61
  radiometrics = RadiometricsCombined(objs, site_meta)
60
62
  radiometrics.screen_time(date)
63
+ radiometrics.sort_timestamps()
61
64
  radiometrics.time_to_fractional_hours()
62
65
  radiometrics.data_to_cloudnet_arrays()
63
66
  radiometrics.add_meta()
64
- assert radiometrics.date is not None
65
- attributes = output.add_time_attribute({}, radiometrics.date)
67
+ if radiometrics.date is None:
68
+ msg = "Failed to find valid timestamps from Radiometrics file(s)."
69
+ raise ValidTimeStampError(msg)
70
+ attributes = output.add_time_attribute(ATTRIBUTES, radiometrics.date)
66
71
  output.update_attributes(radiometrics.data, attributes)
67
- uuid = output.save_level1b(radiometrics, output_file, uuid)
72
+ output.save_level1b(radiometrics, output_file, uuid)
68
73
  return uuid
69
74
 
70
75
 
@@ -76,32 +81,43 @@ class Record(NamedTuple):
76
81
  values: dict[str, Any]
77
82
 
78
83
 
79
- class Radiometrics:
80
- """Reader for level 2 files of Radiometrics microwave radiometers.
84
+ class RadiometricsMP:
85
+ """Reader for level 2 files (*.csv) from Radiometrics MP-3000A and similar
86
+ microwave radiometers.
81
87
 
82
88
  References:
83
89
  Radiometrics (2008). Profiler Operator's Manual: MP-3000A, MP-2500A,
84
90
  MP-1500A, MP-183A.
85
91
  """
86
92
 
87
- def __init__(self, filename: str):
93
+ def __init__(self, filename: Path) -> None:
88
94
  self.filename = filename
89
95
  self.raw_data: list[Record] = []
90
96
  self.data: dict = {}
91
97
  self.instrument = instruments.RADIOMETRICS
98
+ self.ranges: list[str] = []
92
99
 
93
- def read_raw_data(self):
100
+ def read_raw_data(self) -> None:
94
101
  """Reads Radiometrics raw data."""
95
102
  record_columns = {}
96
103
  unknown_record_types = set()
97
104
  rows = []
98
- with open(self.filename, mode="r", encoding="utf8") as infile:
99
- reader = csv.reader(infile)
105
+ with open(self.filename, encoding="utf8") as infile:
106
+ reader = csv.reader(infile, skipinitialspace=True)
100
107
  for row in reader:
101
108
  if row[0] == "Record":
102
- assert row[1] == "Date/Time"
109
+ if row[1] != "Date/Time":
110
+ msg = "Unexpected header in Radiometrics file"
111
+ raise RuntimeError(msg)
103
112
  record_type = int(row[2])
104
- record_columns[record_type] = row[3:]
113
+ columns = row[3:]
114
+ record_columns[record_type] = columns
115
+ if record_type in (10, 400):
116
+ self.ranges = [
117
+ column
118
+ for column in columns
119
+ if re.fullmatch(r"\d+\.\d+", column)
120
+ ]
105
121
  else:
106
122
  record_type = int(row[2])
107
123
  block_type = record_type // 10 * 10
@@ -109,45 +125,201 @@ class Radiometrics:
109
125
  column_names = record_columns.get(block_type)
110
126
  if column_names is None:
111
127
  if record_type not in unknown_record_types:
112
- logging.info(f"Skipping unknown record type {record_type}")
128
+ logging.info("Skipping unknown record type %d", record_type)
113
129
  unknown_record_types.add(record_type)
114
130
  continue
131
+
132
+ row_trimmed = [value for value in row if value != ""]
133
+
115
134
  record = Record(
116
135
  row_number=int(row[0]),
117
136
  timestamp=_parse_datetime(row[1]),
118
137
  block_type=block_type,
119
138
  block_index=block_index,
120
- values=dict(zip(column_names, row[3:])),
139
+ values=dict(zip(column_names, row_trimmed[3:], strict=True)),
121
140
  )
122
141
  rows.append(record)
123
142
 
124
- # The rows should be in sequence but sort them just to be sure.
125
- rows.sort(key=attrgetter("row_number"))
143
+ self.raw_data = sorted(rows, key=attrgetter("row_number"))
126
144
 
127
- for data_row in rows:
128
- # Use the first row of a block and skip the rest which should
129
- # contain the same values in the first columns.
130
- if data_row.block_index == 0:
131
- self.raw_data.append(data_row)
132
-
133
- def read_data(self):
145
+ def read_data(self) -> None:
134
146
  """Reads values."""
135
147
  times = []
136
148
  lwps = []
137
149
  iwvs = []
150
+ irts = []
151
+ irt_times = []
152
+ temps = []
153
+ temp_times = []
154
+ rhs = []
155
+ rh_times = []
156
+ ahs = []
157
+ ah_times = []
158
+ block_titles = {}
159
+ superblock: list[Record] = []
160
+
161
+ def _parse_floats(record: Record) -> list[float]:
162
+ return [
163
+ float(record.values[column])
164
+ if record.values[column].replace(".", "", 1).isdigit()
165
+ else math.nan
166
+ for column in self.ranges
167
+ ]
168
+
169
+ def _process_superblock() -> None:
170
+ # There can be multiple 301 records but they don't have "LV2
171
+ # Processor" column. We can deduce the "LV2 Processor" values from
172
+ # 401 records in the same "superblock" before or after the 301
173
+ # records.
174
+ procs = []
175
+ for record in superblock:
176
+ if record.block_type == 400 and record.block_index == 1:
177
+ procs.append(record.values["LV2 Processor"])
178
+ good_procs = ["0.00:19.80", "Zenith", "Zenith16", "Zenith18"]
179
+ curr_proc = 0
180
+ for record in superblock:
181
+ if record.block_type == 100:
182
+ block_type = int(record.values["Record Type"]) - 1
183
+ title = record.values["Title"]
184
+ block_titles[block_type] = title
185
+ if title := block_titles.get(record.block_type + record.block_index):
186
+ if record.values["LV2 Processor"] not in good_procs:
187
+ continue
188
+ if title == "Temperature (K)":
189
+ temp_times.append(record.timestamp)
190
+ temps.append(_parse_floats(record))
191
+ elif title == "Relative Humidity (%)":
192
+ rh_times.append(record.timestamp)
193
+ rhs.append(_parse_floats(record))
194
+ elif title == "Vapor Density (g/m^3)":
195
+ ah_times.append(record.timestamp)
196
+ ahs.append(_parse_floats(record))
197
+ elif record.block_type == 10:
198
+ if record.block_index == 0:
199
+ lwp = record.values["Lqint(mm)"]
200
+ iwv = record.values["Vint(cm)"]
201
+ irt = record.values["Tir(K)"]
202
+ times.append(record.timestamp)
203
+ lwps.append(float(lwp))
204
+ iwvs.append(float(iwv))
205
+ irt_times.append(record.timestamp)
206
+ irts.append([float(irt)])
207
+ temp_times.append(record.timestamp)
208
+ temps.append(_parse_floats(record))
209
+ elif record.block_index == 1:
210
+ ah_times.append(record.timestamp)
211
+ ahs.append(_parse_floats(record))
212
+ elif record.block_index == 2:
213
+ rh_times.append(record.timestamp)
214
+ rhs.append(_parse_floats(record))
215
+ elif record.block_type == 200:
216
+ irt_times.append(record.timestamp)
217
+ irt = record.values["Tir(K)"]
218
+ irts.append([float(irt)])
219
+ elif record.block_type == 300:
220
+ if procs:
221
+ curr_proc += 1
222
+ if procs[curr_proc - 1] not in good_procs:
223
+ continue
224
+ lwp = record.values["Int. Liquid(mm)"]
225
+ iwv = record.values["Int. Vapor(cm)"]
226
+ times.append(record.timestamp)
227
+ lwps.append(float(lwp))
228
+ iwvs.append(float(iwv))
229
+
138
230
  for record in self.raw_data:
139
- lwp = record.values.get("Lqint(mm)", record.values.get("Int. Liquid(mm)"))
140
- iwv = record.values.get("Vint(cm)", record.values.get("Int. Vapor(cm)"))
141
- if lwp is None and iwv is None:
142
- continue
143
- times.append(record.timestamp)
144
- if lwp is not None:
145
- lwps.append(float(lwp) * 1000) # g / m2
146
- if iwv is not None:
147
- iwvs.append(float(iwv) * 100) # g / m2
231
+ if record.block_type == 200 and superblock:
232
+ _process_superblock()
233
+ superblock.clear()
234
+ superblock.append(record)
235
+ if superblock:
236
+ _process_superblock()
237
+
148
238
  self.data["time"] = np.array(times, dtype="datetime64[s]")
149
- self.data["lwp"] = np.array(lwps, dtype=float)
150
- self.data["iwv"] = np.array(iwvs, dtype=float)
239
+ self.data["lwp"] = np.array(lwps) # mm => kg m-2
240
+ self.data["iwv"] = np.array(iwvs) * 10 # cm => kg m-2
241
+ if irt_times:
242
+ self.data["irt"] = _find_closest(
243
+ np.array(irt_times, dtype="datetime64[s]"),
244
+ np.array(irts),
245
+ self.data["time"],
246
+ )
247
+ if temp_times:
248
+ self.data["temperature"] = _find_closest(
249
+ np.array(temp_times, dtype="datetime64[s]"),
250
+ np.array(temps),
251
+ self.data["time"],
252
+ )
253
+ if rh_times:
254
+ self.data["relative_humidity"] = _find_closest(
255
+ np.array(rh_times, dtype="datetime64[s]"),
256
+ np.array(rhs) / 100, # % => 1
257
+ self.data["time"],
258
+ )
259
+ if ah_times:
260
+ self.data["absolute_humidity"] = _find_closest(
261
+ np.array(ah_times, dtype="datetime64[s]"),
262
+ np.array(ahs) / 1000, # g m-3 => kg m-3
263
+ self.data["time"],
264
+ )
265
+
266
+
267
+ class RadiometricsWVR:
268
+ """Reader for *.los files from Radiometrics WVR-1100 microwave
269
+ radiometer.
270
+ """
271
+
272
+ def __init__(self, filename: Path) -> None:
273
+ self.filename = filename
274
+ self.raw_data: dict = {}
275
+ self.data: dict = {}
276
+ self.instrument = instruments.RADIOMETRICS
277
+ self.ranges: list[str] = []
278
+
279
+ def read_raw_data(self) -> None:
280
+ with open(self.filename, encoding="utf8") as file:
281
+ for line in file:
282
+ columns = line.split()
283
+ if columns[:2] == ["date", "time"]:
284
+ headers = columns
285
+ break
286
+ else:
287
+ msg = "No headers found"
288
+ raise RuntimeError(msg)
289
+ for header in headers:
290
+ self.raw_data[header] = []
291
+ for line in file:
292
+ columns = line.split()
293
+ if len(columns) != len(headers):
294
+ continue
295
+ for header, column in zip(headers, columns, strict=True):
296
+ value: datetime.date | datetime.time | float
297
+ if header == "date":
298
+ value = _parse_date(column)
299
+ elif header == "time":
300
+ value = _parse_time(column)
301
+ else:
302
+ value = _parse_value(column)
303
+ self.raw_data[header].append(value)
304
+
305
+ def read_data(self) -> None:
306
+ self.data["time"] = np.array(
307
+ [
308
+ datetime.datetime.combine(date, time)
309
+ for date, time in zip(
310
+ self.raw_data["date"], self.raw_data["time"], strict=True
311
+ )
312
+ ],
313
+ dtype="datetime64[s]",
314
+ )
315
+ self.data["lwp"] = np.array(self.raw_data["LiqCM"]) * 10 # cm => kg m-2
316
+ self.data["iwv"] = np.array(self.raw_data["VapCM"]) * 10 # cm => kg m-2
317
+ is_zenith = np.abs(np.array(self.raw_data["ELact"]) - 90.0) < 1.0
318
+ tb23_valid = np.array(self.raw_data["TbSky23"]) > 0
319
+ tb31_valid = np.array(self.raw_data["TbSky31"]) > 0
320
+ is_valid = is_zenith & tb23_valid & tb31_valid
321
+ for key in self.data:
322
+ self.data[key] = self.data[key][is_valid]
151
323
 
152
324
 
153
325
  class RadiometricsCombined:
@@ -156,16 +328,25 @@ class RadiometricsCombined:
156
328
  date: datetime.date | None
157
329
  instrument: instruments.Instrument
158
330
 
159
- def __init__(self, objs: list[Radiometrics], site_meta: dict):
331
+ def __init__(
332
+ self, objs: list[RadiometricsMP | RadiometricsWVR], site_meta: dict
333
+ ) -> None:
160
334
  self.site_meta = site_meta
161
335
  self.data = {}
162
336
  self.date = None
163
337
  for obj in objs:
338
+ if obj.ranges != objs[0].ranges:
339
+ msg = "Inconsistent range between files"
340
+ raise InconsistentDataError(msg)
164
341
  for key in obj.data:
165
342
  self.data = utils.append_data(self.data, key, obj.data[key])
343
+ if objs[0].ranges:
344
+ ranges = [float(x) for x in objs[0].ranges]
345
+ self.data["range"] = np.array(ranges) * 1000 # m => km
346
+ self.data["height"] = self.data["range"] + self.site_meta["altitude"]
166
347
  self.instrument = instruments.RADIOMETRICS
167
348
 
168
- def screen_time(self, expected_date: datetime.date | None):
349
+ def screen_time(self, expected_date: datetime.date | None) -> None:
169
350
  """Screens timestamps."""
170
351
  if expected_date is None:
171
352
  self.date = self.data["time"][0].astype(object).date()
@@ -175,24 +356,68 @@ class RadiometricsCombined:
175
356
  if np.count_nonzero(valid_mask) == 0:
176
357
  raise ValidTimeStampError
177
358
  for key in self.data:
359
+ if key in ("range", "height"):
360
+ continue
178
361
  self.data[key] = self.data[key][valid_mask]
179
362
 
180
- def time_to_fractional_hours(self):
363
+ def sort_timestamps(self) -> None:
364
+ ind = np.argsort(self.data["time"])
365
+ for key in self.data:
366
+ if key in ("range", "height"):
367
+ continue
368
+ self.data[key] = self.data[key][ind]
369
+
370
+ def time_to_fractional_hours(self) -> None:
181
371
  base = self.data["time"][0].astype("datetime64[D]")
182
372
  self.data["time"] = (self.data["time"] - base) / np.timedelta64(1, "h")
183
373
 
184
- def data_to_cloudnet_arrays(self):
374
+ def data_to_cloudnet_arrays(self) -> None:
185
375
  """Converts arrays to CloudnetArrays."""
186
376
  for key, array in self.data.items():
187
- self.data[key] = CloudnetArray(array, key)
377
+ dimensions = (
378
+ ("time", "range")
379
+ if key in ("temperature", "relative_humidity", "absolute_humidity")
380
+ else None
381
+ )
382
+ array_masked = ma.masked_invalid(array) if np.isnan(array).any() else array
383
+ self.data[key] = CloudnetArray(array_masked, key, dimensions=dimensions)
188
384
 
189
- def add_meta(self):
385
+ def add_meta(self) -> None:
190
386
  """Adds some metadata."""
191
387
  valid_keys = ("latitude", "longitude", "altitude")
192
388
  for key, value in self.site_meta.items():
193
- key = key.lower()
194
- if key in valid_keys:
195
- self.data[key] = CloudnetArray(float(value), key)
389
+ name = key.lower()
390
+ if name in valid_keys:
391
+ self.data[name] = CloudnetArray(float(value), key)
392
+
393
+
394
+ def _read_file(filename: Path) -> RadiometricsMP | RadiometricsWVR:
395
+ with open(filename) as f:
396
+ first_line = f.readline()
397
+ obj = (
398
+ RadiometricsWVR(filename)
399
+ if "RETRIEVAL COEFFICIENTS" in first_line
400
+ else RadiometricsMP(filename)
401
+ )
402
+ obj.read_raw_data()
403
+ obj.read_data()
404
+ return obj
405
+
406
+
407
+ def _parse_value(text: str) -> float:
408
+ return math.nan if "*" in text else float(text)
409
+
410
+
411
+ def _parse_date(text: str) -> datetime.date:
412
+ month, day, year = map(int, text.split("/"))
413
+ if year < 100:
414
+ year += 2000
415
+ return datetime.date(year, month, day)
416
+
417
+
418
+ def _parse_time(text: str) -> datetime.time:
419
+ hour, minute, second = map(int, text.split(":"))
420
+ return datetime.time(hour, minute, second)
196
421
 
197
422
 
198
423
  def _parse_datetime(text: str) -> datetime.datetime:
@@ -201,4 +426,33 @@ def _parse_datetime(text: str) -> datetime.datetime:
201
426
  hour, minute, second = map(int, time.split(":"))
202
427
  if year < 100:
203
428
  year += 2000
204
- return datetime.datetime(year, month, day, hour, minute, second)
429
+ return datetime.datetime(
430
+ year,
431
+ month,
432
+ day,
433
+ hour,
434
+ minute,
435
+ second,
436
+ )
437
+
438
+
439
+ def _find_closest(x: npt.NDArray, y: npt.NDArray, x_new: npt.NDArray) -> npt.NDArray:
440
+ return y[np.argmin(np.abs(x_new - x[:, np.newaxis]), axis=0)]
441
+
442
+
443
+ ATTRIBUTES = {
444
+ "irt": MetaData(
445
+ long_name="Infrared brightness temperatures",
446
+ units="K",
447
+ dimensions=("time", "ir_channel"),
448
+ ),
449
+ "temperature": MetaData(
450
+ long_name="Temperature", units="K", dimensions=("time", "range")
451
+ ),
452
+ "relative_humidity": MetaData(
453
+ long_name="Relative humidity",
454
+ standard_name="relative_humidity",
455
+ units="1",
456
+ dimensions=("time", "range"),
457
+ ),
458
+ }
@@ -0,0 +1,171 @@
1
+ import csv
2
+ import datetime
3
+ from os import PathLike
4
+ from uuid import UUID
5
+
6
+ import numpy as np
7
+
8
+ from cloudnetpy import output
9
+ from cloudnetpy.exceptions import ValidTimeStampError
10
+ from cloudnetpy.instruments import instruments
11
+ from cloudnetpy.instruments.cloudnet_instrument import CSVFile
12
+ from cloudnetpy.utils import get_uuid
13
+
14
+
15
+ def rain_e_h32nc(
16
+ input_file: str | PathLike,
17
+ output_file: str | PathLike,
18
+ site_meta: dict,
19
+ uuid: str | UUID | None = None,
20
+ date: str | datetime.date | None = None,
21
+ ) -> UUID:
22
+ """Converts rain_e_h3 rain-gauge into Cloudnet Level 1b netCDF file.
23
+
24
+ Args:
25
+ input_file: Filename of rain_e_h3 CSV file.
26
+ output_file: Output filename.
27
+ site_meta: Dictionary containing information about the site. Required key
28
+ is `name`.
29
+ uuid: Set specific UUID for the file.
30
+ date: Expected date of the measurements as YYYY-MM-DD or datetime.date object.
31
+
32
+ Returns:
33
+ UUID of the generated file.
34
+
35
+ Raises:
36
+ ValidTimeStampError: No valid timestamps found.
37
+ """
38
+ rain = RainEH3(site_meta)
39
+ if isinstance(date, str):
40
+ date = datetime.date.fromisoformat(date)
41
+ uuid = get_uuid(uuid)
42
+ rain.parse_input_file(input_file, date)
43
+ rain.add_data()
44
+ rain.add_date()
45
+ rain.convert_units()
46
+ rain.normalize_cumulative_amount("rainfall_amount")
47
+ rain.add_site_geolocation()
48
+ rain.sort_timestamps()
49
+ rain.remove_duplicate_timestamps()
50
+ attributes = output.add_time_attribute({}, rain.date)
51
+ output.update_attributes(rain.data, attributes)
52
+ output.save_level1b(rain, output_file, uuid)
53
+ return uuid
54
+
55
+
56
+ class RainEH3(CSVFile):
57
+ def __init__(self, site_meta: dict) -> None:
58
+ super().__init__(site_meta)
59
+ self.instrument = instruments.RAIN_E_H3
60
+ self._data = {
61
+ "time": [],
62
+ "rainfall_rate": [],
63
+ "rainfall_amount": [],
64
+ }
65
+
66
+ def parse_input_file(
67
+ self, filepath: str | PathLike, date: datetime.date | None = None
68
+ ) -> None:
69
+ with open(filepath, encoding="latin1") as f:
70
+ data = list(csv.reader(f, delimiter=";"))
71
+ n_values = np.median([len(row) for row in data]).astype(int)
72
+
73
+ if n_values == 22:
74
+ self._read_talker_protocol_22_columns(data, date)
75
+ elif n_values == 16:
76
+ self._read_talker_protocol_16_columns(data, date)
77
+ else:
78
+ msg = "Only talker protocol with 16 or 22 columns is supported."
79
+ raise NotImplementedError(msg)
80
+
81
+ def _read_talker_protocol_16_columns(
82
+ self, data: list, date: datetime.date | None = None
83
+ ) -> None:
84
+ """Old Lindenberg data format.
85
+
86
+ 0 date DD.MM.YYYY
87
+ 1 time
88
+ 2 precipitation intensity in mm/h
89
+ 3 precipitation accumulation in mm
90
+ 4 housing contact
91
+ 5 top temperature
92
+ 6 bottom temperature
93
+ 7 heater status
94
+ 8 error code
95
+ 9 system status
96
+ 10 talker interval in seconds
97
+ 11 operating hours
98
+ 12 device type
99
+ 13 user data storage 1
100
+ 14 user data storage 2
101
+ 15 user data storage 3
102
+
103
+ """
104
+ for row in data:
105
+ if len(row) != 16:
106
+ continue
107
+ try:
108
+ dt = datetime.datetime.strptime(
109
+ f"{row[0]} {row[1]}", "%d.%m.%Y %H:%M:%S"
110
+ )
111
+ except ValueError:
112
+ continue
113
+ if date and date != dt.date():
114
+ continue
115
+ self._data["time"].append(dt)
116
+ self._data["rainfall_rate"].append(float(row[2]))
117
+ self._data["rainfall_amount"].append(float(row[3]))
118
+ if not self._data["time"]:
119
+ raise ValidTimeStampError
120
+
121
+ def _read_talker_protocol_22_columns(
122
+ self, data: list, date: datetime.date | None = None
123
+ ) -> None:
124
+ """Columns according to header in Lindenberg data.
125
+
126
+ 0 datetime utc
127
+ 1 date
128
+ 2 time
129
+ 3 precipitation intensity in mm/h
130
+ 4 precipitation accumulation in mm
131
+ 5 housing contact
132
+ 6 top temperature
133
+ 7 bottom temperature
134
+ 8 heater status
135
+ 9 error code
136
+ 10 system status
137
+ 11 talker interval in seconds
138
+ 12 operating hours
139
+ 13 device type
140
+ 14 user data storage 1
141
+ 15 user data storage 2
142
+ 16 user data storage 3
143
+ 17 user data storage 4
144
+ 18 serial number
145
+ 19 hardware version
146
+ 20 firmware version
147
+ 21 external temperature * checksum
148
+
149
+ """
150
+ for row in data:
151
+ if len(row) != 22:
152
+ continue
153
+ try:
154
+ dt = datetime.datetime.strptime(f"{row[0]}", "%Y-%m-%d %H:%M:%S")
155
+ except ValueError:
156
+ continue
157
+ if date and date != dt.date():
158
+ continue
159
+ self._data["time"].append(dt)
160
+ self._data["rainfall_rate"].append(float(row[3]))
161
+ self._data["rainfall_amount"].append(float(row[4]))
162
+ self.serial_number = row[18]
163
+ if not self._data["time"]:
164
+ raise ValidTimeStampError
165
+
166
+ def convert_units(self) -> None:
167
+ rainfall_rate = self.data["rainfall_rate"][:]
168
+ self.data["rainfall_rate"].data = rainfall_rate / 3600 / 1000 # mm/h -> m/s
169
+ self.data["rainfall_amount"].data = (
170
+ self.data["rainfall_amount"][:] / 1000
171
+ ) # mm -> m