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,538 @@
1
+ """Shared utility helpers used across the oceanarray processing pipeline."""
2
+
3
+ import math
4
+ from datetime import datetime
5
+ from functools import wraps
6
+ from typing import Any, Callable, Dict, List, Optional
7
+
8
+ import numpy as np
9
+ import xarray as xr
10
+
11
+ from oceanarray import logger
12
+
13
+
14
+ def extract_inline_instruments(
15
+ inline_list: List[Dict[str, Any]],
16
+ ) -> List[Dict[str, Any]]:
17
+ """Extract processable instrument entries from the mooring YAML ``inline`` list.
18
+
19
+ Most ``inline`` entries describe passive hardware (ropes, shackles, floats).
20
+ This function filters for entries that have an ``instrument`` field and either
21
+ a ``filename`` (to be processed) or ``skip: true`` (to be reported as skipped).
22
+
23
+ Normalisation applied to each matching entry:
24
+
25
+ - ``hab`` is set from ``hab_bottom`` if ``hab`` is not already present.
26
+ For a downward-looking instrument the transducer is at the bottom of the
27
+ housing (``hab_bottom``); for an upward-looking one use ``hab_top``.
28
+ - ``serial`` is split on the first comma and the first token is used as the
29
+ primary serial number. Any remaining tokens are joined and stored under
30
+ ``beacon_id`` in the returned dict.
31
+ - ``source: "inline"`` is added to distinguish these entries from ``clamp``
32
+ entries in downstream logging.
33
+
34
+ **Serial parsing is fragile**: the convention ``serial: 16430, R01-024``
35
+ assumes the *first* comma-separated token is the instrument serial and the
36
+ rest is a transponder/beacon ID. If a YAML author places the beacon serial
37
+ first, the wrong value will be used as the output filename stem. The
38
+ validator emits a WARNING for any inline entry whose serial contains a comma
39
+ so operators can confirm the ordering is correct.
40
+
41
+ Args:
42
+ inline_list: The raw list parsed from the ``inline`` YAML key.
43
+
44
+ Returns:
45
+ List of normalised instrument-config dicts ready for stage processing.
46
+
47
+ """
48
+ result: List[Dict[str, Any]] = []
49
+ for entry in inline_list:
50
+ if not isinstance(entry, dict):
51
+ continue
52
+ if "instrument" not in entry:
53
+ continue
54
+ if not entry.get("filename") and not entry.get("skip"):
55
+ continue
56
+
57
+ normalized: Dict[str, Any] = dict(entry)
58
+
59
+ if "hab" not in normalized and "hab_bottom" in normalized:
60
+ normalized["hab"] = normalized["hab_bottom"]
61
+
62
+ serial_raw = str(entry.get("serial", ""))
63
+ if "," in serial_raw:
64
+ parts = [p.strip() for p in serial_raw.split(",")]
65
+ normalized["serial"] = parts[0]
66
+ normalized["beacon_id"] = ", ".join(parts[1:])
67
+
68
+ normalized["source"] = "inline"
69
+ result.append(normalized)
70
+ return result
71
+
72
+
73
+ def concat_with_scalar_vars(
74
+ datasets: List[xr.Dataset],
75
+ dim: str,
76
+ scalar_vars: Optional[List[str]] = None,
77
+ ) -> xr.Dataset:
78
+ """Concatenate datasets along a dimension, preserving scalar variables.
79
+
80
+ Scalar (0-D) variables would normally be broadcast to the concatenation
81
+ dimension by ``xr.concat``; this helper strips them beforehand and
82
+ re-attaches the first occurrence after the concat so they remain 0-D.
83
+
84
+ Parameters
85
+ ----------
86
+ datasets : list of xarray.Dataset
87
+ Datasets to concatenate.
88
+ dim : str
89
+ Dimension along which to concatenate.
90
+ scalar_vars : list of str, optional
91
+ List of variable names to treat as scalars. If None, auto-detect
92
+ scalar variables (those with ndim == 0 in any dataset).
93
+
94
+ Returns
95
+ -------
96
+ xarray.Dataset
97
+ Concatenated dataset with scalar variables re-attached as 0-D DataArrays.
98
+
99
+ """
100
+ scalar_storage = {}
101
+
102
+ # Auto-detect scalar variables if not provided
103
+ if scalar_vars is None:
104
+ scalar_vars = set()
105
+ for ds in datasets:
106
+ scalar_vars.update([v for v in ds.data_vars if ds[v].ndim == 0])
107
+ scalar_vars = list(scalar_vars)
108
+
109
+ # Strip scalar variables and store them
110
+ cleaned = []
111
+ for _i, ds in enumerate(datasets):
112
+ ds_copy = ds.copy()
113
+ for var in scalar_vars:
114
+ if var in ds_copy and ds_copy[var].ndim == 0:
115
+ scalar_storage[(_i, var)] = ds_copy[var]
116
+ del ds_copy[var]
117
+ cleaned.append(ds_copy)
118
+
119
+ # Concatenate non-scalar parts
120
+ combined = xr.concat(cleaned, dim=dim)
121
+
122
+ # Re-attach scalar variables as 0-D DataArrays
123
+ for (_i, var), da in scalar_storage.items():
124
+ combined[var] = da # safe: 0-D, no coords
125
+
126
+ return combined
127
+
128
+
129
+ def check_necessary_variables(ds: xr.Dataset, vars: list) -> None:
130
+ """Check that all required variables are present in a dataset.
131
+
132
+ Parameters
133
+ ----------
134
+ ds: xarray.Dataset
135
+ Dataset that should be checked
136
+ vars: list
137
+ List of variables
138
+
139
+ Raises
140
+ ------
141
+ KeyError:
142
+ Raises an error if all vars not present in ds
143
+
144
+ Notes
145
+ -----
146
+ Original Author: Callum Rollo
147
+
148
+ """
149
+ missing_vars = set(vars).difference(set(ds.variables))
150
+ if missing_vars:
151
+ msg = f"Required variables {list(missing_vars)} do not exist in the supplied dataset."
152
+ raise KeyError(msg)
153
+
154
+
155
+ def get_time_key(ds: xr.Dataset) -> str:
156
+ """Return the name of the time coordinate or variable in an xarray.Dataset.
157
+
158
+ Parameters
159
+ ----------
160
+ ds : xarray.Dataset
161
+ The dataset to inspect.
162
+
163
+ Returns
164
+ -------
165
+ str
166
+ The name of the time coordinate or variable.
167
+
168
+ Raises
169
+ ------
170
+ ValueError
171
+ If no time dimension or coordinate is found.
172
+
173
+ """
174
+ # Common time names to check
175
+ time_candidates = ["TIME", "time", "Time", "DATETIME", "datetime", "date"]
176
+ for name in time_candidates:
177
+ if name in ds.coords or name in ds.variables:
178
+ return name
179
+
180
+ # Fallback: look for any variable or coordinate with datetime dtype
181
+ for name in ds.coords:
182
+ if np.issubdtype(ds.coords[name].dtype, np.datetime64):
183
+ logger.log_warning(
184
+ "No standard time coordinate found. Using first datetime coordinate: %s",
185
+ name,
186
+ )
187
+ return name
188
+
189
+ for name in ds.variables:
190
+ if np.issubdtype(ds.variables[name].dtype, np.datetime64):
191
+ logger.log_warning(
192
+ "No standard time coordinate found. Using first datetime coordinate: %s",
193
+ name,
194
+ )
195
+ return name
196
+
197
+ raise ValueError("No valid time coordinate found in dataset.") # noqa: TRY003
198
+
199
+
200
+ def get_dims(
201
+ ds_gridded: xr.Dataset,
202
+ ) -> tuple:
203
+ """Extract pressure key, time key, and their dimensions from a dataset.
204
+
205
+ Parameters
206
+ ----------
207
+ ds_gridded : xarray.Dataset
208
+ Dataset containing the variables and dimensions.
209
+
210
+ Returns
211
+ -------
212
+ pres_key : str or None
213
+ Key for the pressure (or depth) variable; None if not found.
214
+ time_key : str
215
+ Key for the time variable.
216
+ pres_dim : str or None
217
+ Dimension associated with the pressure variable; None if not found.
218
+ time_dim : str or None
219
+ Dimension associated with the time variable.
220
+
221
+ """
222
+ if isinstance(ds_gridded, xr.DataArray):
223
+ ds_gridded = ds_gridded.to_dataset()
224
+
225
+ # Determine pressure key
226
+ pressure_candidates = [
227
+ "PRES_ADJUSTED",
228
+ "PRESSURE",
229
+ "pressure",
230
+ "PRES",
231
+ "PRES_INTERPOLATED",
232
+ ]
233
+ pres_key = None
234
+ for name in pressure_candidates:
235
+ if name in ds_gridded.coords or name in ds_gridded.variables:
236
+ pres_key = name
237
+ break
238
+
239
+ # Fall back to depth if no pressure variable found
240
+ dpth_key = None
241
+ if pres_key is None:
242
+ depth_candidates = [
243
+ "DEPTH_ADJUSTED",
244
+ "DEPTH",
245
+ "depth",
246
+ "Depth",
247
+ "DEPTH_INTERPOLATED",
248
+ ]
249
+ for name in depth_candidates:
250
+ if name in ds_gridded.coords or name in ds_gridded.variables:
251
+ dpth_key = name
252
+ break
253
+
254
+ # Get time key using helper
255
+ time_key = get_time_key(ds_gridded)
256
+
257
+ # Determine dimensions
258
+ time_dim = ds_gridded[time_key].dims[0] if ds_gridded[time_key].dims else None
259
+ pres_dim = None
260
+ if pres_key is None and dpth_key is not None:
261
+ pres_dims = ds_gridded[dpth_key].dims
262
+ if len(pres_dims) > 1:
263
+ pres_dim = next(dim for dim in pres_dims if dim != time_dim)
264
+ else:
265
+ pres_dim = pres_dims[0]
266
+
267
+ elif pres_key is not None:
268
+ pres_dims = ds_gridded[pres_key].dims
269
+ if len(pres_dims) > 1:
270
+ pres_dim = next(dim for dim in pres_dims if dim != time_dim)
271
+ else:
272
+ pres_dim = pres_dims[0]
273
+
274
+ return pres_key or dpth_key, time_key, pres_dim, time_dim
275
+
276
+
277
+ def iso8601_duration_from_seconds(seconds: float) -> str:
278
+ """Convert a duration in seconds to an ISO 8601 duration string.
279
+
280
+ Parameters
281
+ ----------
282
+ seconds : float
283
+ Duration in seconds.
284
+
285
+ Returns
286
+ -------
287
+ str
288
+ ISO 8601 duration string, e.g., 'PT1H', 'PT30M', 'PT15S'.
289
+
290
+ """
291
+ seconds = int(round(seconds))
292
+ if seconds >= 86400:
293
+ return f"P{seconds // 86400}D"
294
+ elif seconds >= 3600:
295
+ return f"PT{seconds // 3600}H"
296
+ elif seconds >= 60:
297
+ return f"PT{seconds // 60}M"
298
+ else:
299
+ return f"PT{seconds}S"
300
+
301
+
302
+ def is_iso8601_utc(timestr: str) -> bool:
303
+ """Validate whether a string is in ISO8601 UTC format: YYYY-MM-DDTHH:MM:SSZ.
304
+
305
+ Parameters
306
+ ----------
307
+ timestr : str
308
+ Input time string.
309
+
310
+ Returns
311
+ -------
312
+ bool
313
+ True if valid ISO8601 UTC format, False otherwise.
314
+
315
+ """
316
+ try:
317
+ datetime.strptime(timestr, "%Y/%m/%dT%H:%M:%SZ") # RODB-style
318
+ except ValueError:
319
+ try:
320
+ datetime.strptime(timestr, "%Y-%m-%dT%H:%M:%SZ") # ISO8601 style
321
+ except ValueError:
322
+ return False
323
+ return True
324
+
325
+
326
+ def apply_defaults(default_source: str, default_files: List[str]) -> Callable:
327
+ """Decorate a function to apply default values for source and file_list parameters.
328
+
329
+ Parameters
330
+ ----------
331
+ default_source : str
332
+ Default source URL or path.
333
+ default_files : list of str
334
+ Default list of filenames.
335
+
336
+ Returns
337
+ -------
338
+ Callable
339
+ A wrapped function with defaults applied.
340
+
341
+ """
342
+
343
+ def decorator(func: Callable) -> Callable:
344
+ @wraps(func)
345
+ def wrapper(
346
+ source: Optional[str] = None,
347
+ file_list: Optional[List[str]] = None,
348
+ *args: Any,
349
+ **kwargs: Any,
350
+ ) -> Any:
351
+ if source is None:
352
+ source = default_source
353
+ if file_list is None:
354
+ file_list = default_files
355
+ return func(source=source, file_list=file_list, *args, **kwargs) # noqa: B026
356
+
357
+ return wrapper
358
+
359
+ return decorator
360
+
361
+
362
+ def _status(kind: str, msg: str, *, char: str = "=") -> None:
363
+ """Print a formatted status line to stdout.
364
+
365
+ kind msg char (optional)
366
+ ------- ------------------------- ---------------
367
+ section "Stage 1: dsG3_1_2026" border char, default "=" → "=== Stage 1: dsG3_1_2026 ==="
368
+ instr "<type> <serial>" ignored → "--- microcat 7507 ---"
369
+ file relative path ignored → "Creating output file: proc/..."
370
+ skip relative path ignored → " OUTFILE EXISTS: proc/... (use --force to overwrite)"
371
+ error message ignored → "ERROR: ..."
372
+
373
+ Pass ``char`` to ``section`` calls to distinguish mooring runs in a multi-mooring
374
+ script, e.g. ``char="*"`` gives ``"*** Stage 1: dsG3_2_2026 ***"``.
375
+ """
376
+ if kind == "section":
377
+ border = char * 3
378
+ print(f"\n{border} {msg} {border}")
379
+ elif kind == "instr":
380
+ print(f"\n--- {msg} ---")
381
+ elif kind == "file":
382
+ print(f"Creating output file: {msg}")
383
+ elif kind == "skip":
384
+ print(f" OUTFILE EXISTS: {msg} (use --force to overwrite)")
385
+ elif kind == "error":
386
+ print(f"ERROR: {msg}")
387
+
388
+
389
+ def _nice_colorbar_bounds(vmin: float, vmax: float, n: int = 20) -> np.ndarray:
390
+ """Return a boundary array for a discrete colorbar with approximately *n* levels.
391
+
392
+ The step is rounded to 1 significant figure so tick labels land on clean values.
393
+ The range is centered on the midpoint of [vmin, vmax].
394
+
395
+ Examples
396
+ --------
397
+ Temperature vmin=0.87, vmax=7.23 → step=0.3, bounds 1.2–7.2 (20 levels)
398
+ Salinity vmin=34.782, vmax=35.139 → step=0.02, bounds 34.76–35.16 (20 levels)
399
+
400
+ """
401
+ span = vmax - vmin
402
+ if span <= 0:
403
+ return np.linspace(vmin - 1, vmin + 1, n + 1)
404
+ raw_step = span / n
405
+ mag = 10.0 ** math.floor(math.log10(raw_step))
406
+ rounded = round(raw_step / mag)
407
+ if rounded == 0:
408
+ rounded = 1
409
+ elif rounded >= 10:
410
+ mag *= 10
411
+ rounded = 1
412
+ nice_step = rounded * mag
413
+ mid = (vmin + vmax) / 2
414
+ mid_aligned = round(mid / nice_step) * nice_step
415
+ lo = mid_aligned - (n / 2) * nice_step
416
+ return np.array([lo + i * nice_step for i in range(n + 1)])
417
+
418
+
419
+ # ---------------------------------------------------------------------------
420
+ # Output dtype helpers
421
+ # ---------------------------------------------------------------------------
422
+
423
+
424
+ def find_best_dtype(var_name: str, da: xr.DataArray) -> type:
425
+ """Determine the optimal storage dtype for a variable.
426
+
427
+ Parameters
428
+ ----------
429
+ var_name : str
430
+ Variable name.
431
+ da : xr.DataArray
432
+ Data array to inspect.
433
+
434
+ Returns
435
+ -------
436
+ type
437
+ Recommended numpy dtype.
438
+
439
+ Notes
440
+ -----
441
+ Rules applied in order:
442
+
443
+ - String / datetime / object variables: unchanged.
444
+ - ``time`` in name: unchanged (preserve datetime64 / float encoding).
445
+ - ``*_qc`` suffix or ``flag`` in name: ``int8``.
446
+ - ``serial_number`` or ``serial``: ``int32``.
447
+ - ``latitude`` / ``longitude`` in name: ``float64``.
448
+ - Integer input: downsize to ``int32`` if stored as ``int64``, else unchanged.
449
+ - ``float64`` input: ``float32``.
450
+ - Anything else: unchanged.
451
+
452
+ """
453
+ input_dtype = da.dtype.type
454
+ if da.dtype.kind in ("U", "S", "O", "M"):
455
+ return input_dtype
456
+ if "time" in var_name.lower():
457
+ return input_dtype
458
+ if var_name.endswith("_qc") or "flag" in var_name:
459
+ return np.int8
460
+ if var_name in ("serial_number", "serial"):
461
+ return np.int32
462
+ if "latitude" in var_name.lower() or "longitude" in var_name.lower():
463
+ return np.float64
464
+ if da.dtype.kind in ("i", "u") and da.dtype.itemsize == 8:
465
+ return np.int32
466
+ if input_dtype == np.float64:
467
+ return np.float32
468
+ return input_dtype
469
+
470
+
471
+ def drop_all_zero_vars(ds: xr.Dataset, prefixes: list[str]) -> xr.Dataset:
472
+ """Drop variables whose finite values are all zero.
473
+
474
+ Parameters
475
+ ----------
476
+ ds : xr.Dataset
477
+ Dataset to filter.
478
+ prefixes : list of str
479
+ Variable name prefixes to check (e.g. ``["amplitude_beam", "analog_input_"]``).
480
+ Any variable whose name starts with one of these prefixes and whose
481
+ finite values are all zero (or has no finite values) is dropped.
482
+
483
+ Returns
484
+ -------
485
+ xr.Dataset
486
+ Dataset with all-zero prefix-matched variables removed.
487
+
488
+ """
489
+ to_drop = []
490
+ for vname in ds.data_vars:
491
+ if not any(vname.startswith(p) for p in prefixes):
492
+ continue
493
+ arr = ds[vname].values
494
+ finite = arr[np.isfinite(arr)] if np.issubdtype(arr.dtype, np.floating) else arr
495
+ if finite.size == 0 or np.all(finite == 0):
496
+ to_drop.append(vname)
497
+ return ds.drop_vars(to_drop) if to_drop else ds
498
+
499
+
500
+ def cast_output_dtypes(ds: xr.Dataset) -> xr.Dataset:
501
+ """Cast every variable in *ds* to its optimal storage dtype.
502
+
503
+ Calls :func:`find_best_dtype` per variable and rebuilds only those that
504
+ change. Attributes are preserved. The input dataset is not modified.
505
+
506
+ Parameters
507
+ ----------
508
+ ds : xr.Dataset
509
+ Dataset to cast.
510
+
511
+ Returns
512
+ -------
513
+ xr.Dataset
514
+ New dataset with optimised dtypes, ready for NetCDF output.
515
+
516
+ """
517
+ updates: dict[str, xr.Variable] = {}
518
+ for vname in ds.data_vars:
519
+ var = ds[vname]
520
+ target = find_best_dtype(vname, var)
521
+ if np.dtype(target) != var.dtype:
522
+ if np.issubdtype(np.dtype(target), np.integer) and np.issubdtype(
523
+ var.dtype, np.floating
524
+ ):
525
+ # NaN cannot be represented as an integer; replace before cast.
526
+ # QC variables use 9 (CF "missing value"); other integer vars use 0.
527
+ fill_val = 9 if (vname.endswith("_qc") or "flag" in vname) else 0
528
+ safe_vals = np.where(np.isfinite(var.values), var.values, fill_val)
529
+ updates[vname] = xr.Variable(
530
+ var.dims, safe_vals.astype(target), attrs=var.attrs
531
+ )
532
+ else:
533
+ updates[vname] = xr.Variable(
534
+ var.dims, var.values.astype(target), attrs=var.attrs
535
+ )
536
+ if not updates:
537
+ return ds
538
+ return ds.assign(updates)