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/datasource.py CHANGED
@@ -1,8 +1,8 @@
1
1
  """Datasource module, containing the :class:`DataSource class.`"""
2
2
  import logging
3
3
  import os
4
- from datetime import datetime
5
- from typing import Callable
4
+ from collections.abc import Callable
5
+ from datetime import datetime, timezone
6
6
 
7
7
  import netCDF4
8
8
  import numpy as np
@@ -16,10 +16,12 @@ class DataSource:
16
16
  """Base class for all Cloudnet measurements and model data.
17
17
 
18
18
  Args:
19
+ ----
19
20
  full_path: Calibrated instrument / model NetCDF file.
20
21
  radar: Indicates if data is from cloud radar. Default is False.
21
22
 
22
23
  Attributes:
24
+ ----------
23
25
  filename (str): Filename of the input file.
24
26
  dataset (netCDF4.Dataset): A netCDF4 Dataset instance.
25
27
  source (str): Global attribute `source` read from the input file.
@@ -44,9 +46,9 @@ class DataSource:
44
46
  radar_frequency: float
45
47
  data_dense: dict
46
48
  data_sparse: dict
47
- type: str
49
+ source_type: str
48
50
 
49
- def __init__(self, full_path: os.PathLike | str, radar: bool = False):
51
+ def __init__(self, full_path: os.PathLike | str, *, radar: bool = False):
50
52
  self.filename = os.path.basename(full_path)
51
53
  self.dataset = netCDF4.Dataset(full_path)
52
54
  self.source = getattr(self.dataset, "source", "")
@@ -63,30 +65,35 @@ class DataSource:
63
65
  variables dictionary, fetched from the input netCDF file.
64
66
 
65
67
  Args:
68
+ ----
66
69
  *args: possible names of the variable. The first match is returned.
67
70
 
68
71
  Returns:
72
+ -------
69
73
  ndarray: The actual data.
70
74
 
71
75
  Raises:
76
+ ------
72
77
  RuntimeError: The variable is not found.
73
78
 
74
79
  """
75
80
  for arg in args:
76
81
  if arg in self.dataset.variables:
77
82
  return self.dataset.variables[arg][:]
78
- raise RuntimeError("Missing variable in the input file.")
83
+ msg = f"Missing variable {args[0]} in the input file."
84
+ raise RuntimeError(msg)
79
85
 
80
86
  def append_data(
81
87
  self,
82
- variable: netCDF4.Variable | np.ndarray | float | int,
88
+ variable: netCDF4.Variable | np.ndarray | float,
83
89
  key: str,
84
90
  name: str | None = None,
85
91
  units: str | None = None,
86
- ):
92
+ ) -> None:
87
93
  """Adds new CloudnetVariable or RadarVariable into `data` attribute.
88
94
 
89
95
  Args:
96
+ ----
90
97
  variable: netCDF variable or data array to be added.
91
98
  key: Key used with *variable* when added to `data`
92
99
  attribute (dictionary).
@@ -99,10 +106,12 @@ class DataSource:
99
106
  def get_date(self) -> list:
100
107
  """Returns date components.
101
108
 
102
- Returns:
109
+ Returns
110
+ -------
103
111
  list: Date components [YYYY, MM, DD].
104
112
 
105
- Raises:
113
+ Raises
114
+ ------
106
115
  RuntimeError: Not found or invalid date.
107
116
 
