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
cloudnetpy/cli.py ADDED
@@ -0,0 +1,578 @@
1
+ import argparse
2
+ import base64
3
+ import datetime
4
+ import gzip
5
+ import hashlib
6
+ import importlib
7
+ import logging
8
+ import re
9
+ import shutil
10
+ from os import PathLike
11
+ from pathlib import Path
12
+ from tempfile import TemporaryDirectory
13
+ from typing import TYPE_CHECKING, Final, Literal, cast
14
+
15
+ import requests
16
+ from cloudnet_api_client import APIClient, CloudnetAPIError
17
+ from cloudnet_api_client.containers import Instrument, ProductMetadata, RawMetadata
18
+
19
+ from cloudnetpy import concat_lib, instruments
20
+ from cloudnetpy.categorize import CategorizeInput, generate_categorize
21
+ from cloudnetpy.exceptions import PlottingError
22
+ from cloudnetpy.plotting import generate_figure
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Callable
26
+
27
+
28
+ cloudnet_api_url: Final = "https://cloudnet.fmi.fi/api/"
29
+
30
+
31
+ def run(args: argparse.Namespace, tmpdir: str, client: APIClient) -> None:
32
+ cat_files = {}
33
+
34
+ # Instrument based products
35
+ if source_instruments := _get_source_instruments(args.products, client):
36
+ for product, possible_instruments in source_instruments.items():
37
+ if not possible_instruments:
38
+ continue
39
+ meta = _fetch_raw_meta(possible_instruments, args, client)
40
+ instrument = _select_instrument(meta, product)
41
+ if not instrument:
42
+ logging.info("No instrument found for %s", product)
43
+ continue
44
+ meta = _filter_by_instrument(meta, instrument)
45
+ meta = _filter_by_suffix(meta, product)
46
+ if not meta:
47
+ logging.info("No suitable data available for %s", product)
48
+ continue
49
+ output_filepath = _process_instrument_product(
50
+ product, meta, instrument, tmpdir, args, client
51
+ )
52
+ _plot(output_filepath, product, args)
53
+ cat_files[product] = output_filepath
54
+
55
+ prod_sources = _get_product_sources(args.products, client)
56
+
57
+ # Categorize based products
58
+ if "categorize" in args.products:
59
+ cat_filepath = _process_categorize(cat_files, args, client)
60
+ _plot(cat_filepath, "categorize", args)
61
+ else:
62
+ cat_filepath = None
63
+ cat_products = [p for p in prod_sources if "categorize" in prod_sources[p]]
64
+ for product in cat_products:
65
+ if cat_filepath is None:
66
+ cat_filepath = _fetch_product(args, "categorize", client)
67
+ if cat_filepath is None:
68
+ logging.info("No categorize data available for {}")
69
+ break
70
+ l2_filename = _process_cat_product(product, cat_filepath)
71
+ _plot(l2_filename, product, args)
72
+
73
+ # MWR-L1c based products
74
+ mwrpy_products = [p for p in prod_sources if "mwr-l1c" in prod_sources[p]]
75
+ for product in mwrpy_products:
76
+ if "mwr-l1c" in cat_files:
77
+ mwrpy_filepath = cat_files.get("mwr-l1c")
78
+ else:
79
+ mwrpy_filepath = _fetch_product(args, "mwr-l1c", client)
80
+ if mwrpy_filepath is None:
81
+ logging.info("No MWR-L1c data available for %s", product)
82
+ break
83
+ l2_filename = _process_mwrpy_product(product, mwrpy_filepath, args)
84
+ _plot(l2_filename, product, args)
85
+
86
+
87
+ def _process_categorize(
88
+ input_files: dict, args: argparse.Namespace, client: APIClient
89
+ ) -> str | None:
90
+ cat_filepath = _create_categorize_filepath(args)
91
+
92
+ input_files["model"] = _fetch_model(args, client)
93
+ if input_files["model"] is None:
94
+ logging.info("No model data available for this date.")
95
+ return None
96
+
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
+ try:
107
+ logging.info("Processing categorize...")
108
+ generate_categorize(cast("CategorizeInput", input_files), cat_filepath)
109
+ logging.info("Processed categorize to %s", cat_filepath)
110
+ except NameError:
111
+ logging.info("No data available for this date.")
112
+ return None
113
+ return cat_filepath
114
+
115
+
116
+ def _fetch_mwr(args: argparse.Namespace, client: APIClient) -> str | None:
117
+ mwr_sources = [
118
+ ("mwr-single", None),
119
+ ("mwr", None),
120
+ ("radar", "rpg-fmcw-35"),
121
+ ("radar", "rpg-fmcw-94"),
122
+ ]
123
+ for product, source in mwr_sources:
124
+ mwr = _fetch_product(args, product, client, source=source)
125
+ if mwr:
126
+ return mwr
127
+ return None
128
+
129
+
130
+ def _process_instrument_product(
131
+ product: str,
132
+ meta: list[RawMetadata],
133
+ instrument: Instrument,
134
+ tmpdir: str,
135
+ args: argparse.Namespace,
136
+ client: APIClient,
137
+ ) -> str | None:
138
+ output_filepath = _create_instrument_filepath(instrument, args)
139
+ site_obj = meta[0].site
140
+ site_meta: dict = {
141
+ "name": site_obj.human_readable_name,
142
+ "latitude": site_obj.latitude,
143
+ "longitude": site_obj.longitude,
144
+ "altitude": site_obj.altitude,
145
+ }
146
+ input_files: list[Path] | Path
147
+ input_files = _fetch_raw(meta, args, client)
148
+ input_files = [_unzip_gz_file(f) for f in input_files]
149
+ if args.dl:
150
+ return None
151
+ input_folder = input_files[0].parent
152
+ calibration = _get_calibration(instrument, args, client)
153
+ fun: Callable
154
+ match (product, instrument.instrument_id):
155
+ case ("radar", _id) if "mira" in _id:
156
+ fun = instruments.mira2nc
157
+ case ("radar", _id) if "rpg" in _id:
158
+ fun = instruments.rpg2nc
159
+ input_files = input_folder
160
+ case ("radar", _id) if "basta" in _id:
161
+ fun = instruments.basta2nc
162
+ _check_input(input_files)
163
+ input_files = input_files[0]
164
+ case ("radar", _id) if "copernicus" in _id:
165
+ fun = instruments.copernicus2nc
166
+ case ("radar", _id) if "galileo" in _id:
167
+ fun = instruments.galileo2nc
168
+ case ("disdrometer", _id) if "parsivel" in _id:
169
+ fun = instruments.parsivel2nc
170
+ case ("disdrometer", _id) if "thies" in _id:
171
+ fun = instruments.thies2nc
172
+ input_files = _concatenate_(input_files, tmpdir)
173
+ case ("lidar", _id) if "pollyxt" in _id:
174
+ site_meta["snr_limit"] = calibration.get("snr_limit", 25)
175
+ fun = instruments.pollyxt2nc
176
+ case ("lidar", _id) if _id == "cl61d":
177
+ fun = instruments.ceilo2nc
178
+ variables = ["x_pol", "p_pol", "beta_att", "time", "tilt_angle"]
179
+ concat_file = Path(tmpdir) / "tmp.nc"
180
+ concat_lib.bundle_netcdf_files(
181
+ input_files,
182
+ datetime.date.fromisoformat(args.date),
183
+ concat_file,
184
+ variables=variables,
185
+ )
186
+ input_files = concat_file
187
+ site_meta["model"] = instrument.instrument_id
188
+ case ("lidar", _id):
189
+ fun = instruments.ceilo2nc
190
+ input_files = _concatenate_(input_files, tmpdir)
191
+ site_meta["model"] = instrument.instrument_id
192
+ if factor := calibration.get("calibration_factor"):
193
+ site_meta["calibration_factor"] = factor
194
+ case ("mwr", _id):
195
+ fun = instruments.hatpro2nc
196
+ input_files = input_folder
197
+ case ("mwr-l1c", _id):
198
+ fun = instruments.hatpro2l1c
199
+ site_meta = {**site_meta, **calibration}
200
+ coefficients, links = _fetch_coefficient_files(calibration, tmpdir)
201
+ site_meta["coefficientFiles"] = coefficients
202
+ site_meta["coefficientLinks"] = links
203
+ input_files = input_folder
204
+ case ("mrr", _id):
205
+ fun = instruments.mrr2nc
206
+ case ("weather-station", _id):
207
+ fun = instruments.ws2nc
208
+ logging.info("Processing %s...", product)
209
+ fun(input_files, output_filepath, site_meta, date=args.date)
210
+ logging.info("Processed %s: %s", product, output_filepath)
211
+ return output_filepath
212
+
213
+
214
+ def _concatenate_(input_files: list[Path], tmpdir: str) -> Path:
215
+ if len(input_files) > 1:
216
+ concat_file = Path(tmpdir) / "tmp.nc"
217
+ try:
218
+ concat_lib.concatenate_files(input_files, concat_file)
219
+ except OSError:
220
+ concat_lib.concatenate_text_files(input_files, concat_file)
221
+ return concat_file
222
+ return input_files[0]
223
+
224
+
225
+ def _fetch_coefficient_files(
226
+ calibration: dict, tmpdir: str
227
+ ) -> tuple[list[str], list[str]]:
228
+ msg = "No calibration coefficients found"
229
+ if not (coeffs := calibration.get("retrieval_coefficients")):
230
+ raise ValueError(msg)
231
+ if not (links := coeffs[0].get("links")):
232
+ raise ValueError(msg)
233
+ coefficient_paths = []
234
+ for filename in links:
235
+ res = requests.get(filename, timeout=60)
236
+ res.raise_for_status()
237
+ filepath = Path(tmpdir) / Path(filename).name
238
+ filepath.write_bytes(res.content)
239
+ coefficient_paths.append(str(filepath))
240
+ return coefficient_paths, links
241
+
242
+
243
+ def _get_calibration(
244
+ instrument: Instrument, args: argparse.Namespace, client: APIClient
245
+ ) -> dict:
246
+ try:
247
+ calibration = client.calibration(
248
+ date=args.date,
249
+ instrument_pid=instrument.pid,
250
+ )
251
+ return calibration.get("data", {})
252
+ except CloudnetAPIError:
253
+ return {}
254
+
255
+
256
+ def _create_instrument_filepath(
257
+ instrument: Instrument, args: argparse.Namespace
258
+ ) -> str:
259
+ folder = _create_output_folder("instrument", args)
260
+ pid = _shorten_pid(instrument.pid)
261
+ filename = (
262
+ f"{args.date.replace('-', '')}_{args.site}_{instrument.instrument_id}_{pid}.nc"
263
+ )
264
+ return str(folder / filename)
265
+
266
+
267
+ def _create_categorize_filepath(args: argparse.Namespace) -> str:
268
+ folder = _create_output_folder("geophysical", args)
269
+ filename = f"{args.date.replace('-', '')}_{args.site}_categorize.nc"
270
+ return str(folder / filename)
271
+
272
+
273
+ def _create_input_folder(end_point: str, args: argparse.Namespace) -> Path:
274
+ folder = args.input / args.site / args.date / end_point
275
+ folder.mkdir(parents=True, exist_ok=True)
276
+ return folder
277
+
278
+
279
+ def _create_output_folder(end_point: str, args: argparse.Namespace) -> Path:
280
+ folder = args.output / args.site / args.date / end_point
281
+ folder.mkdir(parents=True, exist_ok=True)
282
+ return folder
283
+
284
+
285
+ def _fetch_raw_meta(
286
+ instruments: list[str], args: argparse.Namespace, client: APIClient
287
+ ) -> list[RawMetadata]:
288
+ return client.raw_files(
289
+ site_id=args.site,
290
+ date=args.date,
291
+ instrument_id=instruments,
292
+ status=["uploaded", "processed"],
293
+ )
294
+
295
+
296
+ def _filter_by_instrument(
297
+ meta: list[RawMetadata], instrument: Instrument
298
+ ) -> list[RawMetadata]:
299
+ return [m for m in meta if m.instrument.pid == instrument.pid]
300
+
301
+
302
+ def _filter_by_suffix(meta: list[RawMetadata], product: str) -> list[RawMetadata]:
303
+ if product == "radar":
304
+ meta = [m for m in meta if not m.filename.lower().endswith(".lv0")]
305
+ elif product == "mwr":
306
+ meta = [m for m in meta if re.search(r"\.(lwp|iwv)", m.filename, re.IGNORECASE)]
307
+ elif product == "mwr-l1c":
308
+ meta = [m for m in meta if not m.filename.lower().endswith(".nc")]
309
+ return meta
310
+
311
+
312
+ def _get_source_instruments(
313
+ products: list[str], client: APIClient
314
+ ) -> dict[str, list[str]]:
315
+ source_instruments = {}
316
+ for product in products:
317
+ prod, model = _parse_instrument(product)
318
+ all_possible = client.product(prod).source_instrument_ids
319
+ if all_possible and (match := [i for i in all_possible if i == model]):
320
+ source_instruments[prod] = match
321
+ else:
322
+ source_instruments[prod] = list(all_possible)
323
+ return source_instruments
324
+
325
+
326
+ def _get_product_sources(
327
+ products: list[str], client: APIClient
328
+ ) -> dict[str, list[str]]:
329
+ source_products = {}
330
+ for product in products:
331
+ prod, _ = _parse_instrument(product)
332
+ product_obj = client.product(prod)
333
+ if product_obj.source_product_ids:
334
+ source_products[prod] = list(product_obj.source_product_ids)
335
+ return source_products
336
+
337
+
338
+ def _parse_instrument(s: str) -> tuple[str, str | None]:
339
+ if "[" in s and s.endswith("]"):
340
+ name = s[: s.index("[")]
341
+ value = s[s.index("[") + 1 : -1]
342
+ else:
343
+ name = s
344
+ value = None
345
+ return name, value
346
+
347
+
348
+ def _select_instrument(meta: list[RawMetadata], product: str) -> Instrument | None:
349
+ instruments = _get_unique_instruments(meta)
350
+ if len(instruments) == 0:
351
+ logging.info("No instruments found")
352
+ return None
353
+ if len(instruments) > 1:
354
+ logging.info("Multiple instruments found for %s", product)
355
+ logging.info("Please specify which one to use")
356
+ for i, instrument in enumerate(instruments):
357
+ logging.info("%d: %s", i + 1, instrument.name)
358
+ ind = int(input("Select: ")) - 1
359
+ selected_instrument = instruments[ind]
360
+ else:
361
+ selected_instrument = instruments[0]
362
+ logging.info("Single instrument found: %s", selected_instrument.name)
363
+ return selected_instrument
364
+
365
+
366
+ def _get_unique_instruments(meta: list[RawMetadata]) -> list[Instrument]:
367
+ unique_instruments = list({m.instrument for m in meta})
368
+ return sorted(unique_instruments, key=lambda x: x.name)
369
+
370
+
371
+ def _fetch_product(
372
+ args: argparse.Namespace, product: str, client: APIClient, source: str | None = None
373
+ ) -> str | None:
374
+ meta = client.files(product_id=product, date=args.date, site_id=args.site)
375
+ if source:
376
+ meta = [
377
+ m
378
+ for m in meta
379
+ if m.instrument is not None and m.instrument.instrument_id == source
380
+ ]
381
+ if not meta:
382
+ logging.info("No data available for %s", product)
383
+ return None
384
+ if len(meta) > 1:
385
+ logging.info(
386
+ "Multiple files for %s ... taking the first but some logic needed", product
387
+ )
388
+ meta = [meta[0]]
389
+ suffix = "geophysical" if "geophysical" in meta[0].product.type else "instrument"
390
+ folder = _create_output_folder(suffix, args)
391
+ return _download_product_file(meta, folder, client)
392
+
393
+
394
+ def _fetch_model(args: argparse.Namespace, client: APIClient) -> str | None:
395
+ files = client.files(product_id="model", date=args.date, site_id=args.site)
396
+ if not files:
397
+ logging.info("No model data available for this date")
398
+ return None
399
+ folder = _create_output_folder("instrument", args)
400
+ return _download_product_file(files, folder, client)
401
+
402
+
403
+ def _fetch_raw(
404
+ metadata: list[RawMetadata], args: argparse.Namespace, client: APIClient
405
+ ) -> list[Path]:
406
+ pid = _shorten_pid(metadata[0].instrument.pid)
407
+ instrument = f"{metadata[0].instrument.instrument_id}_{pid}"
408
+ folder = _create_input_folder(instrument, args)
409
+ return client.download(metadata, output_directory=folder)
410
+
411
+
412
+ def _download_product_file(
413
+ meta: list[ProductMetadata], folder: Path, client: APIClient
414
+ ) -> str:
415
+ if len(meta) > 1:
416
+ msg = "Multiple product files found"
417
+ raise ValueError(msg)
418
+ filepath = folder / meta[0].filename
419
+ if filepath.exists():
420
+ logging.info("Existing file found: %s", filepath)
421
+ return str(filepath)
422
+ logging.info("Downloading file: %s", filepath)
423
+ return str(client.download(meta, output_directory=folder)[0])
424
+
425
+
426
+ def _shorten_pid(pid: str) -> str:
427
+ return pid.split(".")[-1][:8]
428
+
429
+
430
+ def _check_input(files: list) -> None:
431
+ if len(files) > 1:
432
+ msg = "Multiple input files found"
433
+ raise ValueError(msg)
434
+
435
+
436
+ def _plot(
437
+ filepath: PathLike | str | None, product: str, args: argparse.Namespace
438
+ ) -> None:
439
+ if filepath is None or (not args.plot and not args.show):
440
+ 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]
445
+ image_name = str(filepath).replace(".nc", ".png") if args.plot else None
446
+ try:
447
+ generate_figure(
448
+ filepath,
449
+ variables,
450
+ show=args.show,
451
+ output_filename=image_name,
452
+ )
453
+ except PlottingError as e:
454
+ logging.info("Failed to plot %s: %s", product, e)
455
+ if args.plot:
456
+ logging.info("Plotted %s: %s", product, image_name)
457
+
458
+
459
+ def _process_cat_product(product: str, categorize_file: str) -> str:
460
+ output_file = categorize_file.replace("categorize", product)
461
+ module = importlib.import_module("cloudnetpy.products")
462
+ getattr(module, f"generate_{product}")(categorize_file, output_file)
463
+ logging.info("Processed %s: %s", product, output_file)
464
+ return output_file
465
+
466
+
467
+ def _process_mwrpy_product(
468
+ product: str, mwr_l1c_file: str, args: argparse.Namespace
469
+ ) -> str:
470
+ filename = f"{args.date}_{args.site}_{product}.nc"
471
+ output_file = _create_output_folder("geophysical", args) / filename
472
+ module = importlib.import_module("cloudnetpy.products")
473
+ getattr(module, f"generate_{product.replace('-', '_')}")(mwr_l1c_file, output_file)
474
+ logging.info("Processed %s: %s", product, output_file)
475
+ return str(output_file)
476
+
477
+
478
+ def _parse_products(product_argument: str, client: APIClient) -> list[str]:
479
+ products = product_argument.split(",")
480
+ valid_options = [p.id for p in client.products()]
481
+ valid_products = []
482
+ for product in products:
483
+ prod, _ = _parse_instrument(product)
484
+ if prod in valid_options:
485
+ valid_products.append(product)
486
+ return valid_products
487
+
488
+
489
+ def main() -> None:
490
+ client = APIClient()
491
+ parser = argparse.ArgumentParser(
492
+ description="Command line interface for running CloudnetPy."
493
+ )
494
+ parser.add_argument(
495
+ "-s",
496
+ "--site",
497
+ type=str,
498
+ help="Site",
499
+ required=True,
500
+ choices=[site.id for site in client.sites()],
501
+ metavar="SITE",
502
+ )
503
+ parser.add_argument(
504
+ "-d", "--date", type=str, help="Date in YYYY-MM-DD", required=True
505
+ )
506
+ parser.add_argument(
507
+ "-p",
508
+ "--products",
509
+ type=lambda arg: _parse_products(arg, client),
510
+ help=(
511
+ "Products to process, e.g. 'radar' or 'classification'. If the site "
512
+ "has many instruments, you can specify the instrument in brackets, "
513
+ "e.g. radar[mira-35]."
514
+ ),
515
+ required=True,
516
+ )
517
+ parser.add_argument("--input", type=Path, help="Input path", default="input/")
518
+ parser.add_argument("--output", type=Path, help="Output path", default="output/")
519
+ parser.add_argument(
520
+ "--plot",
521
+ help="Plot the processed data",
522
+ default=False,
523
+ action=argparse.BooleanOptionalAction,
524
+ )
525
+ parser.add_argument(
526
+ "--show",
527
+ help="Show plotted image",
528
+ default=False,
529
+ action=argparse.BooleanOptionalAction,
530
+ )
531
+ parser.add_argument(
532
+ "--dl",
533
+ help="Download raw data only",
534
+ default=False,
535
+ action=argparse.BooleanOptionalAction,
536
+ )
537
+ args = parser.parse_args()
538
+
539
+ logger = logging.getLogger()
540
+ logger.setLevel(logging.INFO)
541
+ handler = logging.StreamHandler()
542
+ formatter = logging.Formatter("%(levelname)s: %(message)s")
543
+ handler.setFormatter(formatter)
544
+ logger.handlers = [handler]
545
+
546
+ with TemporaryDirectory() as tmpdir:
547
+ run(args, tmpdir, client)
548
+
549
+
550
+ def md5sum(filename: str | PathLike, *, is_base64: bool = False) -> str:
551
+ """Calculates hash of file using md5."""
552
+ return _calc_hash_sum(filename, "md5", is_base64=is_base64)
553
+
554
+
555
+ def _calc_hash_sum(
556
+ filename: str | PathLike, method: Literal["sha256", "md5"], *, is_base64: bool
557
+ ) -> str:
558
+ hash_sum = getattr(hashlib, method)()
559
+ with open(filename, "rb") as f:
560
+ for byte_block in iter(lambda: f.read(4096), b""):
561
+ hash_sum.update(byte_block)
562
+ if is_base64:
563
+ return base64.encodebytes(hash_sum.digest()).decode("utf-8").strip()
564
+ return hash_sum.hexdigest()
565
+
566
+
567
+ def _unzip_gz_file(path_in: Path) -> Path:
568
+ if path_in.suffix != ".gz":
569
+ return path_in
570
+ path_out = path_in.with_suffix("")
571
+ logging.debug("Decompressing %s to %s", path_in, path_out)
572
+ with gzip.open(path_in, "rb") as file_in, open(path_out, "wb") as file_out:
573
+ shutil.copyfileobj(file_in, file_out)
574
+ return path_out
575
+
576
+
577
+ if __name__ == "__main__":
578
+ main()