oceanarray 0.1.0__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 (76) hide show
  1. oceanarray/__init__.py +20 -0
  2. oceanarray/_version.py +1 -0
  3. oceanarray/cli.py +1343 -0
  4. oceanarray/clock_offset.py +675 -0
  5. oceanarray/config/OS1_sensor_attrs.yaml +49 -0
  6. oceanarray/config/OS1_var_names.yaml +5 -0
  7. oceanarray/config/OS1_vocab_attrs.yaml +84 -0
  8. oceanarray/config/legacy/project_RAPID.yaml +69 -0
  9. oceanarray/config/legacy/rodb_keys.txt +206 -0
  10. oceanarray/config/legacy/rodb_keys.yaml +1012 -0
  11. oceanarray/config/logging.yaml +28 -0
  12. oceanarray/legacy/__init__.py +47 -0
  13. oceanarray/legacy/caldip.py +753 -0
  14. oceanarray/legacy/convertOS.py +527 -0
  15. oceanarray/legacy/find_deployment.py +489 -0
  16. oceanarray/legacy/mooring_rodb.py +429 -0
  17. oceanarray/legacy/process_rodb.py +369 -0
  18. oceanarray/legacy/rodb.py +330 -0
  19. oceanarray/logger.py +192 -0
  20. oceanarray/logsheet/__init__.py +60 -0
  21. oceanarray/logsheet/_columns.py +97 -0
  22. oceanarray/logsheet/_config.py +245 -0
  23. oceanarray/logsheet/_firmware.py +43 -0
  24. oceanarray/logsheet/_latex.py +128 -0
  25. oceanarray/logsheet/_render.py +126 -0
  26. oceanarray/logsheet/builders/__init__.py +13 -0
  27. oceanarray/logsheet/builders/caldip_download.py +195 -0
  28. oceanarray/logsheet/builders/caldip_setup.py +188 -0
  29. oceanarray/logsheet/builders/deployment_setup.py +218 -0
  30. oceanarray/logsheet/builders/mooring_download.py +243 -0
  31. oceanarray/logsheet/builders/recovery.py +157 -0
  32. oceanarray/logsheet/data/column_defs.yaml +686 -0
  33. oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
  34. oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
  35. oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
  36. oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
  37. oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
  38. oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
  39. oceanarray/mooring_level.py +1368 -0
  40. oceanarray/oceanarray.mplstyle +18 -0
  41. oceanarray/parameters.py +270 -0
  42. oceanarray/plotter.py +942 -0
  43. oceanarray/plotters/__init__.py +97 -0
  44. oceanarray/plotters/_animation.py +261 -0
  45. oceanarray/plotters/_current.py +705 -0
  46. oceanarray/plotters/_diagnostic.py +667 -0
  47. oceanarray/plotters/_helpers.py +57 -0
  48. oceanarray/plotters/_primitives.py +101 -0
  49. oceanarray/plotters/_section.py +16 -0
  50. oceanarray/plotters/_timeseries.py +11 -0
  51. oceanarray/rapid_interp.py +739 -0
  52. oceanarray/read_rbr_hex.py +541 -0
  53. oceanarray/readers.py +303 -0
  54. oceanarray/report/__init__.py +5 -0
  55. oceanarray/report/_array.py +386 -0
  56. oceanarray/report/_grid.py +539 -0
  57. oceanarray/report/_html_helpers.py +1004 -0
  58. oceanarray/report/_instrument.py +853 -0
  59. oceanarray/report/_mooring.py +1385 -0
  60. oceanarray/report/_plots.py +4284 -0
  61. oceanarray/report/_stack.py +941 -0
  62. oceanarray/stage1.py +1959 -0
  63. oceanarray/stage2.py +1160 -0
  64. oceanarray/stage3.py +2494 -0
  65. oceanarray/time_gridding.py +958 -0
  66. oceanarray/tools.py +400 -0
  67. oceanarray/transports.py +1 -0
  68. oceanarray/utilities.py +538 -0
  69. oceanarray/validation.py +389 -0
  70. oceanarray/writers.py +99 -0
  71. oceanarray-0.1.0.dist-info/METADATA +503 -0
  72. oceanarray-0.1.0.dist-info/RECORD +76 -0
  73. oceanarray-0.1.0.dist-info/WHEEL +5 -0
  74. oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
  75. oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
  76. oceanarray-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,389 @@