108
117
  """
@@ -110,11 +119,13 @@ class DataSource:
110
119
  year = str(self.dataset.year)
111
120
  month = str(self.dataset.month).zfill(2)
112
121
  day = str(self.dataset.day).zfill(2)
113
- datetime.strptime(f"{year}{month}{day}", "%Y%m%d")
122
+ datetime.strptime(f"{year}{month}{day}", "%Y%m%d").replace(
123
+ tzinfo=timezone.utc,
124
+ )
125
+
114
126
  except (AttributeError, ValueError) as read_error:
115
- raise RuntimeError(
116
- "Missing or invalid date in global attributes."
117
- ) from read_error
127
+ msg = "Missing or invalid date in global attributes."
128
+ raise RuntimeError(msg) from read_error
118
129
  return [year, month, day]
119
130
 
120
131
  def close(self) -> None:
@@ -128,7 +139,8 @@ class DataSource:
128
139
  if var.units == "km":
129
140
  alt *= 1000
130
141
  elif var.units not in ("m", "meters"):
131
- raise ValueError(f"Unexpected unit: {var.units}")
142
+ msg = f"Unexpected unit: {var.units}"
143
+ raise ValueError(msg)
132
144
  return alt
133
145
 
134
146
  @staticmethod
@@ -138,13 +150,15 @@ class DataSource:
138
150
  if var.units == "m":
139
151
  alt /= 1000
140
152
  elif var.units != "km":
141
- raise ValueError(f"Unexpected unit: {var.units}")
153
+ msg = f"Unexpected unit: {var.units}"
154
+ raise ValueError(msg)
142
155
  return alt
143
156
 
144
157
  def _init_time(self) -> np.ndarray:
145
158
  time = self.getvar("time")
146
159
  if len(time) == 0:
147
- raise ValidTimeStampError("Empty time vector")
160
+ msg = "Empty time vector"
161
+ raise ValidTimeStampError(msg)
148
162
  if max(time) > 25:
149
163
  logging.debug("Assuming time as seconds, converting to fraction hour")
150
164
  time = utils.seconds2hours(time)
@@ -160,7 +174,7 @@ class DataSource:
160
174
  return float(
161
175
  altitude_above_sea
162
176
  if utils.isscalar(altitude_above_sea)
163
- else np.mean(altitude_above_sea)
177
+ else np.mean(altitude_above_sea),
164
178
  )
165
179
  return None
166
180
 
@@ -177,11 +191,13 @@ class DataSource:
177
191
  """Transforms netCDF4-variables into CloudnetArrays.
178
192
 
179
193
  Args:
194
+ ----
180
195
  keys: netCDF4-variables to be converted. The results
181
196
  are saved in *self.data* dictionary with *fields*
182
197
  strings as keys.
183
198
 
184
199
  Notes:
200
+ -----
185
201
  The attributes of the variables are not copied. Just the data.
186
202
 
187
203
  """
@@ -193,11 +209,13 @@ class DataSource:
193
209
  possible_names: tuple,
194
210
  key: str,
195
211
  units: str | None = None,
212
+ *,
196
213
  ignore_mask: bool = False,
197
- ):
214
+ ) -> None:
198
215
  """Transforms single netCDF4 variable into CloudnetArray.
199
216
 
200
217
  Args:
218
+ ----
201
219
  possible_names: Tuple of strings containing the possible
202
220
  names of the variable in the input NetCDF file.
203
221
  key: Key for self.data dictionary and name-attribute
@@ -206,6 +224,7 @@ class DataSource:
206
224
  ignore_mask: If true, always writes an ordinary numpy array.
207
225
 
208
226
  Raises:
227
+ ------
209
228
  RuntimeError: No variable found.
210
229
 
