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,7 +1,11 @@
1
1
  """Module with a class for Lufft chm15k ceilometer."""
2
+
3
+ import datetime
2
4
  import logging
5
+ from os import PathLike
3
6
 
4
7
  import netCDF4
8
+ import numpy as np
5
9
  from numpy import ma
6
10
 
7
11
  from cloudnetpy import utils
@@ -12,16 +16,17 @@ from cloudnetpy.instruments.nc_lidar import NcLidar
12
16
  class LufftCeilo(NcLidar):
13
17
  """Class for Lufft chm15k ceilometer."""
14
18
 
15
- serial_number: str | None
16
-
17
19
  def __init__(
18
- self, file_name: str, site_meta: dict, expected_date: str | None = None
19
- ):
20
+ self,
21
+ file_name: str | PathLike,
22
+ site_meta: dict,
23
+ expected_date: datetime.date | None = None,
24
+ ) -> None:
20
25
  super().__init__()
21
26
  self.file_name = file_name
22
27
  self.site_meta = site_meta
23
28
  self.expected_date = expected_date
24
- self.serial_number = None
29
+ self.is_old_version = False
25
30
 
26
31
  def read_ceilometer_file(self, calibration_factor: float | None = None) -> None:
27
32
  """Reads data and metadata from Jenoptik netCDF file."""
@@ -34,32 +39,46 @@ class LufftCeilo(NcLidar):
34
39
  self._fetch_zenith_angle("zenith")
35
40
 
36
41
  def _fetch_beta_raw(self, calibration_factor: float | None = None) -> None:
37
- assert self.dataset is not None
38
42
  if calibration_factor is None:
39
43
  logging.warning("Using default calibration factor")
40
44
  calibration_factor = 3e-12
41
45
  beta_raw = self._getvar("beta_raw", "beta_att")
46
+ beta_raw = ma.masked_array(beta_raw)
42
47
  old_version = self._get_old_software_version()
43
48
  if old_version is not None:
49
+ self.is_old_version = True
44
50
  logging.warning(
45
- f"Software version {old_version}. Assuming data not range corrected."
51
+ "Software version %s. Assuming data not range corrected.",
52
+ old_version,
46
53
  )
47
54
  data_std = self._getvar("stddev")
48
55
  normalised_apd = self._get_nn()
49
- beta_raw *= utils.transpose(data_std / normalised_apd)
56
+ beta_raw *= utils.transpose(ma.masked_array(data_std / normalised_apd))
50
57
  beta_raw *= self.data["range"] ** 2
51
58
  beta_raw *= calibration_factor
52
59
  self.data["calibration_factor"] = float(calibration_factor)
53
60
  self.data["beta_raw"] = beta_raw
54
61
 
55
62
  def _get_old_software_version(self) -> str | None:
56
- assert self.dataset is not None
63
+ if self.dataset is None:
64
+ msg = "No dataset found"
65
+ raise RuntimeError(msg)
57
66
  version = self.dataset.software_version
58
- if len(str(version)) > 4:
67
+ # In old files, the version is a single integer.
68
+ if isinstance(version, np.integer):
69
+ return str(version)
70
+ # In newer files, the version is a space-separated list: Operating
71
+ # system, FPGA, firmware, CloudDetectionMode (added in firmware 0.747).
72
+ if isinstance(version, str):
73
+ parts = version.split()
74
+ firmware = parts[2]
75
+ if firmware < "0.702":
76
+ return firmware
59
77
  return None
60
- return version
78
+ msg = f"Cannot determine version: {version}"
79
+ raise RuntimeError(msg)
61
80
 
62
- def _get_nn(self):
81
+ def _get_nn(self) -> float | ma.MaskedArray:
63
82
  nn1 = self._getvar("nn1", "NN1")
64
83
  median_nn1 = ma.median(nn1)
65
84
  # Parameters taken from the matlab code and should be verified
@@ -72,18 +91,21 @@ class LufftCeilo(NcLidar):
72
91
  return 1
73
92
  return step_factor ** (-(nn1 - reference) / scale)
74
93
 
75
- def _getvar(self, *args):
76
- assert self.dataset is not None
94
+ def _getvar(self, *args: str) -> float | ma.MaskedArray:
95
+ if self.dataset is None:
96
+ msg = "No dataset found"
97
+ raise RuntimeError(msg)
77
98
  for arg in args:
78
99
  if arg in self.dataset.variables:
79
100
  var = self.dataset.variables[arg]
80
101
  return var[0] if utils.isscalar(var) else var[:]
81
- raise ValueError("Unknown variable")
102
+ msg = f"Unable to find variable {args[0]}"
103
+ raise ValueError(msg)
82
104
 