1
+ """Validation utilities for oceanarray mooring YAML configuration files.
2
+
3
+ The ``instrument`` field in each clamp/instruments entry is used as a
4
+ subdirectory name when reading raw files and writing processed output::
5
+
6
+ <basedir>/raw/<instrument>/<mooring_name>/<filename>
7
+ <basedir>/proc/<mooring_name>/<instrument>/<output>.nc
8
+
9
+ Valid instrument names and their typical file types
10
+ ----------------------------------------------------
11
+
12
+ +------------+------------------------------+------------------------+
13
+ | instrument | Description | Typical file_type |
14
+ +============+==============================+========================+
15
+ | microcat | SeaBird SBE37 CTD | sbe-cnv, sbe-ascii |
16
+ | sbe56 | SeaBird SBE56 temperature | sbe-cnv |
17
+ | sbe16 | SeaBird SBE16 CTD | sbe-cnv, sbe-hex |
18
+ | rbrsolo | RBR Solo temperature | rbr-rsk, rbr-dat |
19
+ | rbrduet | RBR Duet CT | rbr-rsk, rbr-dat |
20
+ | aquadopp | Nortek Aquadopp current meter| nortek-aqd, nortek-ascii, nortek-csv |
21
+ | adcp | Acoustic Doppler Current Prof| adcp-matlab |
22
+ | tr1050 | Turner TR-1050 (via RBR) | rbr-matlab |
23
+ +------------+------------------------------+------------------------+
24
+
25
+ Do NOT use hardware/model names as the instrument value:
26
+ - ``sbe37`` → use ``microcat``
27
+ - ``nortek`` → use ``aquadopp``
28
+
29
+ Clock correction YAML fields (per-instrument, applied in Stage 2)
30
+ -----------------------------------------------------------------
31
+
32
+ Stage 2 applies corrections in this order:
33
+
34
+ 1. **Constant offset** — applied uniformly across the entire record::
35
+
36
+ clock_offset: 15 # seconds; positive = instrument was slow (behind)
37
+
38
+ 2. **Linear drift** — grows linearly from 0 at deployment to the full drift
39
+ at recovery. Two equivalent ways to specify it:
40
+
41
+ **Option A** — direct::
42
+
43
+ clock_drift_seconds: 8 # positive = instrument was slow (behind at recovery)
44
+
45
+ **Option B** — two timestamps read off at recovery (preferred; no sign errors)::
46
+
47
+ clock_computer_at_recovery: '2026-07-11T10:23:30'
48
+ clock_instrument_at_recovery: '2026-07-11T10:23:22'
49
+ # drift = computer − instrument = +8 s (instrument was 8 s behind)
50
+
51
+ If both Option A and Option B are present, Option B takes priority.
52
+
53
+ 3. **Trimming** — data outside deployment_time … recovery_time is discarded
54
+ *after* all clock corrections, so corrections are applied to the full raw record.
55
+
56
+ Sign convention — both values are amounts **added** to instrument time:
57
+
58
+ +--------------------------------+------------------+--------------------+
59
+ | Situation | clock_offset | clock_drift_seconds|
60
+ +================================+==================+====================+
61
+ | Instrument clock was slow | positive (+) | positive (+) |
62
+ | (behind real time) | | |
63
+ +--------------------------------+------------------+--------------------+
64
+ | Instrument clock was fast | negative (-) | negative (-) |
65
+ | (ahead of real time) | | |
66
+ +--------------------------------+------------------+--------------------+
67
+
68
+ The original, uncorrected time is always saved as ``time_orig`` in the output
69
+ NetCDF alongside the corrected ``time`` coordinate. The ``history`` attribute
70
+ records what was applied and when.
71
+ """
72
+
73
+ import re
74
+ from pathlib import Path
75
+ from typing import Any, Dict, List, NamedTuple
76
+
77
+ import yaml
78
+
79
+
80
+ VALID_INSTRUMENTS: Dict[str, Dict[str, Any]] = {
81
+ "microcat": {
82
+ "description": "SeaBird SBE37 CTD",
83
+ "typical_file_types": ["sbe-cnv", "sbe-ascii"],
84
+ },
85
+ "sbe56": {
86
+ "description": "SeaBird SBE56 temperature logger",
87
+ "typical_file_types": ["sbe-cnv"],
88
+ },
89
+ "sbe16": {
90
+ "description": "SeaBird SBE16 CTD",
91
+ "typical_file_types": ["sbe-cnv", "sbe-hex"],
92
+ },
93
+ "rbrsolo": {
94
+ "description": "RBR Solo temperature logger",
95
+ "typical_file_types": ["rbr-rsk", "rbr-dat"],
96
+ },
97
+ "rbrduet": {
98
+ "description": "RBR Duet CT logger",
99
+ "typical_file_types": ["rbr-rsk", "rbr-dat"],
100
+ },
101
+ "aquadopp": {
102
+ "description": "Nortek Aquadopp current meter",
103
+ "typical_file_types": ["nortek-aqd", "nortek-ascii"],
104
+ },
105
+ "adcp": {
106
+ "description": "Acoustic Doppler Current Profiler (lowercase; use 'ADCP' in YAML)",
107
+ "typical_file_types": ["rdi-raw", "adcp-matlab"],
108
+ },
109
+ "ADCP": {
110
+ "description": "Acoustic Doppler Current Profiler (RDI or similar)",
111
+ "typical_file_types": ["rdi-raw", "adcp-matlab"],
112
+ },
113
+ "tr1050": {
114
+ "description": "Turner TR-1050 fluorometer (via RBR logger)",
115
+ "typical_file_types": ["rbr-matlab", "rbr-hex-oa"],
116
+ },
117
+ "seapoint": {
118
+ "description": "Seapoint turbidity sensor (via RBR logger)",
119
+ "typical_file_types": ["rbr-rsk", "rbr-dat"],
120
+ },
121
+ }
122
+
123
+ KNOWN_ALIASES: Dict[str, str] = {
124
+ "sbe37": "microcat",
125
+ "nortek": "aquadopp",
126
+ }
127
+
128
+ VALID_FILE_TYPES = {
129
+ "sbe-cnv",
130
+ "sbe-asc",
131
+ "sbe-ascii",
132
+ "sbe-hex",
133
+ "nortek-aqd",
134
+ "nortek-ascii",
135
+ "nortek-csv", # seasenselib reader (future)
136
+ "nortek-csv-oa", # DEPRECATED: internal oceanarray CSV reader
137
+ "rbr-rsk",
138
+ "rbr-dat",
139
+ "rbr-matlab",
140
+ "adcp-matlab",
141
+ "rdi-raw", # RDI ADCP raw binary; requires mhkit[dolfyn]
142
+ "rbr-hex-oa", # internal oceanarray hex reader for TR-1050; use rbr-hex once seasenselib supports it
143
+ }
144
+
145
+ UNSUPPORTED_FILE_TYPES: Dict[str, str] = {}
146
+
147
+ REQUIRED_MOORING_KEYS = ["name", "waterdepth", "deployment_time", "recovery_time"]
148
+
149
+
150
+ class ValidationIssue(NamedTuple):
151
+ """A single validation finding with a severity level and human-readable message."""
152
+
153
+ level: str # "ERROR" or "WARNING"
154
+ message: str
155
+
156
+
157
+ def validate_mooring_yaml(yaml_path: str) -> List[ValidationIssue]:
158
+ """Validate a mooring YAML configuration file.
159
+
160
+ Checks:
161
+ - Required top-level keys are present
162
+ - Each instrument entry uses a valid ``instrument`` name (not a model alias)
163
+ - Each instrument entry with a ``file_type`` uses a recognised value
164
+ - Instruments with ``filename`` also have ``file_type``
165
+ - Instruments without ``filename`` are flagged as warnings (not yet staged)
166
+
167
+ Returns a list of :class:`ValidationIssue` named-tuples. An empty list
168
+ means the file passed all checks.
169
+ """
170
+ issues: List[ValidationIssue] = []
171
+ path = Path(yaml_path)
172
+
173
+ if not path.exists():
174
+ return [ValidationIssue("ERROR", f"File not found: {yaml_path}")]
175
+
176
+ try:
177
+ with open(path) as f:
178
+ data = yaml.safe_load(f)
179
+ except yaml.YAMLError as e:
180
+ return [ValidationIssue("ERROR", f"YAML parse error: {e}")]
181
+
182
+ if not isinstance(data, dict):
183
+ return [ValidationIssue("ERROR", "YAML root must be a mapping")]
184
+
185
+ # Check required mooring-level keys
186
+ for key in REQUIRED_MOORING_KEYS:
187
+ if key not in data:
188
+ issues.append(ValidationIssue("ERROR", f"Missing required key: '{key}'"))
189
+
190
+ # Validate instrument list (supports both 'clamp' and legacy 'instruments').
191
+ # Also validate inline hardware entries that carry instrument data.
192
+ instrument_list = data.get("clamp", data.get("instruments", []))
193
+ inline_list = data.get("inline", [])
194
+ inline_instruments = [
195
+ e
196
+ for e in inline_list
197
+ if isinstance(e, dict)
198
+ and "instrument" in e
199
+ and (e.get("filename") or e.get("skip"))
200
+ ]
201
+ combined_list = list(instrument_list) + inline_instruments
202
+
203
+ if not combined_list:
204
+ issues.append(
205
+ ValidationIssue(
206
+ "WARNING",
207
+ "No instruments found under 'clamp', 'instruments', or 'inline'",
208
+ )
209
+ )
210
+ return issues
211
+
212
+ for i, entry in enumerate(combined_list):
213
+ if not isinstance(entry, dict):
214
+ continue
215
+
216
+ serial = entry.get("serial", f"index {i}")
217
+ prefix = f"[serial {serial}]"
218
+
219
+ instrument = entry.get("instrument")
220
+ file_type = entry.get("file_type")
221
+ filename = entry.get("filename")
222
+
223
+ serial_str = str(serial)
224
+ if re.search(r"[^\w\-]", serial_str):
225
+ issues.append(
226
+ ValidationIssue(
227
+ "WARNING",
228
+ f"{prefix} serial='{serial_str}' contains characters illegal in filenames "
229
+ f"(e.g. '*') — they will be stripped automatically when constructing output filenames",
230
+ )
231
+ )
232
+
233
+ # Fragile serial parsing for inline instruments with compound serials.
234
+ # extract_inline_instruments splits on the first comma and uses the first
235
+ # token as the instrument serial. Warn so operators can verify the ordering.
236
+ if entry.get("source") == "inline" or entry in inline_instruments:
237
+ if "," in serial_str:
238
+ parts = [p.strip() for p in serial_str.split(",")]
239
+ issues.append(
240
+ ValidationIssue(
241
+ "WARNING",
242
+ f"{prefix} inline serial='{serial_str}' contains a comma — "
243
+ f"the first token '{parts[0]}' will be used as the instrument serial "
244
+ f"and '{', '.join(parts[1:])}' stored as beacon_id. "
245
+ f"Confirm the instrument serial comes first.",
246
+ )
247
+ )
248
+
249
+ if instrument is None:
250
+ issues.append(
251
+ ValidationIssue("WARNING", f"{prefix} Missing 'instrument' field")
252
+ )
253
+ elif instrument in KNOWN_ALIASES:
254
+ correct = KNOWN_ALIASES[instrument]
255
+ issues.append(
256
+ ValidationIssue(
257
+ "ERROR",
258
+ f"{prefix} instrument='{instrument}' is deprecated — use '{correct}' instead",
259
+ )
260
+ )
261
+ elif instrument not in VALID_INSTRUMENTS:
262
+ issues.append(
263
+ ValidationIssue(
264
+ "WARNING",
265
+ f"{prefix} instrument='{instrument}' is not in the known list: "
266
+ f"{', '.join(sorted(VALID_INSTRUMENTS))}",
267
+ )
268
+ )
269
+
270
+ if (
271
+ file_type is not None
272
+ and file_type not in VALID_FILE_TYPES
273
+ and file_type != "TBD"
274
+ ):
275
+ if file_type in UNSUPPORTED_FILE_TYPES:
276
+ issues.append(
277
+ ValidationIssue(
278
+ "ERROR",
279
+ f"{prefix} file_type='{file_type}' is not supported: {UNSUPPORTED_FILE_TYPES[file_type]}",
280
+ )
281
+ )
282
+ else:
283
+ issues.append(
284
+ ValidationIssue(
285
+ "WARNING",
286
+ f"{prefix} file_type='{file_type}' is not recognised. "
287
+ f"Known types: {', '.join(sorted(VALID_FILE_TYPES))}",
288
+ )
289
+ )
290
+
291
+ if filename and not file_type:
292
+ issues.append(
293
+ ValidationIssue(
294
+ "ERROR",
295
+ f"{prefix} has 'filename' but no 'file_type'",
296
+ )
297
+ )
298
+
299
+ if not filename and not entry.get("skip"):
300
+ issues.append(
301
+ ValidationIssue(
302
+ "WARNING",
303
+ f"{prefix} instrument='{instrument}' has no 'filename' — not yet staged for processing",
304
+ )
305
+ )
306
+
307
+ # Clock correction field checks
308
+ has_comp = "computer_clock_at_recovery" in entry
309
+ has_inst = "instrument_clock_at_recovery" in entry
310
+ if has_comp != has_inst:
311
+ issues.append(
312
+ ValidationIssue(
313
+ "ERROR",
314
+ f"{prefix} computer_clock_at_recovery and instrument_clock_at_recovery "
315
+ f"must both be present or both absent",
316
+ )
317
+ )
318
+ elif has_comp and has_inst:
319
+ for ts_key in (
320
+ "computer_clock_at_recovery",
321
+ "instrument_clock_at_recovery",
322
+ ):
323
+ try:
324
+ import pandas as _pd
325
+
326
+ _pd.Timestamp(entry[ts_key])
327
+ except Exception: # noqa: BLE001 — report issue and continue; Timestamp parse errors vary
328
+ issues.append(
329
+ ValidationIssue(
330
+ "ERROR",
331
+ f"{prefix} {ts_key}='{entry[ts_key]}' is not a valid timestamp",
332
+ )
333
+ )
334
+ if "clock_drift_seconds" in entry:
335
+ try:
336
+ import pandas as _pd
337
+
338
+ comp_t = _pd.Timestamp(entry["computer_clock_at_recovery"])
339
+ inst_t = _pd.Timestamp(entry["instrument_clock_at_recovery"])
340
+ computed_drift = (comp_t - inst_t).total_seconds()
341
+ stated_drift = float(entry["clock_drift_seconds"])
342
+ if abs(computed_drift - stated_drift) > 1.0:
343
+ issues.append(
344
+ ValidationIssue(
345
+ "ERROR",
346
+ f"{prefix} clock_drift_seconds={stated_drift:.1f}s conflicts with "
347
+ f"timestamp pair (computed drift={computed_drift:+.1f}s); "
348
+ f"remove clock_drift_seconds or correct the timestamps",
349
+ )
350
+ )
351
+ else:
352
+ issues.append(
353
+ ValidationIssue(
354
+ "WARNING",
355
+ f"{prefix} both clock_drift_seconds and timestamp pair are present; "
356
+ f"timestamp pair will be used (they agree to <1 s)",
357
+ )
358
+ )
359
+ except Exception: # noqa: BLE001 — parse errors already reported above; skip silently
360
+ pass
361
+
362
+ return issues
363
+
364
+
365
+ def print_validation_report(yaml_path: str) -> bool:
366
+ """Print a human-readable validation report. Returns True if no errors."""
367
+ issues = validate_mooring_yaml(yaml_path)
368
+
369
+ errors = [i for i in issues if i.level == "ERROR"]
370
+ warnings = [i for i in issues if i.level == "WARNING"]
371
+
372
+ print(f"Validating: {yaml_path}")
373
+
374
+ if not issues:
375
+ print(" OK — no issues found")
376
+ return True
377
+
378
+ for issue in issues:
379
+ marker = " ERROR " if issue.level == "ERROR" else " warn "
380
+ print(f"{marker}{issue.message}")
381
+
382
+ summary = []
383
+ if errors:
384
+ summary.append(f"{len(errors)} error(s)")
385
+ if warnings:
386
+ summary.append(f"{len(warnings)} warning(s)")
387
+ print(f" {', '.join(summary)}")
388
+
389
+ return len(errors) == 0
oceanarray/writers.py ADDED
@@ -0,0 +1,99 @@
1
+ from numbers import Number
2
+ from pathlib import Path
3
+
4
+ import numpy as np
5
+ import xarray as xr
6
+
7
+
8
+ def save_dataset(ds: xr.Dataset, output_file: str = "../test.nc") -> bool:
9
+ """Attempts to save the dataset to a NetCDF file. If a TypeError occurs due to invalid attribute values,
10
+ it converts the invalid attributes to strings and retries the save operation.
11
+
12
+ Parameters
13
+ ----------
14
+ ds : xarray.Dataset
15
+ The dataset to be saved.
16
+ output_file : str, optional
17
+ The path to the output NetCDF file. Defaults to '../test.nc'.
18
+
19
+ Returns
20
+ -------
21
+ bool
22
+ True if the dataset was saved successfully, False otherwise.
23
+
24
+ Notes
25
+ -----
26
+ This function is based on a workaround for issues with saving datasets containing
27
+ attributes of unsupported types. See: https://github.com/pydata/xarray/issues/3743
28
+
29
+ """
30
+ # More general
31
+ valid_types = (str, Number, np.ndarray, np.number, list, tuple)
32
+ try:
33
+ ds.to_netcdf(output_file, format="NETCDF4_CLASSIC")
34
+ except TypeError as e:
35
+ print(e.__class__.__name__, e)
36
+ for varname, variable in ds.variables.items():
37
+ for k, v in variable.attrs.items():
38
+ if not isinstance(v, valid_types) or isinstance(v, bool):
39
+ print(
40
+ f"variable '{varname}': Converting attribute '{k}' with value '{v}' to string.",
41
+ )
42
+ variable.attrs[k] = str(v)
43
+ try:
44
+ ds.to_netcdf(output_file, format="NETCDF4_CLASSIC")
45
+ except Exception as e: # noqa: BLE001 — log failure details and report False; write errors vary
46
+ print("Failed to save dataset:", e)
47
+ datetime_vars = [
48
+ var for var in ds.variables if ds[var].dtype == "datetime64[ns]"
49
+ ]
50
+ print("Variables with dtype datetime64[ns]:", datetime_vars)
51
+ float_attrs = [
52
+ attr for attr in ds.attrs if isinstance(ds.attrs[attr], float)
53
+ ]
54
+ print("Attributes with dtype float64:", float_attrs)
55
+ return False
56
+ return True
57
+
58
+
59
+ def save_OS_instrument(ds: xr.Dataset, data_dir: Path):
60
+ """Save OceanSITES dataset to netCDF using the 'id' global attribute as filename.
61
+
62
+ Parameters
63
+ ----------
64
+ ds : xarray.Dataset
65
+ Dataset with OceanSITES-compliant global attributes including 'id'.
66
+ data_dir : pathlib.Path
67
+ Directory to save the netCDF file.
68
+
69
+ Returns
70
+ -------
71
+ Path
72
+ Full path to the saved NetCDF file.
73
+
74
+ """
75
+ if "id" not in ds.attrs:
76
+ raise ValueError( # noqa: TRY003
77
+ "Global attribute 'id' not found. Cannot determine output filename."
78
+ )
79
+
80
+ filename = f"{ds.attrs['id']}.nc"
81
+ filepath = data_dir / filename
82
+
83
+ # Use netCDF4 format and encoding for compression
84
+ encoding = {var: {"zlib": True, "complevel": 4} for var in ds.data_vars}
85
+
86
+ # Sanitize attributes: replace None with empty string
87
+ for k, v in ds.attrs.items():
88
+ if v is None:
89
+ ds.attrs[k] = ""
90
+
91
+ # Remove conflicting attributes that may clash with encoding
92
+ conflicting_keys = ["units", "calendar"]
93
+ for var in ds.coords:
94
+ for key in conflicting_keys:
95
+ ds[var].attrs.pop(key, None)
96
+
97
+ ds.to_netcdf(filepath, format="NETCDF4", encoding=encoding)
98
+
99
+ return filepath