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.
- oceanarray/__init__.py +20 -0
- oceanarray/_version.py +1 -0
- oceanarray/cli.py +1343 -0
- oceanarray/clock_offset.py +675 -0
- oceanarray/config/OS1_sensor_attrs.yaml +49 -0
- oceanarray/config/OS1_var_names.yaml +5 -0
- oceanarray/config/OS1_vocab_attrs.yaml +84 -0
- oceanarray/config/legacy/project_RAPID.yaml +69 -0
- oceanarray/config/legacy/rodb_keys.txt +206 -0
- oceanarray/config/legacy/rodb_keys.yaml +1012 -0
- oceanarray/config/logging.yaml +28 -0
- oceanarray/legacy/__init__.py +47 -0
- oceanarray/legacy/caldip.py +753 -0
- oceanarray/legacy/convertOS.py +527 -0
- oceanarray/legacy/find_deployment.py +489 -0
- oceanarray/legacy/mooring_rodb.py +429 -0
- oceanarray/legacy/process_rodb.py +369 -0
- oceanarray/legacy/rodb.py +330 -0
- oceanarray/logger.py +192 -0
- oceanarray/logsheet/__init__.py +60 -0
- oceanarray/logsheet/_columns.py +97 -0
- oceanarray/logsheet/_config.py +245 -0
- oceanarray/logsheet/_firmware.py +43 -0
- oceanarray/logsheet/_latex.py +128 -0
- oceanarray/logsheet/_render.py +126 -0
- oceanarray/logsheet/builders/__init__.py +13 -0
- oceanarray/logsheet/builders/caldip_download.py +195 -0
- oceanarray/logsheet/builders/caldip_setup.py +188 -0
- oceanarray/logsheet/builders/deployment_setup.py +218 -0
- oceanarray/logsheet/builders/mooring_download.py +243 -0
- oceanarray/logsheet/builders/recovery.py +157 -0
- oceanarray/logsheet/data/column_defs.yaml +686 -0
- oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
- oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
- oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
- oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
- oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
- oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
- oceanarray/mooring_level.py +1368 -0
- oceanarray/oceanarray.mplstyle +18 -0
- oceanarray/parameters.py +270 -0
- oceanarray/plotter.py +942 -0
- oceanarray/plotters/__init__.py +97 -0
- oceanarray/plotters/_animation.py +261 -0
- oceanarray/plotters/_current.py +705 -0
- oceanarray/plotters/_diagnostic.py +667 -0
- oceanarray/plotters/_helpers.py +57 -0
- oceanarray/plotters/_primitives.py +101 -0
- oceanarray/plotters/_section.py +16 -0
- oceanarray/plotters/_timeseries.py +11 -0
- oceanarray/rapid_interp.py +739 -0
- oceanarray/read_rbr_hex.py +541 -0
- oceanarray/readers.py +303 -0
- oceanarray/report/__init__.py +5 -0
- oceanarray/report/_array.py +386 -0
- oceanarray/report/_grid.py +539 -0
- oceanarray/report/_html_helpers.py +1004 -0
- oceanarray/report/_instrument.py +853 -0
- oceanarray/report/_mooring.py +1385 -0
- oceanarray/report/_plots.py +4284 -0
- oceanarray/report/_stack.py +941 -0
- oceanarray/stage1.py +1959 -0
- oceanarray/stage2.py +1160 -0
- oceanarray/stage3.py +2494 -0
- oceanarray/time_gridding.py +958 -0
- oceanarray/tools.py +400 -0
- oceanarray/transports.py +1 -0
- oceanarray/utilities.py +538 -0
- oceanarray/validation.py +389 -0
- oceanarray/writers.py +99 -0
- oceanarray-0.1.0.dist-info/METADATA +503 -0
- oceanarray-0.1.0.dist-info/RECORD +76 -0
- oceanarray-0.1.0.dist-info/WHEEL +5 -0
- oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
- oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
- oceanarray-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
"""Clock offset analysis functions for oceanographic instrument data.
|
|
2
|
+
|
|
3
|
+
This module provides functions to analyze and detect clock offsets between
|
|
4
|
+
different instruments on the same mooring by comparing deployment timing
|
|
5
|
+
and performing lag correlation analysis.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import xarray as xr
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from oceanarray import tools
|
|
16
|
+
from oceanarray.legacy import find_deployment
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_mooring_instruments(mooring_name, base_dir, output_path, file_suffix="_raw"): # noqa: ARG001
|
|
20
|
+
"""Load all instruments for a mooring from netCDF files and enrich with YAML metadata.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
mooring_name : str
|
|
25
|
+
Name of the mooring (e.g., 'dsE_1_2018')
|
|
26
|
+
base_dir : str
|
|
27
|
+
Base directory path
|
|
28
|
+
output_path : str
|
|
29
|
+
Path to processed data directory
|
|
30
|
+
file_suffix : str, optional
|
|
31
|
+
File suffix to use ('_raw' or '_use'), default '_raw'
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
list of xarray.Dataset
|
|
36
|
+
List of datasets for each instrument
|
|
37
|
+
dict
|
|
38
|
+
YAML metadata for the mooring
|
|
39
|
+
|
|
40
|
+
"""
|
|
41
|
+
proc_dir = output_path + mooring_name
|
|
42
|
+
moor_yaml = proc_dir + "/" + mooring_name + ".mooring.yaml"
|
|
43
|
+
|
|
44
|
+
with open(moor_yaml, "r") as f:
|
|
45
|
+
moor_yaml_data = yaml.safe_load(f)
|
|
46
|
+
|
|
47
|
+
datasets = []
|
|
48
|
+
for i in moor_yaml_data["instruments"]:
|
|
49
|
+
fname = mooring_name + "_" + str(i["serial"]) + file_suffix + ".nc"
|
|
50
|
+
rawfile = proc_dir + "/" + i["instrument"] + "/" + fname
|
|
51
|
+
|
|
52
|
+
if os.path.exists(rawfile):
|
|
53
|
+
print(rawfile)
|
|
54
|
+
ds1 = xr.open_dataset(rawfile)
|
|
55
|
+
|
|
56
|
+
# Add metadata from YAML
|
|
57
|
+
if "InstrDepth" not in ds1.variables and "depth" in i:
|
|
58
|
+
ds1["InstrDepth"] = i["depth"]
|
|
59
|
+
if "instrument" not in ds1.variables and "instrument" in i:
|
|
60
|
+
ds1["instrument"] = i["instrument"]
|
|
61
|
+
if "serial_number" not in ds1.variables and "serial" in i:
|
|
62
|
+
ds1["serial_number"] = i["serial"]
|
|
63
|
+
if "timeS" in ds1.variables:
|
|
64
|
+
ds1 = ds1.drop_vars("timeS")
|
|
65
|
+
|
|
66
|
+
datasets.append(ds1)
|
|
67
|
+
|
|
68
|
+
return datasets, moor_yaml_data
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def create_common_time_grid(datasets):
|
|
72
|
+
"""Create a common time grid for interpolation based on all datasets.
|
|
73
|
+
|
|
74
|
+
Parameters
|
|
75
|
+
----------
|
|
76
|
+
datasets : list of xarray.Dataset
|
|
77
|
+
List of instrument datasets
|
|
78
|
+
|
|
79
|
+
Returns
|
|
80
|
+
-------
|
|
81
|
+
numpy.ndarray
|
|
82
|
+
Common time grid as datetime64 array
|
|
83
|
+
|
|
84
|
+
"""
|
|
85
|
+
intervals_min = []
|
|
86
|
+
start_times = []
|
|
87
|
+
end_times = []
|
|
88
|
+
|
|
89
|
+
for ds in datasets:
|
|
90
|
+
time = ds["time"]
|
|
91
|
+
time_interval = np.nanmedian(np.diff(time.values) / np.timedelta64(1, "m"))
|
|
92
|
+
intervals_min.append(time_interval)
|
|
93
|
+
start_times.append(time.values[0])
|
|
94
|
+
end_times.append(time.values[-1])
|
|
95
|
+
|
|
96
|
+
earliest_start = pd.to_datetime(start_times).min().to_datetime64()
|
|
97
|
+
|
|
98
|
+
end_arr = np.array(end_times, dtype="datetime64[ns]")
|
|
99
|
+
mask = ~np.isnat(end_arr)
|
|
100
|
+
if mask.any():
|
|
101
|
+
med_ns = np.median(end_arr[mask].astype("int64"))
|
|
102
|
+
latest_end = np.datetime64(int(med_ns), "ns")
|
|
103
|
+
else:
|
|
104
|
+
latest_end = np.datetime64("NaT", "ns")
|
|
105
|
+
|
|
106
|
+
dt_sec = int(np.nanmedian(intervals_min) * 60)
|
|
107
|
+
time_grid = np.arange(earliest_start, latest_end, np.timedelta64(dt_sec, "s"))
|
|
108
|
+
|
|
109
|
+
return time_grid
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def interpolate_datasets_to_grid(datasets, time_grid):
|
|
113
|
+
"""Interpolate all datasets to a common time grid.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
datasets : list of xarray.Dataset
|
|
118
|
+
List of instrument datasets
|
|
119
|
+
time_grid : numpy.ndarray
|
|
120
|
+
Common time grid for interpolation
|
|
121
|
+
|
|
122
|
+
Returns
|
|
123
|
+
-------
|
|
124
|
+
list of xarray.Dataset
|
|
125
|
+
List of interpolated datasets
|
|
126
|
+
|
|
127
|
+
"""
|
|
128
|
+
datasets_interp = []
|
|
129
|
+
|
|
130
|
+
for idx, ds in enumerate(datasets):
|
|
131
|
+
if "time" not in ds.sizes:
|
|
132
|
+
print(
|
|
133
|
+
f" WARNING: Dataset {idx} has no time dimension, skipping interpolation"
|
|
134
|
+
)
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
if ds.sizes["time"] <= 1:
|
|
138
|
+
print(
|
|
139
|
+
f" WARNING: Dataset {idx} has only {ds.sizes['time']} time point(s), skipping interpolation"
|
|
140
|
+
)
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
interp_vars = {}
|
|
145
|
+
for var in ds.data_vars:
|
|
146
|
+
if "time" in ds[var].dims:
|
|
147
|
+
interp_vars[var] = ds[var].interp(time=time_grid)
|
|
148
|
+
else:
|
|
149
|
+
interp_vars[var] = ds[var]
|
|
150
|
+
|
|
151
|
+
if interp_vars:
|
|
152
|
+
ds_interp = xr.Dataset(interp_vars, coords={"time": time_grid})
|
|
153
|
+
|
|
154
|
+
if "InstrDepth" in ds:
|
|
155
|
+
ds_interp = ds_interp.assign_coords(depth=ds["InstrDepth"])
|
|
156
|
+
if "clock_offset" in ds:
|
|
157
|
+
ds_interp = ds_interp.assign_coords(
|
|
158
|
+
seconds_offset=ds["clock_offset"]
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
datasets_interp.append(ds_interp)
|
|
162
|
+
else:
|
|
163
|
+
print(f" No time-dependent variables found in dataset {idx}")
|
|
164
|
+
|
|
165
|
+
except Exception as e: # noqa: BLE001 — log and continue; one bad dataset must not abort mooring
|
|
166
|
+
print(f" ERROR interpolating dataset {idx}: {e}")
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
return datasets_interp
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def combine_interpolated_datasets(datasets_interp):
|
|
173
|
+
"""Combine interpolated datasets into a single multi-level dataset.
|
|
174
|
+
|
|
175
|
+
Parameters
|
|
176
|
+
----------
|
|
177
|
+
datasets_interp : list of xarray.Dataset
|
|
178
|
+
List of interpolated datasets
|
|
179
|
+
|
|
180
|
+
Returns
|
|
181
|
+
-------
|
|
182
|
+
xarray.Dataset
|
|
183
|
+
Combined dataset with N_LEVELS dimension
|
|
184
|
+
|
|
185
|
+
"""
|
|
186
|
+
vars_to_keep = [
|
|
187
|
+
"temperature",
|
|
188
|
+
"salinity",
|
|
189
|
+
"conductivity",
|
|
190
|
+
"pressure",
|
|
191
|
+
"u_velocity",
|
|
192
|
+
"v_velocity",
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
datasets_clean = []
|
|
196
|
+
for ds in datasets_interp:
|
|
197
|
+
ds_sel = ds.drop_vars(
|
|
198
|
+
["density", "potential_temperature", "julian_days_offset", "timeS"],
|
|
199
|
+
errors="ignore",
|
|
200
|
+
)
|
|
201
|
+
datasets_clean.append(ds_sel)
|
|
202
|
+
|
|
203
|
+
time_coord = datasets_interp[0]["time"]
|
|
204
|
+
combined_data = {}
|
|
205
|
+
N_LEVELS = len(datasets_clean)
|
|
206
|
+
|
|
207
|
+
for var in vars_to_keep:
|
|
208
|
+
arrs = []
|
|
209
|
+
for ds in datasets_clean:
|
|
210
|
+
if var in ds:
|
|
211
|
+
arrs.append(ds[var].values)
|
|
212
|
+
else:
|
|
213
|
+
arrs.append(np.full(time_coord.shape, np.nan))
|
|
214
|
+
combined_data[var] = (("time", "N_LEVELS"), np.stack(arrs, axis=-1))
|
|
215
|
+
|
|
216
|
+
# Gather metadata for each level
|
|
217
|
+
depths = []
|
|
218
|
+
clock_offsets = []
|
|
219
|
+
serial = []
|
|
220
|
+
instrtype = []
|
|
221
|
+
|
|
222
|
+
for ds in datasets_clean:
|
|
223
|
+
depths.append(float(ds["InstrDepth"].item()) if "InstrDepth" in ds else np.nan)
|
|
224
|
+
serial.append(ds["serial_number"].item() if "serial_number" in ds else np.nan)
|
|
225
|
+
instrtype.append(ds["instrument"].item() if "instrument" in ds else "unknown")
|
|
226
|
+
|
|
227
|
+
if "clock_offset" in ds:
|
|
228
|
+
co = ds["clock_offset"].item()
|
|
229
|
+
elif "seconds_offset" in ds:
|
|
230
|
+
co = ds["seconds_offset"].item()
|
|
231
|
+
else:
|
|
232
|
+
co = 0
|
|
233
|
+
clock_offsets.append(int(np.rint(co)) if np.isfinite(co) else 0)
|
|
234
|
+
|
|
235
|
+
combined_ds = xr.Dataset(
|
|
236
|
+
data_vars=combined_data,
|
|
237
|
+
coords={
|
|
238
|
+
"time": time_coord,
|
|
239
|
+
"N_LEVELS": np.arange(N_LEVELS),
|
|
240
|
+
"clock_offset": ("N_LEVELS", np.array(clock_offsets)),
|
|
241
|
+
"serial_number": ("N_LEVELS", np.array(serial)),
|
|
242
|
+
"nominal_depth": ("N_LEVELS", np.array(depths)),
|
|
243
|
+
"instrument": ("N_LEVELS", np.asarray(instrtype)),
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return combined_ds
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def analyze_deployment_timing(combined_ds):
|
|
251
|
+
"""Analyze deployment timing using temperature-based detection of deployment periods.
|
|
252
|
+
|
|
253
|
+
Parameters
|
|
254
|
+
----------
|
|
255
|
+
combined_ds : xarray.Dataset
|
|
256
|
+
Combined dataset with all instruments
|
|
257
|
+
|
|
258
|
+
Returns
|
|
259
|
+
-------
|
|
260
|
+
xarray.Dataset
|
|
261
|
+
Dataset enriched with deployment timing information
|
|
262
|
+
|
|
263
|
+
"""
|
|
264
|
+
return find_deployment.find_deployment(
|
|
265
|
+
combined_ds, bottom_strategy="deployment_bounds"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def calculate_timing_offsets(combined_ds, bin_width_sec=60):
|
|
270
|
+
"""Calculate timing offsets between instruments based on deployment start/end times.
|
|
271
|
+
|
|
272
|
+
Parameters
|
|
273
|
+
----------
|
|
274
|
+
combined_ds : xarray.Dataset
|
|
275
|
+
Dataset with deployment timing information
|
|
276
|
+
bin_width_sec : float, optional
|
|
277
|
+
Bin width in seconds for consensus grouping, default 60
|
|
278
|
+
|
|
279
|
+
Returns
|
|
280
|
+
-------
|
|
281
|
+
dict
|
|
282
|
+
Dictionary containing offset analysis results
|
|
283
|
+
|
|
284
|
+
"""
|
|
285
|
+
start_times = pd.to_datetime(combined_ds["start_time"].values)
|
|
286
|
+
end_times = pd.to_datetime(combined_ds["end_time"].values)
|
|
287
|
+
|
|
288
|
+
f_start = np.isfinite(start_times)
|
|
289
|
+
f_end = np.isfinite(end_times)
|
|
290
|
+
|
|
291
|
+
# Initial reference times for clustering
|
|
292
|
+
ref_start0 = start_times[f_start].min()
|
|
293
|
+
ref_end0 = end_times[f_end].max()
|
|
294
|
+
|
|
295
|
+
start_off0 = np.full(start_times.shape, np.nan, float)
|
|
296
|
+
end_off0 = np.full(end_times.shape, np.nan, float)
|
|
297
|
+
start_off0[f_start] = (start_times[f_start] - ref_start0) / np.timedelta64(1, "s")
|
|
298
|
+
end_off0[f_end] = (end_times[f_end] - ref_end0) / np.timedelta64(1, "s")
|
|
299
|
+
|
|
300
|
+
# Find consensus group
|
|
301
|
+
vals = start_off0[np.isfinite(start_off0)]
|
|
302
|
+
if vals.size == 0:
|
|
303
|
+
raise RuntimeError("No finite start offsets to form consensus.") # noqa: TRY003
|
|
304
|
+
|
|
305
|
+
vmin, vmax = vals.min(), vals.max()
|
|
306
|
+
bins = np.arange(vmin - bin_width_sec, vmax + 2 * bin_width_sec, bin_width_sec)
|
|
307
|
+
hist, edges = np.histogram(vals, bins=bins)
|
|
308
|
+
k = np.argmax(hist)
|
|
309
|
+
lo, hi = edges[k], edges[k + 1]
|
|
310
|
+
in_consensus = (start_off0 >= lo) & (start_off0 < hi)
|
|
311
|
+
|
|
312
|
+
idx_consensus = np.where(in_consensus & f_start & f_end)[0]
|
|
313
|
+
if idx_consensus.size == 0:
|
|
314
|
+
idx_consensus = np.where(in_consensus & f_start)[0]
|
|
315
|
+
|
|
316
|
+
# Redefine references from consensus group
|
|
317
|
+
ref_start = start_times[idx_consensus].min()
|
|
318
|
+
ref_end = end_times[idx_consensus].max()
|
|
319
|
+
|
|
320
|
+
# Recompute offsets
|
|
321
|
+
start_off = (start_times - ref_start) / np.timedelta64(1, "s")
|
|
322
|
+
end_off = (end_times - ref_end) / np.timedelta64(1, "s")
|
|
323
|
+
avg_off = (start_off + end_off) / 2.0
|
|
324
|
+
diff_off = start_off - end_off
|
|
325
|
+
|
|
326
|
+
# Calculate drift rates
|
|
327
|
+
dur = (end_times - start_times) / np.timedelta64(1, "s")
|
|
328
|
+
drift_rate_per_day = np.full_like(avg_off, np.nan, dtype=float)
|
|
329
|
+
ok = np.isfinite(start_off) & np.isfinite(end_off) & np.isfinite(dur) & (dur > 0)
|
|
330
|
+
drift_rate_per_day[ok] = (end_off[ok] - start_off[ok]) / dur[ok] * 86400.0
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
"start_offsets": start_off,
|
|
334
|
+
"end_offsets": end_off,
|
|
335
|
+
"avg_offsets": avg_off,
|
|
336
|
+
"diff_offsets": diff_off,
|
|
337
|
+
"drift_rates": drift_rate_per_day,
|
|
338
|
+
"consensus_indices": idx_consensus,
|
|
339
|
+
"ref_start": ref_start,
|
|
340
|
+
"ref_end": ref_end,
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def perform_lag_correlation_analysis(combined_ds, ref_index=0, sub_sample=5):
|
|
345
|
+
"""Perform lag correlation analysis between instruments to detect clock offsets.
|
|
346
|
+
|
|
347
|
+
Parameters
|
|
348
|
+
----------
|
|
349
|
+
combined_ds : xarray.Dataset
|
|
350
|
+
Combined dataset with all instruments
|
|
351
|
+
ref_index : int, optional
|
|
352
|
+
Index of reference instrument, default 0
|
|
353
|
+
sub_sample : int, optional
|
|
354
|
+
Subsampling factor to speed up correlation, default 5
|
|
355
|
+
|
|
356
|
+
Returns
|
|
357
|
+
-------
|
|
358
|
+
dict
|
|
359
|
+
Dictionary containing correlation analysis results
|
|
360
|
+
|
|
361
|
+
"""
|
|
362
|
+
time_interval = np.nanmedian(
|
|
363
|
+
np.diff(combined_ds["time"].values) / np.timedelta64(1, "s")
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
ref_temp_sub = combined_ds["temperature"][:, ref_index].values[::sub_sample]
|
|
367
|
+
n_sub = len(ref_temp_sub)
|
|
368
|
+
|
|
369
|
+
max_lag_sub = n_sub // 5
|
|
370
|
+
lags_sub = np.arange(-max_lag_sub, max_lag_sub + 1)
|
|
371
|
+
|
|
372
|
+
results = {
|
|
373
|
+
"ref_index": ref_index,
|
|
374
|
+
"sub_sample": sub_sample,
|
|
375
|
+
"time_interval": time_interval,
|
|
376
|
+
"lags": [],
|
|
377
|
+
"correlations": [],
|
|
378
|
+
"max_correlations": [],
|
|
379
|
+
"clock_offsets": [],
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
N_LEVELS = combined_ds.sizes["N_LEVELS"]
|
|
383
|
+
|
|
384
|
+
for i in range(N_LEVELS):
|
|
385
|
+
temp_i_sub = combined_ds["temperature"][:, i].values[::sub_sample]
|
|
386
|
+
coff = combined_ds["clock_offset"][i].values
|
|
387
|
+
|
|
388
|
+
corrs_sub = tools.lag_correlation(ref_temp_sub, temp_i_sub, max_lag_sub)
|
|
389
|
+
|
|
390
|
+
max_corr_idx = np.nanargmax(corrs_sub)
|
|
391
|
+
max_corr = corrs_sub[max_corr_idx]
|
|
392
|
+
max_lag = lags_sub[max_corr_idx]
|
|
393
|
+
dt_sub = sub_sample * time_interval
|
|
394
|
+
clock_offset_total = max_lag * dt_sub + coff
|
|
395
|
+
|
|
396
|
+
results["lags"].append(max_lag)
|
|
397
|
+
results["correlations"].append(corrs_sub)
|
|
398
|
+
results["max_correlations"].append(max_corr)
|
|
399
|
+
results["clock_offsets"].append(clock_offset_total)
|
|
400
|
+
|
|
401
|
+
return results
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def print_timing_offset_summary(combined_ds, offset_results):
|
|
405
|
+
"""Print a summary table of timing offsets for all instruments.
|
|
406
|
+
|
|
407
|
+
Parameters
|
|
408
|
+
----------
|
|
409
|
+
combined_ds : xarray.Dataset
|
|
410
|
+
Combined dataset with instrument metadata
|
|
411
|
+
offset_results : dict
|
|
412
|
+
Results from calculate_timing_offsets()
|
|
413
|
+
|
|
414
|
+
"""
|
|
415
|
+
N = combined_ds.sizes["N_LEVELS"]
|
|
416
|
+
labels = combined_ds["instrument"].values
|
|
417
|
+
serial = combined_ds["serial_number"].values
|
|
418
|
+
|
|
419
|
+
start_times = pd.to_datetime(combined_ds["start_time"].values)
|
|
420
|
+
end_times = pd.to_datetime(combined_ds["end_time"].values)
|
|
421
|
+
|
|
422
|
+
print(f"Consensus group size: {offset_results['consensus_indices'].size}")
|
|
423
|
+
print(
|
|
424
|
+
f"Consensus-derived refs -> ref_start={offset_results['ref_start']}, ref_end={offset_results['ref_end']}\n"
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
for i in range(N):
|
|
428
|
+
tag = "REF" if i in offset_results["consensus_indices"] else "-"
|
|
429
|
+
s = offset_results["start_offsets"][i]
|
|
430
|
+
e = offset_results["end_offsets"][i]
|
|
431
|
+
a = offset_results["avg_offsets"][i]
|
|
432
|
+
d = offset_results["diff_offsets"][i]
|
|
433
|
+
dr = offset_results["drift_rates"][i]
|
|
434
|
+
|
|
435
|
+
print(
|
|
436
|
+
f"{i:02d}: {str(labels[i]):8s}/{str(serial[i]):6s} | "
|
|
437
|
+
f"start={pd.to_datetime(start_times[i])} ({s:+8.0f}s) | "
|
|
438
|
+
f"end={pd.to_datetime(end_times[i])} ({e:+8.0f}s) | "
|
|
439
|
+
f"avg={a:+8.0f}s | diff={d:+6.0f}s | drift={'nan' if not np.isfinite(dr) else f'{dr:+.2f} s/day'} | {tag}"
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def suggest_reference_instrument(combined_ds, offset_results):
|
|
444
|
+
"""Suggest the best reference instrument for lag correlation analysis.
|
|
445
|
+
|
|
446
|
+
This function recommends the instrument with the smallest average timing offset
|
|
447
|
+
from the temperature threshold method, making it the most likely to have
|
|
448
|
+
accurate timing for use as a correlation reference.
|
|
449
|
+
|
|
450
|
+
Parameters
|
|
451
|
+
----------
|
|
452
|
+
combined_ds : xarray.Dataset
|
|
453
|
+
Combined dataset with instrument metadata
|
|
454
|
+
offset_results : dict
|
|
455
|
+
Results from calculate_timing_offsets()
|
|
456
|
+
|
|
457
|
+
Returns
|
|
458
|
+
-------
|
|
459
|
+
dict
|
|
460
|
+
Dictionary with recommendation details including suggested index
|
|
461
|
+
|
|
462
|
+
"""
|
|
463
|
+
avg_offsets = offset_results["avg_offsets"]
|
|
464
|
+
abs_offsets = np.abs(avg_offsets)
|
|
465
|
+
|
|
466
|
+
# Find instruments with finite offsets
|
|
467
|
+
finite_mask = np.isfinite(abs_offsets)
|
|
468
|
+
if not finite_mask.any():
|
|
469
|
+
print("Warning: No instruments have finite timing offsets")
|
|
470
|
+
return {"suggested_index": 0, "reason": "fallback - no finite offsets"}
|
|
471
|
+
|
|
472
|
+
# Find the instrument with smallest absolute average offset
|
|
473
|
+
finite_indices = np.where(finite_mask)[0]
|
|
474
|
+
min_abs_idx = finite_indices[np.argmin(abs_offsets[finite_mask])]
|
|
475
|
+
|
|
476
|
+
instruments = combined_ds["instrument"].values
|
|
477
|
+
serial = combined_ds["serial_number"].values
|
|
478
|
+
depths = combined_ds["nominal_depth"].values
|
|
479
|
+
|
|
480
|
+
suggestion = {
|
|
481
|
+
"suggested_index": min_abs_idx,
|
|
482
|
+
"offset_seconds": avg_offsets[min_abs_idx],
|
|
483
|
+
"abs_offset_seconds": abs_offsets[min_abs_idx],
|
|
484
|
+
"instrument": instruments[min_abs_idx],
|
|
485
|
+
"serial": serial[min_abs_idx],
|
|
486
|
+
"depth": depths[min_abs_idx],
|
|
487
|
+
"reason": "smallest absolute average timing offset",
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
print("Suggested reference instrument for lag correlation:")
|
|
491
|
+
print(
|
|
492
|
+
f" Index {min_abs_idx}: {instruments[min_abs_idx]} #{serial[min_abs_idx]} at {depths[min_abs_idx]:.0f}m"
|
|
493
|
+
)
|
|
494
|
+
print(f" Average timing offset: {avg_offsets[min_abs_idx]:+.1f}s")
|
|
495
|
+
print(f" Reason: {suggestion['reason']}")
|
|
496
|
+
print()
|
|
497
|
+
|
|
498
|
+
return suggestion
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def print_correlation_summary(combined_ds, correlation_results):
|
|
502
|
+
"""Print a summary of lag correlation analysis results.
|
|
503
|
+
|
|
504
|
+
Parameters
|
|
505
|
+
----------
|
|
506
|
+
combined_ds : xarray.Dataset
|
|
507
|
+
Combined dataset with instrument metadata
|
|
508
|
+
correlation_results : dict
|
|
509
|
+
Results from perform_lag_correlation_analysis()
|
|
510
|
+
|
|
511
|
+
"""
|
|
512
|
+
serial = combined_ds["serial_number"].values
|
|
513
|
+
|
|
514
|
+
print("Lag Correlation Analysis Results:")
|
|
515
|
+
print("(Enter the summed value, no sign change, in the yaml as clock_offset)")
|
|
516
|
+
print()
|
|
517
|
+
|
|
518
|
+
for i, (lag, corr, offset) in enumerate(
|
|
519
|
+
zip(
|
|
520
|
+
correlation_results["lags"],
|
|
521
|
+
correlation_results["max_correlations"],
|
|
522
|
+
correlation_results["clock_offsets"],
|
|
523
|
+
)
|
|
524
|
+
):
|
|
525
|
+
print(
|
|
526
|
+
f"Level {i + 1} (#{serial[i]}): max corr = {corr:.3f} @lag {lag} --> clock_offset: {offset:.0f}s"
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def plot_deployment_boundaries(
|
|
531
|
+
original_datasets, combined_ds, n_samples=10, figsize=(12, 4)
|
|
532
|
+
):
|
|
533
|
+
"""Plot detailed view of deployment boundaries showing individual measurements.
|
|
534
|
+
|
|
535
|
+
This function creates plots showing the exact transition points where instruments
|
|
536
|
+
move from surface to deployment conditions, with individual data points highlighted
|
|
537
|
+
around the predicted start and end times.
|
|
538
|
+
|
|
539
|
+
Parameters
|
|
540
|
+
----------
|
|
541
|
+
original_datasets : list of xarray.Dataset
|
|
542
|
+
Original (non-interpolated) datasets for each instrument
|
|
543
|
+
combined_ds : xarray.Dataset
|
|
544
|
+
Combined dataset with deployment timing information
|
|
545
|
+
n_samples : int, optional
|
|
546
|
+
Number of samples to show before/after predicted boundaries, default 10
|
|
547
|
+
figsize : tuple, optional
|
|
548
|
+
Figure size for each plot, default (12, 4)
|
|
549
|
+
|
|
550
|
+
"""
|
|
551
|
+
import matplotlib.pyplot as plt
|
|
552
|
+
|
|
553
|
+
start_times = combined_ds["start_time"].values
|
|
554
|
+
end_times = combined_ds["end_time"].values
|
|
555
|
+
split_vals = combined_ds["split_value"].values
|
|
556
|
+
instruments = combined_ds["instrument"].values
|
|
557
|
+
depths = combined_ds["nominal_depth"].values
|
|
558
|
+
|
|
559
|
+
for i, ds in enumerate(original_datasets):
|
|
560
|
+
if i >= len(start_times):
|
|
561
|
+
break
|
|
562
|
+
|
|
563
|
+
if "temperature" not in ds.data_vars:
|
|
564
|
+
print(f"Skipping instrument {i}: no temperature data")
|
|
565
|
+
continue
|
|
566
|
+
|
|
567
|
+
time_orig = ds["time"].values
|
|
568
|
+
temp_orig = ds["temperature"].values
|
|
569
|
+
|
|
570
|
+
# Convert deployment times to numpy datetime64 for comparison
|
|
571
|
+
start_time = start_times[i]
|
|
572
|
+
end_time = end_times[i]
|
|
573
|
+
split_val = split_vals[i]
|
|
574
|
+
|
|
575
|
+
# Find indices closest to start and end times
|
|
576
|
+
start_idx = None
|
|
577
|
+
end_idx = None
|
|
578
|
+
|
|
579
|
+
if np.isfinite(start_time.astype("datetime64[ns]").astype("int64")):
|
|
580
|
+
start_idx = np.argmin(np.abs(time_orig - start_time))
|
|
581
|
+
|
|
582
|
+
if np.isfinite(end_time.astype("datetime64[ns]").astype("int64")):
|
|
583
|
+
end_idx = np.argmin(np.abs(time_orig - end_time))
|
|
584
|
+
|
|
585
|
+
# Create subplots for start and end (if both exist)
|
|
586
|
+
if start_idx is not None and end_idx is not None:
|
|
587
|
+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(figsize[0] * 2, figsize[1]))
|
|
588
|
+
axes = [ax1, ax2]
|
|
589
|
+
titles = ["Deployment Start", "Deployment End"]
|
|
590
|
+
indices = [start_idx, end_idx]
|
|
591
|
+
times_ref = [start_time, end_time]
|
|
592
|
+
elif start_idx is not None:
|
|
593
|
+
fig, ax1 = plt.subplots(1, 1, figsize=figsize)
|
|
594
|
+
axes = [ax1]
|
|
595
|
+
titles = ["Deployment Start"]
|
|
596
|
+
indices = [start_idx]
|
|
597
|
+
times_ref = [start_time]
|
|
598
|
+
elif end_idx is not None:
|
|
599
|
+
fig, ax1 = plt.subplots(1, 1, figsize=figsize)
|
|
600
|
+
axes = [ax1]
|
|
601
|
+
titles = ["Deployment End"]
|
|
602
|
+
indices = [end_idx]
|
|
603
|
+
times_ref = [end_time]
|
|
604
|
+
else:
|
|
605
|
+
print(f"Skipping instrument {i}: no valid deployment times")
|
|
606
|
+
continue
|
|
607
|
+
|
|
608
|
+
# Plot each boundary
|
|
609
|
+
for ax, title, idx, time_ref in zip(axes, titles, indices, times_ref):
|
|
610
|
+
# Define range around the boundary
|
|
611
|
+
start_range = max(0, idx - n_samples)
|
|
612
|
+
end_range = min(len(time_orig), idx + n_samples + 1)
|
|
613
|
+
|
|
614
|
+
time_window = time_orig[start_range:end_range]
|
|
615
|
+
temp_window = temp_orig[start_range:end_range]
|
|
616
|
+
|
|
617
|
+
# Plot connecting line first (so points appear on top)
|
|
618
|
+
ax.plot(
|
|
619
|
+
time_window,
|
|
620
|
+
temp_window,
|
|
621
|
+
"b-",
|
|
622
|
+
linewidth=1.5,
|
|
623
|
+
alpha=0.8,
|
|
624
|
+
label="Temperature",
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
# Plot individual measurements as filled red circles
|
|
628
|
+
ax.plot(
|
|
629
|
+
time_window,
|
|
630
|
+
temp_window,
|
|
631
|
+
"ro",
|
|
632
|
+
markersize=6,
|
|
633
|
+
markerfacecolor="red",
|
|
634
|
+
markeredgecolor="darkred",
|
|
635
|
+
markeredgewidth=1,
|
|
636
|
+
label="Measurements",
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
# Highlight the exact predicted boundary time
|
|
640
|
+
ax.axvline(
|
|
641
|
+
time_ref,
|
|
642
|
+
color="green",
|
|
643
|
+
linestyle="--",
|
|
644
|
+
linewidth=2,
|
|
645
|
+
alpha=0.8,
|
|
646
|
+
label=f"Predicted {title.split()[1]}",
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
# Add horizontal line for split value
|
|
650
|
+
ax.axhline(
|
|
651
|
+
split_val,
|
|
652
|
+
color="orange",
|
|
653
|
+
linestyle=":",
|
|
654
|
+
linewidth=1.5,
|
|
655
|
+
alpha=0.7,
|
|
656
|
+
label=f"Split Value ({split_val:.2f}°C)",
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
ax.set_xlabel("Time")
|
|
660
|
+
ax.set_ylabel("Temperature (°C)")
|
|
661
|
+
ax.set_title(f"{title} - {instruments[i]} at {depths[i]:.0f}m")
|
|
662
|
+
ax.legend(loc="best")
|
|
663
|
+
ax.grid(True, alpha=0.3)
|
|
664
|
+
|
|
665
|
+
# Format x-axis for better readability
|
|
666
|
+
import matplotlib.dates as mdates
|
|
667
|
+
|
|
668
|
+
ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S"))
|
|
669
|
+
ax.xaxis.set_major_locator(
|
|
670
|
+
mdates.MinuteLocator(interval=max(1, n_samples // 5))
|
|
671
|
+
)
|
|
672
|
+
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45)
|
|
673
|
+
|
|
674
|
+
plt.tight_layout()
|
|
675
|
+
plt.show()
|