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,429 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ import xarray as xr
4
+ from scipy.interpolate import interp1d
5
+ from scipy.signal import butter, filtfilt
6
+
7
+ from .. import utilities
8
+
9
+
10
+ def find_time_vars(ds_list, time_key="TIME"):
11
+ """Return all variable names that have (time_key,) dimensions in any dataset."""
12
+ time_vars = set()
13
+ for ds in ds_list:
14
+ time_vars.update([var for var in ds.data_vars if ds[var].dims == (time_key,)])
15
+ return sorted(time_vars)
16
+
17
+
18
+ def find_common_attributes(ds_list):
19
+ """Return attributes that are common across all datasets with the same value."""
20
+ common = {}
21
+ shared_keys = set.intersection(*(set(ds.attrs) for ds in ds_list))
22
+ for key in shared_keys:
23
+ values = [ds.attrs[key] for ds in ds_list]
24
+ if all(val == values[0] for val in values):
25
+ common[key] = values[0]
26
+ return common
27
+
28
+
29
+ def stack_instruments(ds_list, time_key="TIME"):
30
+ ds_list = sorted(ds_list, key=lambda ds: ds.InstrDepth.values)
31
+ time = ds_list[0][time_key]
32
+ n_levels = len(ds_list)
33
+ n_time = len(time)
34
+
35
+ # Ensure all datasets have the same TIME dimension & arrays are exactly equal
36
+ for ds in ds_list:
37
+ if not np.array_equal(ds[time_key], time):
38
+ raise ValueError("Not all datasets share the same TIME dimension.")
39
+
40
+ # Find all time variables across datasets
41
+ time_vars = find_time_vars(ds_list, time_key=time_key)
42
+
43
+ # Stack the time variables across datasets
44
+ data_vars = {}
45
+ for var in time_vars:
46
+ stacked = np.full((n_levels, n_time), np.nan)
47
+ for i, ds in enumerate(ds_list):
48
+ if var in ds:
49
+ stacked[i, :] = ds[var].values
50
+ data_vars[var] = (("N_LEVELS", time_key), stacked)
51
+
52
+ # Create a list of instrument depths
53
+ instr_depths = np.array([ds.InstrDepth.values for ds in ds_list])
54
+
55
+ # Verify that all datasets have the same latitude and longitude
56
+ lats = np.array([ds.Latitude.values for ds in ds_list])
57
+ lons = np.array([ds.Longitude.values for ds in ds_list])
58
+ if not np.allclose(lats, lats[0]):
59
+ raise ValueError("Latitude values differ between datasets.")
60
+ if not np.allclose(lons, lons[0]):
61
+ raise ValueError("Longitude values differ between datasets.")
62
+
63
+ # Collect serial numbers and common attributes
64
+ serial_numbers = [ds.attrs.get("serial_number", "unknown") for ds in ds_list]
65
+ common_attrs = find_common_attributes(ds_list)
66
+
67
+ ds_out = xr.Dataset(
68
+ data_vars=data_vars,
69
+ coords={
70
+ time_key: time,
71
+ "N_LEVELS": np.arange(n_levels),
72
+ "InstrDepth": ("N_LEVELS", instr_depths),
73
+ },
74
+ attrs={
75
+ **common_attrs,
76
+ "Latitude": float(lats[0]),
77
+ "Longitude": float(lons[0]),
78
+ "serial_numbers": serial_numbers,
79
+ },
80
+ )
81
+
82
+ return ds_out
83
+
84
+
85
+ def add_serial_and_sensor_info(ds_combined, ds_list):
86
+ """Add serial number/depth mapping and sensor variable info to the combined dataset.
87
+
88
+ Parameters
89
+ ----------
90
+ ds_combined : xarray.Dataset
91
+ The combined mooring-level dataset (with DEPTH coordinate).
92
+ ds_list : list of xarray.Dataset
93
+ List of instrument-level datasets used to create ds_combined.
94
+
95
+ Returns
96
+ -------
97
+ xarray.Dataset
98
+ The dataset with added attributes and SENSOR_VARS variable.
99
+ dict
100
+ The updated attributes dictionary.
101
+
102
+ """
103
+ # Get the serial numbers and depths
104
+ serial_numbers = []
105
+ depths = []
106
+ for ds in ds_list:
107
+ serial_number = ds.attrs.get("serial_number", "unknown")
108
+ if serial_number not in serial_numbers:
109
+ serial_numbers.append(serial_number)
110
+ depth_val = (
111
+ float(ds.InstrDepth.values)
112
+ if np.ndim(ds.InstrDepth.values) == 0
113
+ else float(ds.InstrDepth.values[0])
114
+ )
115
+ depths.append(depth_val)
116
+ serial_depth_dict = dict(zip(serial_numbers, depths))
117
+ attrs = ds_combined.attrs.copy()
118
+ attrs["serial_depth_dict"] = str(serial_depth_dict)
119
+
120
+ # Collect all variable names that match "SENSOR_" prefix
121
+ sensor_vars = [var for var in ds_combined.data_vars if var.startswith("SENSOR_")]
122
+ sensor_depths = []
123
+ for var in sensor_vars:
124
+ vals = ds_combined[var].values
125
+ if vals.ndim == 1:
126
+ idx = np.where(~np.isnan(vals))[0]
127
+ depth = (
128
+ float(ds_combined["DEPTH"].values[idx[0]]) if idx.size > 0 else np.nan
129
+ )
130
+ elif vals.ndim > 1:
131
+ idx = np.where(~np.isnan(vals.any(axis=tuple(range(1, vals.ndim)))))
132
+ depth = (
133
+ float(ds_combined["DEPTH"].values[idx[0][0]])
134
+ if idx[0].size > 0
135
+ else np.nan
136
+ )
137
+ else:
138
+ depth = np.nan
139
+ sensor_depths.append(depth)
140
+ sensor_vars = np.array(sensor_vars)
141
+ sensor_depths = np.array(sensor_depths)
142
+ sensor_vars_at_depth = []
143
+ for depth in ds_combined["DEPTH"].values:
144
+ matching_vars = sensor_vars[sensor_depths == depth]
145
+ if matching_vars.size > 0:
146
+ sensor_vars_at_depth.append(", ".join(matching_vars))
147
+ else:
148
+ sensor_vars_at_depth.append("")
149
+ sensor_vars_arr = np.array(sensor_vars_at_depth, dtype="object")
150
+ ds_combined["SENSOR_VARS"] = xr.DataArray(
151
+ sensor_vars_arr,
152
+ dims=("DEPTH",),
153
+ coords={"DEPTH": ds_combined["DEPTH"]},
154
+ attrs={"long_name": "Sensor variable names", "units": "string"},
155
+ )
156
+ return ds_combined, attrs
157
+
158
+
159
+ def combine_mooring_OS(ds_list):
160
+ """Combine a list of OceanSITES instrument-level datasets into a mooring-level dataset.
161
+
162
+ Parameters
163
+ ----------
164
+ ds_list : list of xarray.Dataset
165
+ List of instrument-level datasets to concatenate along DEPTH.
166
+
167
+ Returns
168
+ -------
169
+ xarray.Dataset
170
+ Combined dataset with cleaned and updated global attributes.
171
+
172
+ """
173
+ if not ds_list:
174
+ raise ValueError("Input list is empty. At least one dataset is required.")
175
+
176
+ ds_combined = utilities.concat_with_scalar_vars(ds_list, dim="DEPTH")
177
+ ds_combined = ds_combined.sortby("DEPTH") # Ensure DEPTH is sorted
178
+
179
+ # Start with a copy of attributes from the first dataset
180
+ attrs = ds_combined.attrs.copy()
181
+
182
+ # Remove instrument-specific attribute
183
+ attrs.pop("serial_number", None)
184
+
185
+ # Merge all unique source_file entries
186
+ source_files = []
187
+ for ds in ds_list:
188
+ sf = ds.attrs.get("source_file")
189
+ if isinstance(sf, list):
190
+ source_files.extend(sf)
191
+ elif isinstance(sf, str):
192
+ # Split comma-separated strings if needed
193
+ source_files.extend([s.strip() for s in sf.split(",")])
194
+ unique_sorted = sorted(set(source_files))
195
+ attrs["source_file"] = ", ".join(unique_sorted)
196
+
197
+ # Simplify id and title
198
+ platform_code = attrs.get("platform_code", "unknown")
199
+ deployment_code = attrs.get("deployment_code", "unknown")
200
+ attrs["title"] = f"Time series from {platform_code}_{deployment_code}"
201
+ attrs["id"] = f"OS_{platform_code}_{deployment_code}_P"
202
+
203
+ # Update geospatial vertical bounds from DEPTH coordinate
204
+ if "DEPTH" in ds_combined.coords:
205
+ depths = ds_combined["DEPTH"].values
206
+ attrs["geospatial_vertical_min"] = float(np.nanmin(depths))
207
+ attrs["geospatial_vertical_max"] = float(np.nanmax(depths))
208
+
209
+ # Re-order variables to put any scalar variables at the end, and if they have numeric values, then put them in order of low to high values
210
+ scalar_vars = []
211
+ for var in ds_combined.data_vars:
212
+ if ds_combined[var].ndim == 0:
213
+ scalar_vars.append(var)
214
+ scalar_vars.sort(
215
+ key=lambda v: (
216
+ ds_combined[v].values
217
+ if np.issubdtype(ds_combined[v].dtype, np.number)
218
+ else float("inf")
219
+ )
220
+ )
221
+ non_scalar_vars = [var for var in ds_combined.data_vars if var not in scalar_vars]
222
+ ds_combined = ds_combined[non_scalar_vars + scalar_vars]
223
+
224
+ # Assign back updated attributes
225
+ ds_combined.attrs = attrs
226
+
227
+ return ds_combined
228
+
229
+
230
+ def auto_filt(y, sr, co, typ="low", fo=6):
231
+ """Apply a Butterworth digital filter to a data array.
232
+
233
+ Parameters
234
+ ----------
235
+ y : array_like
236
+ Input data array (1D).
237
+ sr : float
238
+ Sampling rate (Hz or 1/time units of your data).
239
+ co : float or tuple of float
240
+ Cutoff frequency/frequencies. A scalar for 'low' or 'high', a 2-tuple for 'bandstop'.
241
+ typ : str, optional
242
+ Filter type: 'low', 'high', or 'bandstop'. Default is 'low'.
243
+ fo : int, optional
244
+ Filter order. Default is 6.
245
+
246
+ Returns
247
+ -------
248
+ yf : ndarray
249
+ Filtered data array.
250
+
251
+ """
252
+ # Normalize cutoff frequency to the Nyquist rate
253
+ nyquist = 0.5 * sr
254
+ if isinstance(co, (list, tuple, np.ndarray)):
255
+ wh = [c / nyquist for c in co]
256
+ else:
257
+ wh = co / nyquist
258
+
259
+ b, a = butter(fo, wh, btype=typ)
260
+ yf = filtfilt(b, a, y)
261
+ return yf
262
+
263
+
264
+ def filter_all_time_vars(ds, cutoff_days=2, fo=6):
265
+ """Apply a lowpass Butterworth filter to all data variables that depend on TIME.
266
+
267
+ Parameters
268
+ ----------
269
+ ds : xarray.Dataset
270
+ Dataset containing time series variables.
271
+ cutoff_days : float, optional
272
+ Lowpass filter cutoff in days. Default is 2 days.
273
+ fo : int, optional
274
+ Filter order. Default is 6.
275
+
276
+ Returns
277
+ -------
278
+ xarray.Dataset
279
+ Dataset with filtered variables.
280
+
281
+ """
282
+ time = ds["TIME"]
283
+ dt = (time.isel(TIME=1) - time.isel(TIME=0)) / np.timedelta64(1, "s") # seconds
284
+ sr = 1.0 / dt # Hz
285
+ co = 1.0 / (cutoff_days * 24 * 3600) # Hz
286
+
287
+ ds_filt = ds.copy(deep=True)
288
+ padlen = 3 * fo
289
+
290
+ for var in ds.data_vars:
291
+ da = ds[var]
292
+ if "TIME" in da.dims and da.sizes["TIME"] == ds["TIME"].size:
293
+ y = da.values
294
+
295
+ if y.shape[0] <= padlen:
296
+ continue # skip too-short series
297
+
298
+ # Reshape for filtering along TIME
299
+ y_flat = y.reshape((y.shape[0], -1)) # (TIME, N)
300
+ y_filt = np.empty_like(y_flat)
301
+
302
+ for i in range(y_flat.shape[1]):
303
+ y1d = y_flat[:, i]
304
+ if np.isnan(y1d).all():
305
+ y_filt[:, i] = np.nan
306
+ else:
307
+ try:
308
+ y_filt[:, i] = auto_filt(y1d, sr, co, typ="low", fo=fo)
309
+ except ValueError:
310
+ y_filt[:, i] = np.nan # fallback for rare failures
311
+
312
+ # Reshape back to original
313
+ ds_filt[var].values = y_filt.reshape(y.shape)
314
+
315
+ return ds_filt
316
+
317
+
318
+ def get_12hourly_time_grid(
319
+ time_or_ds,
320
+ freq="12h",
321
+ start_offset=pd.Timedelta(days=1),
322
+ end_offset=pd.Timedelta(0),
323
+ time_var="TIME",
324
+ ):
325
+ """Given a pandas.DatetimeIndex, array of datetimes, or xarray.Dataset,
326
+ return a regular time grid (default: 12-hourly) from the first full day after the start
327
+ to the last full day before the end.
328
+
329
+ Parameters
330
+ ----------
331
+ time_or_ds : array-like of datetime64, pandas.DatetimeIndex, or xarray.Dataset
332
+ Input time array or dataset containing a time variable.
333
+ freq : str, optional
334
+ Frequency string for the output grid (default '12h').
335
+ start_offset : pd.Timedelta, optional
336
+ Offset to add to the start time before normalizing (default 1 day).
337
+ end_offset : pd.Timedelta, optional
338
+ Offset to subtract from the end time after normalizing (default 0).
339
+ time_var : str, optional
340
+ Name of the time variable in the dataset (default 'TIME').
341
+
342
+ Returns
343
+ -------
344
+ pandas.DatetimeIndex
345
+ Regular time grid at the specified frequency.
346
+
347
+ """
348
+ if hasattr(time_or_ds, "dims") and time_var in time_or_ds:
349
+ # Assume xarray.Dataset or DataArray
350
+ time = pd.to_datetime(time_or_ds[time_var].values)
351
+ else:
352
+ time = pd.to_datetime(time_or_ds)
353
+ start = time[0]
354
+ stop = time[-1]
355
+ start_day = (
356
+ (start + start_offset).normalize()
357
+ if start.time() != pd.Timestamp("00:00").time()
358
+ else start.normalize()
359
+ )
360
+ stop_day = (stop - end_offset).normalize()
361
+ jd_grid = pd.date_range(start=start_day, end=stop_day, freq=freq)
362
+ return jd_grid
363
+
364
+
365
+ def interp_to_12hour_grid(ds1):
366
+ """Interpolate all variables with a TIME dimension to a regular 12-hour grid.
367
+
368
+ Handles both 1D and multidimensional variables with TIME as the first dimension.
369
+ """
370
+ jd_grid = get_12hourly_time_grid(ds1)
371
+ time1 = ds1["TIME"].values
372
+ time1_float = time1.astype("datetime64[s]").astype(float)
373
+ grid_float = jd_grid.values.astype("datetime64[s]").astype(float)
374
+
375
+ interp_vars = {}
376
+
377
+ for var in ds1.data_vars:
378
+ da = ds1[var]
379
+ if "TIME" not in da.dims:
380
+ continue # skip non-time-dependent variables
381
+
382
+ axis_time = da.get_axis_num("TIME")
383
+ other_dims = [d for d in da.dims if d != "TIME"]
384
+
385
+ # Reshape to (TIME, -1) to interpolate each column
386
+ reshaped = da.transpose("TIME", *other_dims).data.reshape(len(time1), -1)
387
+
388
+ interpolated = np.empty((len(grid_float), reshaped.shape[1]))
389
+
390
+ for i in range(reshaped.shape[1]):
391
+ interp_func = interp1d(
392
+ time1_float,
393
+ reshaped[:, i],
394
+ bounds_error=False,
395
+ fill_value="extrapolate",
396
+ assume_sorted=True,
397
+ )
398
+ interpolated[:, i] = interp_func(grid_float)
399
+
400
+ # Reshape back to (TIME, *other_dims)
401
+ new_shape = (len(grid_float),) + tuple(
402
+ da.transpose("TIME", *other_dims).shape[1:]
403
+ )
404
+ interp_data = interpolated.reshape(new_shape)
405
+
406
+ # Create new DataArray
407
+ coords = {"TIME": jd_grid}
408
+ for dim in other_dims:
409
+ coords[dim] = ds1[dim]
410
+
411
+ interp_vars[var] = xr.DataArray(
412
+ interp_data, dims=("TIME", *other_dims), coords=coords, attrs=da.attrs
413
+ )
414
+
415
+ # Build new dataset
416
+ ds_interp = xr.Dataset(interp_vars, coords={"TIME": jd_grid})
417
+ ds_interp.attrs = ds1.attrs.copy()
418
+
419
+ # Copy non-time-dependent variables
420
+ for var in ds1.data_vars:
421
+ if "TIME" not in ds1[var].dims:
422
+ ds_interp[var] = ds1[var]
423
+
424
+ # Also copy over coordinates that are not 'TIME'
425
+ for coord in ds1.coords:
426
+ if coord != "TIME" and coord not in ds_interp.coords:
427
+ ds_interp = ds_interp.assign_coords({coord: ds1[coord]})
428
+
429
+ return ds_interp