83
- def _fetch_attributes(self):
105
+ def _fetch_attributes(self) -> None:
84
106
  self.serial_number = getattr(self.dataset, "device_name", None)
85
107
  if self.serial_number is None:
86
- self.serial_number = getattr(self.dataset, "source")
108
+ self.serial_number = getattr(self.dataset, "source", "")
87
109
  self.instrument = (
88
110
  instruments.CHM15KX
89
111
  if self.serial_number.startswith("CHX")
@@ -1,21 +1,31 @@
1
1
  """Module for reading raw cloud radar data."""
2
+
3
+ import datetime
4
+ import logging
2
5
  import os
3
- from tempfile import TemporaryDirectory
6
+ import re
7
+ from collections import OrderedDict
8
+ from collections.abc import Sequence
9
+ from os import PathLike
10
+ from tempfile import NamedTemporaryFile, TemporaryDirectory
11
+ from uuid import UUID
12
+
13
+ import numpy as np
14
+ from numpy import ma
4
15
 
5
16
  from cloudnetpy import concat_lib, output, utils
6
- from cloudnetpy.exceptions import ValidTimeStampError
7
- from cloudnetpy.instruments.instruments import MIRA35
17
+ from cloudnetpy.instruments.instruments import MIRA10, MIRA35
8
18
  from cloudnetpy.instruments.nc_radar import NcRadar
9
19
  from cloudnetpy.metadata import MetaData
10
20
 
11
21
 
12
22
  def mira2nc(
13
- raw_mira: str | list[str],
14
- output_file: str,
23
+ raw_mira: str | PathLike | Sequence[str | PathLike],
24
+ output_file: str | PathLike,
15
25
  site_meta: dict,
16
- uuid: str | None = None,
17
- date: str | None = None,
18
- ) -> str:
26
+ uuid: str | UUID | None = None,
27
+ date: str | datetime.date | None = None,
28
+ ) -> UUID:
19
29
  """Converts METEK MIRA-35 cloud radar data into Cloudnet Level 1b netCDF file.
20
30
 
21
31
  This function converts raw MIRA file(s) into a much smaller file that
@@ -23,8 +33,10 @@ def mira2nc(
23
33
  steps.
24
34
 
25
35
  Args:
26
- raw_mira: Filename of a daily MIRA .mmclx file. Can be also a folder containing
27
- several non-concatenated .mmclx files from one day or list of files.
36
+ raw_mira: Filename of a daily MIRA .mmclx or .zncfile. Can be also a folder
37
+ containing several non-concatenated .mmclx or .znc files from one day
38
+ or list of files. znc files take precedence because they are the newer
39
+ filetype
28
40
  output_file: Output filename.
29
41
  site_meta: Dictionary containing information about the site. Required key
30
42
  value pair is `name`.
@@ -36,66 +48,68 @@ def mira2nc(
36
48
 
37
49
  Raises:
38
50
  ValidTimeStampError: No valid timestamps found.
51
+ FileNotFoundError: No suitable input files found.
52
+ ValueError: Wrong suffix in input file(s).
53
+ TypeError: Mixed mmclx and znc files.
39
54
 
40
55
  Examples:
41
56
  >>> from cloudnetpy.instruments import mira2nc
42
57
  >>> site_meta = {'name': 'Vehmasmaki'}
43
58
  >>> mira2nc('raw_radar.mmclx', 'radar.nc', site_meta)
59
+ >>> mira2nc('raw_radar.znc', 'radar.nc', site_meta)
44
60
  >>> mira2nc('/one/day/of/mira/mmclx/files/', 'radar.nc', site_meta)
61
+ >>> mira2nc('/one/day/of/mira/znc/files/', 'radar.nc', site_meta)
45
62
 
46
63
  """
47
- keymap = {
48
- "Zg": "Zh",
49
- "VELg": "v",
50
- "RMSg": "width",
51
- "LDRg": "ldr",
52
- "SNRg": "SNR",
53
- "elv": "elevation",
54
- "azi": "azimuth_angle",
55
- "aziv": "azimuth_velocity",
56
- "nfft": "nfft",
57
- "nave": "nave",
58
- "prf": "prf",
59
- "rg0": "rg0",
60
- }
64
+ if isinstance(date, str):
65
+ date = datetime.date.fromisoformat(date)
66
+ uuid = utils.get_uuid(uuid)
61
67
 
62
68
  with TemporaryDirectory() as temp_dir:
63
- if isinstance(raw_mira, list) or os.path.isdir(raw_mira):
64
- mmclx_filename = f"{temp_dir}/tmp.mmclx"
65
- if isinstance(raw_mira, list):
66
- valid_filenames = sorted(raw_mira)
67
- else:
68
- valid_filenames = utils.get_sorted_filenames(raw_mira, ".mmclx")
69
- valid_filenames = utils.get_files_with_common_range(valid_filenames)
70
- variables = list(keymap.keys())
71
- concat_lib.concatenate_files(
72
- valid_filenames,
73
- mmclx_filename,
74
- variables=variables,
75
- allow_difference=["nave", "ovl"],
76
- )
77
- else:
78
- mmclx_filename = raw_mira
69
+ input_filename, keymap = _parse_input_files(raw_mira, temp_dir)
79
70
 
80
- with Mira(mmclx_filename, site_meta) as mira:
71
+ with Mira(input_filename, site_meta) as mira:
81
72
  mira.init_data(keymap)
82
73
  if date is not None:
83
74
  mira.screen_by_date(date)
84
- mira.date = date.split("-")
75
+ mira.date = date
85
76
  mira.sort_timestamps()
86
77
  mira.remove_duplicate_timestamps()
87
78
  mira.linear_to_db(("Zh", "ldr", "SNR"))
88
- mira.screen_by_snr()
79
+ mira.screen_low_power()
80
+
81
+ if "snr_limit" in site_meta and site_meta["snr_limit"] is not None:
82
+ snr_limit = site_meta["snr_limit"]
83
+ else:
84
+ # Empirical values, should be checked
85
+ snr_limit = -30 if mira.instrument == MIRA10 else -17
86
+
87
+ # Old MIRA files don't have angle variables.
88
+ if "elevation" not in mira.data:
89
+ mira.append_data(ma.masked_all_like(mira.time.data), "elevation")
90
+ if "azimuth_angle" not in mira.data:
91
+ mira.append_data(ma.masked_all_like(mira.time.data), "azimuth_angle")
92
+
93
+ mira.screen_by_snr(snr_limit)
94
+ mira.screen_invalid_ldr()
89
95
  mira.mask_invalid_data()
96
+ mira.mask_bad_angles()
90
97
  mira.add_time_and_range()
91
98
  mira.add_site_geolocation()
92
99
  mira.add_radar_specific_variables()
93
- valid_indices = mira.add_zenith_and_azimuth_angles()
100
+ valid_indices = mira.add_zenith_and_azimuth_angles(
101
+ elevation_threshold=1.1,
102
+ elevation_diff_threshold=1e-6,
103
+ azimuth_diff_threshold=1e-3,
104
+ zenith_offset=site_meta.get("zenith_offset"),
105
+ azimuth_offset=site_meta.get("azimuth_offset"),
106
+ )
94
107
  mira.screen_time_indices(valid_indices)
95
108
  mira.add_height()
109
+ mira.test_if_all_masked()
96
110
  attributes = output.add_time_attribute(ATTRIBUTES, mira.date)
97
111
  output.update_attributes(mira.data, attributes)
98
- uuid = output.save_level1b(mira, output_file, uuid)
112
+ output.save_level1b(mira, output_file, uuid)
99
113
  return uuid
100
114
 
101
115
 
@@ -108,42 +122,235 @@ class Mira(NcRadar):
108
122
 
