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,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()