211
230
  """
@@ -216,7 +235,8 @@ class DataSource:
216
235
  array = np.array(array)
217
236
  self.append_data(array, key, units=units)
218
237
  return
219
- raise RuntimeError("Missing variable in the input file.")
238
+ msg = f"Missing variable {possible_names[0]} in the input file."
239
+ raise RuntimeError(msg)
220
240
 
221
241
  def __enter__(self):
222
242
  return self
@@ -22,6 +22,7 @@ def basta2nc(
22
22
  steps.
23
23
 
24
24
  Args:
25
+ ----
25
26
  basta_file: Filename of a daily BASTA .nc file.
26
27
  output_file: Output filename.
27
28
  site_meta: Dictionary containing information about the site. Required key
@@ -30,12 +31,15 @@ def basta2nc(
30
31
  date: Expected date of the measurements as YYYY-MM-DD.
31
32
 
32
33
  Returns:
34
+ -------
33
35
  UUID of the generated file.
34
36
 
35
37
  Raises:
38
+ ------
36
39
  ValueError: Timestamps do not match the expected date.
37
40
 
38
41
  Examples:
42
+ --------
39
43
  >>> from cloudnetpy.instruments import basta2nc
40
44
  >>> site_meta = {'name': 'Palaiseau', 'latitude': 48.718, 'longitude': 2.207}
41
45
  >>> basta2nc('basta_file.nc', 'radar.nc', site_meta)
@@ -63,14 +67,14 @@ def basta2nc(
63
67
  basta.remove_duplicate_timestamps()
64
68
  attributes = output.add_time_attribute(ATTRIBUTES, basta.date)
65
69
  output.update_attributes(basta.data, attributes)
66
- uuid = output.save_level1b(basta, output_file, uuid)
67
- return uuid
70
+ return output.save_level1b(basta, output_file, uuid)
68
71
 
69
72
 
70
73
  class Basta(NcRadar):
71
74
  """Class for BASTA raw radar data. Child of NcRadar().
72
75
 
73
76
  Args:
77
+ ----
74
78
  full_path: BASTA netCDF filename.
75
79
  site_meta: Site properties in a dictionary. Required key is `name`.
76
80
 
@@ -1,6 +1,6 @@
1
1
  import binascii
2
2
  import re
3
- from datetime import datetime
3
+ from datetime import datetime, timezone
4
4
  from typing import NamedTuple
5
5
 
6
6
  import numpy as np
@@ -13,7 +13,10 @@ from cloudnetpy.instruments.ceilometer import Ceilometer
13
13
 
14
14
  class Cs135(Ceilometer):
15
15
  def __init__(
16
- self, full_path: str, site_meta: dict, expected_date: str | None = None
16
+ self,
17
+ full_path: str,
18
+ site_meta: dict,
19
+ expected_date: str | None = None,
17
20
  ):
18
21
  super().__init__()
19
22
  self.full_path = full_path
@@ -33,7 +36,10 @@ class Cs135(Ceilometer):
33
36
 
34
37
  parts = re.split(rb"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{6}),", content)
35
38
  for i in range(1, len(parts), 2):
36
- timestamp = datetime.strptime(parts[i].decode(), "%Y-%m-%dT%H:%M:%S.%f")
39
+ timestamp = datetime.strptime(
40
+ parts[i].decode(),
41
+ "%Y-%m-%dT%H:%M:%S.%f",
42
+ ).replace(tzinfo=timezone.utc)
37
43
  try:
38
44
  self._check_timestamp(timestamp)
39
45
  except ValidTimeStampError:
@@ -49,13 +55,16 @@ class Cs135(Ceilometer):
49
55
  range_resolutions.append(message.range_resolution)
50
56
 
51
57
  if len(timestamps) == 0:
52
- raise ValidTimeStampError("No valid timestamps found in the file")
58
+ msg = "No valid timestamps found in the file"
59
+ raise ValidTimeStampError(msg)
53
60
  range_resolution = range_resolutions[0]
54
61
  n_gates = len(profiles[0])
55
62
  if any(res != range_resolution for res in range_resolutions):
56
- raise InconsistentDataError("Inconsistent range resolution")
63
+ msg = "Inconsistent range resolution"
64
+ raise InconsistentDataError(msg)
57
65
  if any(len(profile) != n_gates for profile in profiles):
58
- raise InconsistentDataError("Inconsistent number of gates")
66
+ msg = "Inconsistent number of gates"
67
+ raise InconsistentDataError(msg)
59
68
 
60
69
  self.data["beta_raw"] = np.array(profiles)
61
70
  if calibration_factor is None:
@@ -68,14 +77,18 @@ class Cs135(Ceilometer):
68
77
  self.data["time"] = utils.datetime2decimal_hours(timestamps)
69
78
  self.data["zenith_angle"] = np.median(tilt_angles)
70
79
 