109
123
  """
110
124
 
111
- epoch = (1970, 1, 1)
125
+ epoch = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
112
126
 
113
- def __init__(self, full_path: str, site_meta: dict):
127
+ def __init__(self, full_path: str | PathLike, site_meta: dict) -> None:
114
128
  super().__init__(full_path, site_meta)
115
129
  self.date = self._init_mira_date()
116
- self.instrument = MIRA35
130
+ if "model" not in site_meta or site_meta["model"] == "mira-35":
131
+ self.instrument = MIRA35
132
+ elif site_meta["model"] == "mira-10":
133
+ self.instrument = MIRA10
134
+ else:
135
+ msg = f"Invalid model: {site_meta['model']}"
136
+ raise ValueError(msg)
117
137
 
118
- def screen_by_date(self, expected_date: str) -> None:
138
+ def screen_by_date(self, expected_date: datetime.date) -> None:
119
139
  """Screens incorrect time stamps."""
120
140
  time_stamps = self.getvar("time")
121
141
  valid_indices = []
122
142
  for ind, timestamp in enumerate(time_stamps):
123
- date = "-".join(utils.seconds2date(timestamp, self.epoch)[:3])
143
+ if not timestamp:
144
+ continue
145
+ date = utils.seconds2date(timestamp, self.epoch).date()
124
146
  if date == expected_date:
125
147
  valid_indices.append(ind)
126
- if not valid_indices:
127
- raise ValidTimeStampError
128
148
  self.screen_time_indices(valid_indices)
129
149
 
130
- def _init_mira_date(self) -> list[str]:
150
+ def _init_mira_date(self) -> datetime.date:
131
151
  time_stamps = self.getvar("time")
132
- return utils.seconds2date(time_stamps[0], self.epoch)[:3]
152
+ return utils.seconds2date(float(time_stamps[0]), self.epoch).date()
153
+
154
+ def screen_low_power(self) -> None:
155
+ """Screen times with average transmit power close to zero."""
156
+ if "tpow" not in self.data:
157
+ logging.warning("Variable tpow is missing")
158
+ return
159
+ tpow = self.data["tpow"][:]
160
+ # Threshold for abnormally low power e.g. Limassol 2024-10-20. Average
161
+ # power should 30 to 60 W according to MIRA-35 data sheet. Based on a
162
+ # random sample, typical range is 15 to 25 W. In Lampedusa, the power is
163
+ # constantly as low as 1.9 W.
164
+ is_low = tpow < 1
165
+ n_removed = np.count_nonzero(is_low)
166
+ if n_removed > 0:
167
+ logging.warning(
168
+ "Filtering %s profiles due to low average transmit power", n_removed
169
+ )
170
+ self.screen_time_indices(~is_low)
171
+
172
+ def screen_invalid_ldr(self) -> None:
173
+ """Masks LDR in MIRA STSR mode data.
174
+ Is there a better way to identify this mode?
175
+ """
176
+ if "ldr" not in self.data:
177
+ return
178
+ ldr = self.data["ldr"][:]
179
+ if ma.mean(ldr) > 0:
180
+ logging.warning(
181
+ "LDR values suspiciously high. Mira in STSR mode? "
182
+ "Screening all LDR for now.",
183
+ )
184
+ self.data["ldr"].data[:] = ma.masked
185
+
186
+ def mask_bad_angles(self) -> None:
187
+ """Masks clearly bad elevation and azimuth angles."""
188
+ limits = {
189
+ "elevation": (0, 180),
190
+ "azimuth_angle": (-360, 360),
191
+ }
192
+ for key, (lower, upper) in limits.items():
193
+ if (array := self.data[key].data) is not None:
194
+ margin = (upper - lower) * 0.05
195
+ array[array < (lower - margin)] = ma.masked
196
+ array[array > (upper + margin)] = ma.masked
197
+
198
+
199
+ def _parse_input_files(
200
+ input_files: str | PathLike | Sequence[str | PathLike], temp_dir: str
201
+ ) -> tuple[str | PathLike, dict[str, str]]:
202
+ input_filename: str | PathLike
203
+ if (
204
+ not isinstance(input_files, str) and isinstance(input_files, Sequence)
205
+ ) or os.path.isdir(input_files):
206
+ with NamedTemporaryFile(
207
+ dir=temp_dir,
208
+ suffix=".nc",
209
+ delete=False,
210
+ ) as temp_file:
211
+ input_filename = temp_file.name
212
+ if not isinstance(input_files, str) and isinstance(input_files, Sequence):
213
+ valid_files = sorted(map(str, input_files))
214
+ else:
215
+ valid_files = utils.get_sorted_filenames(input_files, ".znc")
216
+ if not valid_files:
217
+ valid_files = utils.get_sorted_filenames(input_files, ".mmclx")
218
+
219
+ if not valid_files:
220
+ msg = (
221
+ (
222
+ f"Neither znc nor mmclx files found {input_files}. "
223
+ f"Please check your input."
224
+ ),
225
+ )
226
+ raise FileNotFoundError(msg)
227
+
228
+ filetypes = list({_get_suffix(f) for f in valid_files})
229
+
230
+ if len(filetypes) > 1:
231
+ err_msg = "Mixed mmclx and znc files. Please use only one filetype."
232
+ raise TypeError(err_msg)
233
+
234
+ keymap = _get_keymap(filetypes[0])
235
+
236
+ variables = list(keymap.keys())
237
+ concat_lib.concatenate_files(
238
+ valid_files,
239
+ input_filename,
240
+ variables=variables,
241
+ ignore=_get_ignored_variables(filetypes[0]),
242
+ )
243
+ else:
244
+ input_filename = input_files
245
+ keymap = _get_keymap(_get_suffix(input_filename))
246
+
247
+ return input_filename, keymap
248
+
249
+
250
+ def _get_ignored_variables(filetype: str) -> list | None:
251
+ """Returns variables to ignore for METEK MIRA-35 cloud radar concat."""
252
+ _check_file_type(filetype)
253
+ # Ignore spectral variables for now
254
+ keymaps = {
255
+ "znc": ["DropSize", "SPCco", "SPCcx", "SPCcocxRe", "SPCcocxIm", "doppler"],
256
+ "mmclx": None,
257
+ }
258
+
259
+ return keymaps.get(filetype.lower(), keymaps.get("mmclx"))
260
+
261
+
262
+ def _get_suffix(filename: str | PathLike) -> str:
263
+ m = re.search(r"\.(\w+)(\.\d+)?$", str(filename))
264
+ if m is None:
265
+ return ""
266
+ return m[1].lower()
267
+
268
+
269
+ def _get_keymap(filetype: str) -> dict[str, str]:
270
+ """Returns a dictionary mapping the variables in the raw data to the processed
271
+ Cloudnet file.
272
+ """
273
+ _check_file_type(filetype)
274
+
275
+ # Order is relevant with the new znc files from STSR radar
276
+ keymaps = {
277
+ "znc": OrderedDict(
278
+ [
279
+ ("Zg", "Zh"), # fallback
280
+ ("Zh2l", "Zh"),
281
+ ("VELg", "v"), # fallback
282
+ ("VELh2l", "v"),
283
+ ("RMSg", "width"), # fallback
284
+ ("RMSh2l", "width"),
285
+ ("LDRg", "ldr"), # fallback
286
+ ("LDRh2l", "ldr"),
287
+ ("SNRg", "SNR"), # fallback
288
+ ("SNRh2l", "SNR"),
289
+ ("elv", "elevation"),
290
+ ("azi", "azimuth_angle"),
291
+ ("nfft", "nfft"),
292
+ ("nave", "nave"),
293
+ ("prf", "prf"),
294
+ ("rg0", "rg0"),
295
+ ("tpow", "tpow"),
296
+ ],
297
+ ),
298
+ "mmclx": OrderedDict(
299
+ [
300
+ ("Ze", "Zh"), # fallback for old mmclx files
301
+ ("Zg", "Zh"),
302
+ ("VELg", "v"),
303
+ ("RMSg", "width"),
304
+ ("LDRg", "ldr"),
305
+ ("SNRg", "SNR"),
306
+ ("elv", "elevation"),
307
+ ("azi", "azimuth_angle"),
308
+ ("nfft", "nfft"),
309
+ ("nave", "nave"),
310
+ ("prf", "prf"),
311
+ ("rg0", "rg0"),
312
+ ("NyquistVelocity", "NyquistVelocity"), # variable in some mmclx files
313
+ ("tpow", "tpow"),
314
+ ]
315
+ ),
316
+ }
317
+
318
+ return keymaps.get(filetype.lower(), keymaps["mmclx"])
319
+
320
+
321
+ def _check_file_type(filetype: str) -> None:
322
+ known_filetypes = ["znc", "mmclx"]
323
+ if filetype.lower() not in known_filetypes:
324
+ msg = f"Filetype must be one of {known_filetypes}"
325
+ raise ValueError(msg)
133
326
 
134
327
 
135
328
  ATTRIBUTES = {
136
- "nfft": MetaData(
137
- long_name="Number of FFT points",
138
- units="1",
139
- ),
329
+ "nfft": MetaData(long_name="Number of FFT points", units="1", dimensions=("time",)),
140
330
  "nave": MetaData(
141
331
  long_name="Number of spectral averages (not accounting for overlapping FFTs)",
142
332
  units="1",
333
+ dimensions=("time",),
334
+ ),
335
+ "rg0": MetaData(
336
+ long_name="Number of lowest range gates", units="1", dimensions=("time",)
143
337
  ),
144
- "rg0": MetaData(long_name="Number of lowest range gates", units="1"),
145
338
  "prf": MetaData(
146
- long_name="Pulse Repetition Frequency",
147
- units="Hz",
339
+ long_name="Pulse Repetition Frequency", units="Hz", dimensions=("time",)
340
+ ),
341
+ "tpow": MetaData(
342
+ long_name="Average Transmit Power", units="W", dimensions=("time",)
343
+ ),
344
+ "zenith_offset": MetaData(
345
+ long_name="Zenith offset of the instrument",
346
+ units="degrees",
347
+ comment="Zenith offset applied.",
348
+ dimensions=None,
349
+ ),
350
+ "azimuth_offset": MetaData(
351
+ long_name="Azimuth offset of the instrument (positive clockwise from north)",
352
+ units="degrees",
353
+ comment="Azimuth offset applied.",
354
+ dimensions=None,
148
355
  ),
149
356
  }