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
oceanarray/tools.py ADDED
@@ -0,0 +1,400 @@
1
+ """Science utility functions for oceanographic data analysis."""
2
+
3
+ import logging
4
+
5
+ import gsw
6
+ import numpy as np
7
+ import pandas as pd
8
+ import xarray as xr
9
+ from scipy.signal import find_peaks
10
+
11
+ from oceanarray import utilities
12
+
13
+ # Initialize logging
14
+ _log = logging.getLogger(__name__)
15
+
16
+
17
+ def lag_correlation(x, y, max_lag, min_overlap=10):
18
+ """Pearson correlation at integer lags in [-max_lag, max_lag]."""
19
+ x = np.asarray(x, float)
20
+ y = np.asarray(y, float)
21
+ if x.shape != y.shape:
22
+ raise ValueError("x and y must have same length (subsample both).") # noqa: TRY003
23
+ corrs = np.full(2 * max_lag + 1, np.nan)
24
+ for k, lag in enumerate(range(-max_lag, max_lag + 1)):
25
+ if lag < 0:
26
+ xs = x[:lag] # up to last |lag|
27
+ ys = y[-lag:] # from |lag| to end
28
+ elif lag > 0:
29
+ xs = x[lag:] # from lag to end
30
+ ys = y[:-lag] # up to n-lag
31
+ else:
32
+ xs, ys = x, y
33
+
34
+ m = ~np.isnan(xs) & ~np.isnan(ys)
35
+ if m.sum() >= min_overlap:
36
+ xc = xs[m] - np.nanmean(xs[m])
37
+ yc = ys[m] - np.nanmean(ys[m])
38
+ denom = np.nanstd(xc) * np.nanstd(yc)
39
+ corrs[k] = (np.nanmean(xc * yc) / denom) if denom > 0 else np.nan
40
+ return corrs
41
+
42
+
43
+ def split_value(data, nbins=30):
44
+ """Find a histogram-based split value between two data modes."""
45
+ data = data[~np.isnan(data)] # Remove NaNs for histogram
46
+ # Step 1: Create histogram
47
+ counts, bins = np.histogram(data, bins=nbins)
48
+
49
+ # Step 2: Find peaks
50
+ peaks, _ = find_peaks(counts)
51
+
52
+ # Step 3: Find minimum between first two major peaks
53
+ if len(peaks) >= 2:
54
+ i1, i2 = sorted(peaks[:2])
55
+ split_index = np.argmin(counts[i1:i2]) + i1
56
+ splitter = bins[split_index]
57
+ # print("Split value:", splitter)
58
+ return splitter
59
+
60
+
61
+ def find_cold_entry_exit(
62
+ time, temp, quantile=0.95, dwell_seconds=1800, smooth_window=5
63
+ ):
64
+ """Identify first sustained entry into 'cold' regime and last sustained exit.
65
+
66
+ Parameters
67
+ ----------
68
+ time : array-like of datetime64
69
+ Time coordinate array.
70
+ temp : array-like of float
71
+ Temperature values aligned with *time*.
72
+ quantile : float
73
+ Percentile for threshold (e.g. 0.1 ~ 10th percentile).
74
+ dwell_seconds : int
75
+ Minimum time in cold regime for it to count (seconds).
76
+ smooth_window : int
77
+ Rolling median window length (samples).
78
+
79
+ Returns
80
+ -------
81
+ t_start, t_end, threshold : tuple of (Timestamp or None, Timestamp or None, float)
82
+
83
+ """
84
+ time = pd.to_datetime(time)
85
+ temp = np.asarray(temp, dtype=float)
86
+ thr = np.nanquantile(temp, quantile)
87
+
88
+ # smooth with rolling median
89
+ if smooth_window > 1:
90
+ temp = (
91
+ pd.Series(temp)
92
+ .rolling(smooth_window, center=True, min_periods=1)
93
+ .median()
94
+ .values
95
+ )
96
+
97
+ below = temp <= thr
98
+ if not below.any():
99
+ return None, None, thr
100
+
101
+ # sampling interval (assume near-regular)
102
+ dt = np.nanmedian(np.diff(time.values).astype("timedelta64[s]").astype(float))
103
+ min_len = int(np.ceil(dwell_seconds / dt))
104
+
105
+ # contiguous runs
106
+ idx = np.where(below)[0]
107
+ gaps = np.diff(idx) > 1
108
+ starts = np.r_[idx[0], idx[1:][gaps]]
109
+ ends = np.r_[idx[:-1][gaps], idx[-1]]
110
+
111
+ runs = [(s, e) for s, e in zip(starts, ends) if (e - s + 1) >= min_len]
112
+ if not runs:
113
+ return None, None, thr
114
+
115
+ s0, _ = runs[0]
116
+ _, eL = runs[-1]
117
+ return time[s0], time[eL], thr
118
+
119
+
120
+ def calc_psal(ds):
121
+ """Compute Practical Salinity from conductivity, temperature, and pressure.
122
+
123
+ Uses the Gibbs SeaWater (GSW) toolbox: ``gsw.SP_from_C`` applies the
124
+ PSS-78 equation to derive Practical Salinity (dimensionless, roughly PSU)
125
+ from conductivity (mS cm⁻¹), temperature (°C), and pressure (dbar).
126
+
127
+ If ``PSAL`` is already present in *ds* the function is a no-op.
128
+
129
+ Parameters
130
+ ----------
131
+ ds : xarray.Dataset
132
+ Dataset containing ``CNDC`` (conductivity, mS cm⁻¹), ``TEMP``
133
+ (temperature, °C), and ``PRES`` (pressure, dbar).
134
+
135
+ Returns
136
+ -------
137
+ xarray.Dataset
138
+ Input dataset with ``PSAL`` added (same dimensions as ``CNDC``),
139
+ or unchanged if ``PSAL`` was already present.
140
+
141
+ """
142
+ if "PSAL" not in ds:
143
+ SP = gsw.SP_from_C(ds["CNDC"], ds["TEMP"], ds["PRES"])
144
+ ds["PSAL"] = (ds["CNDC"].dims, SP.data)
145
+ else:
146
+ print("ds already contains variable PSAL")
147
+ return ds
148
+
149
+
150
+ def flag_salinity_outliers(ds, n_std=4):
151
+ """Flags PSAL values that are more than n_std standard deviations from the mean,
152
+ computed separately for each depth level.
153
+
154
+ Parameters
155
+ ----------
156
+ ds : xarray.Dataset
157
+ Dataset containing "PSAL" variable with dimensions including "DEPTH".
158
+ n_std : float, optional
159
+ Number of standard deviations from the mean to define an outlier (default is 4).
160
+
161
+ Returns
162
+ -------
163
+ xarray.DataArray (bool)
164
+ Boolean array with True where salinity is flagged as an outlier.
165
+
166
+ """
167
+ if "CNDC_QC" in ds:
168
+ mask = ds["CNDC_QC"] == 0
169
+ mean_sal = ds["PSAL"].where(mask).mean(dim="TIME", skipna=True)
170
+ std_sal = ds["PSAL"].where(mask).std(dim="TIME", skipna=True)
171
+ else:
172
+ mean_sal = ds["PSAL"].mean(dim="TIME", skipna=True)
173
+ std_sal = ds["PSAL"].std(dim="TIME", skipna=True)
174
+
175
+ # Broadcast mean and std over time
176
+ lower = mean_sal - n_std * std_sal
177
+ upper = mean_sal + n_std * std_sal
178
+
179
+ flag = (ds["PSAL"] < lower) | (ds["PSAL"] > upper)
180
+ return flag
181
+
182
+
183
+ def flag_temporal_spikes(ds, var="CNDC", threshold=5):
184
+ """Flags large absolute differences in time for each depth.
185
+
186
+ threshold: maximum allowed difference in units of the variable
187
+ """
188
+ diff = np.abs(ds[var].diff("TIME", label="upper"))
189
+ flag = diff > threshold
190
+ # Pad to match original dimensions
191
+ flag = flag.reindex(TIME=ds["TIME"], method="ffill")
192
+ flag = flag.fillna(False)
193
+ return flag.astype(bool)
194
+
195
+
196
+ def flag_vertical_inconsistencies(ds, var="CNDC", threshold=2):
197
+ """Flags points that are very different from vertical neighbors.
198
+ threshold: max allowed difference between vertically adjacent sensors.
199
+ """
200
+ # Central difference approximation in depth
201
+ vert_diff = np.abs(ds[var].diff("DEPTH"))
202
+ # Pad to match original dimensions
203
+ vert_diff = vert_diff.reindex(DEPTH=ds["DEPTH"], method="ffill")
204
+ flag = vert_diff > threshold
205
+ return flag
206
+
207
+
208
+ def run_qc(ds):
209
+ """Apply a sequence of QC tests and write combined CNDC_QC flag variable."""
210
+ if "PSAL" not in ds:
211
+ ds = calc_psal(ds)
212
+
213
+ sal_flag = flag_salinity_outliers(ds, 6).astype(bool)
214
+ time_flag = flag_temporal_spikes(ds).astype(bool)
215
+ # vert_flag = flag_vertical_inconsistencies(ds).astype(bool)
216
+
217
+ # Combine all
218
+ combined_flag = sal_flag | time_flag
219
+ ds["CNDC_QC"] = (ds["CNDC"].dims, combined_flag.data.astype(int))
220
+
221
+ sal_flag = flag_salinity_outliers(ds, 3).astype(bool)
222
+ combined_flag = sal_flag | time_flag
223
+ ds["CNDC_QC"] = (ds["CNDC"].dims, combined_flag.data.astype(int))
224
+
225
+ return ds
226
+
227
+
228
+ def downsample_to_sparse(
229
+ temp_profiles, salt_profiles, full_pressures, sparse_pressures
230
+ ):
231
+ """Downsample full T/S profiles to sparse pressure levels.
232
+
233
+ Parameters
234
+ ----------
235
+ temp_profiles : np.ndarray
236
+ Full temperature profiles, shape (n_profiles, n_pressures_full).
237
+ salt_profiles : np.ndarray
238
+ Full salinity profiles, shape (n_profiles, n_pressures_full).
239
+ full_pressures : np.ndarray
240
+ Full pressure levels corresponding to temp_profiles and salt_profiles, shape (n_pressures_full,).
241
+ sparse_pressures : np.ndarray
242
+ Target sparse pressure levels to sample, shape (n_pressures_sparse,).
243
+
244
+ Returns
245
+ -------
246
+ sparse_inputs : np.ndarray
247
+ Concatenated sparse temperature and salinity features,
248
+ shape (n_profiles, 2 * n_pressures_sparse).
249
+ (temp_sparse followed by salt_sparse)
250
+
251
+ """
252
+ n_profiles = temp_profiles.shape[0]
253
+ all_temp = []
254
+ all_salt = []
255
+
256
+ for i in range(n_profiles):
257
+ temp_sparse = np.interp(
258
+ sparse_pressures,
259
+ full_pressures,
260
+ temp_profiles[i],
261
+ left=np.nan,
262
+ right=np.nan,
263
+ )
264
+ salt_sparse = np.interp(
265
+ sparse_pressures,
266
+ full_pressures,
267
+ salt_profiles[i],
268
+ left=np.nan,
269
+ right=np.nan,
270
+ )
271
+ all_temp.append(temp_sparse)
272
+ all_salt.append(salt_sparse)
273
+
274
+ return np.array(all_temp), np.array(all_salt)
275
+
276
+
277
+ def process_dataset(
278
+ ds: xr.Dataset,
279
+ latlim: tuple[float, float] = (26.0, 27.0),
280
+ lonlim: tuple[float, float] = (-77.0, -76.5),
281
+ pgrid: np.ndarray = None,
282
+ ) -> tuple[xr.Dataset, xr.Dataset]:
283
+ """Filter and process a hydrographic dataset for use in training.
284
+
285
+ This function selects a region of interest, extracts and downsamples profiles of
286
+ temperature and salinity onto both standard and sparse pressure grids. It also computes
287
+ potential density anomaly for both resolutions.
288
+
289
+ Parameters
290
+ ----------
291
+ ds : xr.Dataset
292
+ Input dataset containing hydrographic data including CT, SA, PRES, and metadata.
293
+ latlim : tuple of float, optional
294
+ Latitude limits for filtering, by default (26.0, 27.0).
295
+ lonlim : tuple of float, optional
296
+ Longitude limits for filtering, by default (-77.0, -76.5).
297
+ pgrid : np.ndarray, optional
298
+ Target pressure levels (dbar) for vertical interpolation. When None,
299
+ a 20 dbar grid is constructed automatically from 0 to the maximum
300
+ observed pressure.
301
+
302
+ Returns
303
+ -------
304
+ ds_standard : xr.Dataset
305
+ Dataset downsampled to standard pressure levels.
306
+ ds_sparse : xr.Dataset
307
+ Dataset downsampled to sparse pressure levels.
308
+
309
+ See Also
310
+ --------
311
+ verticalnn.data_utils.downsample_to_sparse : Used to interpolate to target pressure levels.
312
+ verticalnn.config.STANDARD_PRESSURES : Standard pressure grid.
313
+ verticalnn.config.SPARSE_PRESSURES : Sparse pressure grid.
314
+
315
+ """
316
+ pres_key, time_key, pres_dim, time_dim = utilities.get_dims(ds)
317
+
318
+ if pgrid is None:
319
+ max_pres = np.nanmax(ds[pres_key].values)
320
+ pgrid = np.arange(0, max_pres + 1, 20)
321
+
322
+ # Extract variables
323
+ TEMP_profiles = ds["TEMP"].values
324
+ PSAL_profiles = ds["PSAL"].values
325
+ CT_profiles = ds["CT"].values
326
+ SA_profiles = ds["SA"].values
327
+ PRES = ds[pres_key].values
328
+ LAT = ds["LATITUDE"].values
329
+ LON = ds["LONGITUDE"].values
330
+ time = ds[time_key].values
331
+
332
+ # Apply region of interest mask
333
+ mask = (
334
+ (LAT >= latlim[0])
335
+ & (LAT <= latlim[1])
336
+ & (LON >= lonlim[0])
337
+ & (LON <= lonlim[1])
338
+ )
339
+ TEMP_profiles = TEMP_profiles[mask]
340
+ PSAL_profiles = PSAL_profiles[mask]
341
+ CT_profiles = CT_profiles[mask]
342
+ SA_profiles = SA_profiles[mask]
343
+ time = time[mask]
344
+ LAT = LAT[mask]
345
+ LON = LON[mask]
346
+
347
+ # Downsample to standard pressures
348
+ CT_standard, SA_standard = downsample_to_sparse(
349
+ CT_profiles, SA_profiles, PRES, pgrid
350
+ )
351
+ # Downsample to standard pressures
352
+ temp_standard, salt_standard = downsample_to_sparse(
353
+ TEMP_profiles, PSAL_profiles, PRES, pgrid
354
+ )
355
+ standard_pressures = pgrid.flatten()
356
+
357
+ # Create ds_standard
358
+ ds_standard = xr.Dataset(
359
+ {
360
+ "CT": ((time_dim, pres_dim), CT_standard),
361
+ "SA": ((time_dim, pres_dim), SA_standard),
362
+ "TEMP": ((time_dim, pres_dim), temp_standard),
363
+ "PSAL": ((time_dim, pres_dim), salt_standard),
364
+ },
365
+ coords={
366
+ time_key: (time_dim, time),
367
+ pres_key: (pres_dim, standard_pressures),
368
+ "LATITUDE": (time_dim, LAT),
369
+ "LONGITUDE": (time_dim, LON),
370
+ },
371
+ )
372
+
373
+ return ds_standard
374
+
375
+
376
+ def calc_ds_difference(ds1, ds2):
377
+ """Compute the variable-by-variable difference between two time-matched datasets."""
378
+ if not np.array_equal(ds1["TIME"].values, ds2["TIME"].values):
379
+ raise ValueError("TIME grids do not match between datasets.") # noqa: TRY003
380
+
381
+ # Variables to exclude from differencing
382
+ exclude_vars = {"YY", "MM", "DD", "HH"}
383
+
384
+ # Prepare a dict for new data variables
385
+ diff_data = {}
386
+
387
+ for var in ds1.data_vars:
388
+ # Only difference variables with dimension TIME and not in exclude list
389
+ if "TIME" in ds1[var].dims and var not in exclude_vars:
390
+ diff_data[var] = ds1[var] - ds2[var]
391
+ else:
392
+ # Copy over variables that are not differenced
393
+ diff_data[var] = ds1[var]
394
+
395
+ # Create a new dataset with the same coordinates and attributes
396
+ ds_diff = ds1.copy()
397
+ for var in diff_data:
398
+ ds_diff[var] = diff_data[var]
399
+
400
+ return ds_diff
@@ -0,0 +1 @@
1
+ """Array-level transport computations across multiple moorings (work in progress)."""