71
- def _check_timestamp(self, timestamp: datetime):
80
+ def _check_timestamp(self, timestamp: datetime) -> None:
72
81
  timestamp_components = str(timestamp.date()).split("-")
73
- if self.expected_date is not None:
74
- if timestamp_components != self.expected_date.split("-"):
75
- raise ValidTimeStampError
82
+ if (
83
+ self.expected_date is not None
84
+ and timestamp_components != self.expected_date.split("-")
85
+ ):
86
+ raise ValidTimeStampError
76
87
  if not self.date:
77
88
  self.date = timestamp_components
78
- assert timestamp_components == self.date
89
+ if timestamp_components != self.date:
90
+ msg = "Inconsistent dates in the file"
91
+ raise RuntimeError(msg)
79
92
 
80
93
 
81
94
  class Message(NamedTuple):
@@ -100,18 +113,22 @@ def _read_message(message: bytes) -> Message:
100
113
  expected_checksum = int(message[end_idx + 1 : end_idx + 5], 16)
101
114
  actual_checksum = _crc16(content)
102
115
  if expected_checksum != actual_checksum:
103
- raise InvalidMessageError(
116
+ msg = (
104
117
  "Invalid checksum: "
105
118
  f"expected {expected_checksum:04x}, "
106
119
  f"got {actual_checksum:04x}"
107
120
  )
121
+ raise InvalidMessageError(msg)
108
122
  lines = message.splitlines()
109
123
  if len(lines[0]) != 11:
110
- raise NotImplementedError("Unknown message format")
124
+ msg = f"Expected 11 characters in first line, got {len(lines[0])}"
125
+ raise NotImplementedError(msg)
111
126
  if (msg_no := lines[0][-4:-1]) != b"002":
112
- raise NotImplementedError(f"Message number {msg_no.decode()} not implemented")
127
+ msg = f"Message number {msg_no.decode()} not implemented"
128
+ raise NotImplementedError(msg)
113
129
  if len(lines) != 5:
114
- raise InvalidMessageError("Invalid line count")
130
+ msg = f"Expected 5 lines, got {len(lines)}"
131
+ raise InvalidMessageError(msg)
115
132
  scale, res, n, energy, lt, ti, bl, pulse, rate, _sum = map(int, lines[2].split())
116
133
  data = _read_backscatter(lines[3].strip(), n)
117
134
  return Message(scale, res, energy, lt, ti, bl, pulse, rate, data)
@@ -124,7 +141,7 @@ def _read_backscatter(data: bytes, n_gates: int) -> np.ndarray:
124
141
  limit = (1 << (n_bits - 1)) - 1
125
142
  offset = 1 << n_bits
126
143
  out = np.array(
127
- [int(data[i : i + n_chars], 16) for i in range(0, n_gates * n_chars, n_chars)]
144
+ [int(data[i : i + n_chars], 16) for i in range(0, n_gates * n_chars, n_chars)],
128
145
  )
129
146
  out[out > limit] -= offset
130
147
  return out
@@ -40,6 +40,7 @@ def ceilo2nc(
40
40
  of weak aerosol layers and supercooled liquid clouds.
41
41
 
42
42
  Args:
43
+ ----
43
44
  full_path: Ceilometer file name.
44
45
  output_file: Output file name, e.g. 'ceilo.nc'.
45
46
  site_meta: Dictionary containing information about the site and instrument.
@@ -53,12 +54,15 @@ def ceilo2nc(
53
54
  date: Expected date as YYYY-MM-DD of all profiles in the file.
54
55
 
55
56
  Returns:
57
+ -------
56
58
  UUID of the generated file.
57
59
 
58
60
  Raises:
61
+ ------
59
62
  RuntimeError: Failed to read or process raw ceilometer data.
60
63
 
61
64
  Examples:
65
+ --------
62
66
  >>> from cloudnetpy.instruments import ceilo2nc
63
67
  >>> site_meta = {'name': 'Mace-Head', 'altitude': 5}
64
68
  >>> ceilo2nc('vaisala_raw.txt', 'vaisala.nc', site_meta)
@@ -74,12 +78,18 @@ def ceilo2nc(
74
78
  ceilo_obj.read_ceilometer_file(calibration_factor)
75
79
  ceilo_obj.check_beta_raw_shape()
76
80
  ceilo_obj.data["beta"] = ceilo_obj.calc_screened_product(
77
- ceilo_obj.data["beta_raw"], snr_limit, range_corrected
81
+ ceilo_obj.data["beta_raw"],
82
+ snr_limit,
83
+ range_corrected=range_corrected,
78
84
  )
79
85
  ceilo_obj.data["beta_smooth"] = ceilo_obj.calc_beta_smooth(
80
- ceilo_obj.data["beta"], snr_limit, range_corrected
86
+ ceilo_obj.data["beta"],
87
+ snr_limit,
88
+ range_corrected=range_corrected,
81
89
  )
82
- assert ceilo_obj.instrument is not None and ceilo_obj.instrument.model is not None
90
+ if ceilo_obj.instrument is None or ceilo_obj.instrument.model is None:
91
+ msg = "Failed to read ceilometer model"
92
+ raise RuntimeError(msg)
83
93
  if "cl61" in ceilo_obj.instrument.model.lower():
84
94
  # This kind of screening could be used with other ceilometers as well:
85
95
  mask = ceilo_obj.data["beta_smooth"].mask
@@ -94,12 +104,13 @@ def ceilo2nc(
94
104
  output.update_attributes(ceilo_obj.data, attributes)
95
105
  for key in ("beta", "beta_smooth"):
96
106
  ceilo_obj.add_snr_info(key, snr_limit)
97
- uuid = output.save_level1b(ceilo_obj, output_file, uuid)
98
- return uuid
107
+ return output.save_level1b(ceilo_obj, output_file, uuid)
99
108
 
100
109
 
101
110
  def _initialize_ceilo(
102
- full_path: str, site_meta: dict, date: str | None = None
111
+ full_path: str,
112
+ site_meta: dict,
113
+ date: str | None = None,
103
114
  ) -> ClCeilo | Ct25k | LufftCeilo | Cl61d | Cs135:
104
115
  if "model" in site_meta:
105
116
  if site_meta["model"] not in (
@@ -110,7 +121,8 @@ def _initialize_ceilo(
110
121
  "chm15k",
111
122
  "cs135",
112
123
  ):
113
- raise ValueError(f"Invalid ceilometer model: {site_meta['model']}")
124
+ msg = f"Invalid ceilometer model: {site_meta['model']}"
125
+ raise ValueError(msg)
114
126
  if site_meta["model"] in ("cl31", "cl51"):
115
127
  model = "cl31_or_cl51"
116
128
  else:
@@ -129,21 +141,26 @@ def _initialize_ceilo(
129
141
 
130
142
 
131
143
  def _find_ceilo_model(full_path: str) -> str:
144
+ model = None
132
145
  try:
133
146
  with netCDF4.Dataset(full_path) as nc:
134
147
  title = nc.title
135
148
  for identifier in ["cl61d", "cl61-d"]:
136
149
  if identifier in title.lower() or identifier in full_path.lower():
137
- return "cl61d"
138
- return "chm15k"
150
+ model = "cl61d"
151
+ if model is None:
152
+ model = "chm15k"
139
153
  except OSError:
140
154
  with open(full_path, "rb") as file:
141
155
  for line in islice(file, 100):
142
156
  if line.startswith(b"\x01CL"):
143
- return "cl31_or_cl51"
144
- if line.startswith(b"\x01CT"):
145
- return "ct25k"
146
- raise RuntimeError("Error: Unknown ceilo model.")
157
+ model = "cl31_or_cl51"
158
+ elif line.startswith(b"\x01CT"):
159
+ model = "ct25k"
160
+ if model is None:
161
+ msg = "Unable to determine ceilometer model"
162
+ raise RuntimeError(msg)
163
+ return model
147
164
 
148
165
 
149
166
  ATTRIBUTES = {