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,753 @@
1
+ """
2
+ Calibration dip analysis for oceanographic instrument data.
3
+
4
+ This module provides functions to compare MicroCAT sensor data with shipboard
5
+ CTD data during calibration casts to identify sensor biases and drifts.
6
+ The workflow follows procedures from matlab_calibration scripts but implements
7
+ them in Python using xarray and modern oceanographic data formats.
8
+
9
+ Key functionality:
10
+ - Water impact detection for time synchronization
11
+ - Bottle stop identification and data extraction
12
+ - Sensor bias calculation (temperature, conductivity, pressure)
13
+ - Conductivity pressure correction for sensors without pressure
14
+ - Visualization of calibration results
15
+
16
+ Based on MATLAB calibration workflow from:
17
+ - cp_mc_bot.m, cp_mtd_bot.m
18
+ - insitu_cal.m, microcat_apply_cal_plus.m
19
+ - read_botfile.m
20
+ """
21
+
22
+ import warnings
23
+ from pathlib import Path
24
+ from typing import Dict, List, Optional, Tuple, Union
25
+
26
+ import matplotlib.pyplot as plt
27
+ import numpy as np
28
+ import pandas as pd
29
+ import xarray as xr
30
+ from scipy import signal
31
+ from scipy.interpolate import interp1d
32
+
33
+ from oceanarray import tools
34
+
35
+
36
+ def detect_water_impact(
37
+ data: xr.Dataset,
38
+ variable: str = "conductivity",
39
+ threshold: float = 30.0,
40
+ method: str = "threshold",
41
+ ) -> pd.Timestamp:
42
+ """
43
+ Detect water impact time for instrument synchronization.
44
+
45
+ Parameters
46
+ ----------
47
+ data : xarray.Dataset
48
+ Instrument data with time coordinate
49
+ variable : str, optional
50
+ Variable to use for impact detection ('conductivity' or 'pressure')
51
+ Default is 'conductivity'
52
+ threshold : float, optional
53
+ Threshold value for impact detection
54
+ Default 30.0 (mS/cm for conductivity, dbar for pressure)
55
+ method : str, optional
56
+ Detection method ('threshold' or 'gradient')
57
+ Default is 'threshold'
58
+
59
+ Returns
60
+ -------
61
+ pandas.Timestamp
62
+ Time of water impact
63
+
64
+ Notes
65
+ -----
66
+ For conductivity: detects when C > threshold
67
+ For pressure: detects when P > threshold
68
+ Gradient method uses first derivative to detect rapid change
69
+ """
70
+ if variable not in data.variables:
71
+ raise ValueError(f"Variable '{variable}' not found in dataset")
72
+
73
+ var_data = data[variable].dropna(dim="time")
74
+ if len(var_data) == 0:
75
+ raise ValueError(f"No valid data found for variable '{variable}'")
76
+
77
+ if method == "threshold":
78
+ # Find first point above threshold
79
+ impact_mask = var_data > threshold
80
+ impact_indices = np.where(impact_mask)[0]
81
+
82
+ if len(impact_indices) == 0:
83
+ raise ValueError(f"No data points above threshold {threshold}")
84
+
85
+ impact_idx = impact_indices[0]
86
+
87
+ elif method == "gradient":
88
+ # Use gradient to detect rapid change
89
+ gradient = np.gradient(var_data.values)
90
+ # Find maximum gradient (steepest increase)
91
+ impact_idx = np.argmax(gradient)
92
+
93
+ else:
94
+ raise ValueError("Method must be 'threshold' or 'gradient'")
95
+
96
+ impact_time = var_data.time.isel(time=impact_idx).values
97
+ return pd.Timestamp(impact_time)
98
+
99
+
100
+ def identify_bottle_stops(
101
+ ctd_data: xr.Dataset,
102
+ bottle_file: Optional[Union[str, Path]] = None,
103
+ bottle_data: Optional[pd.DataFrame] = None,
104
+ pressure_var: str = "pressure",
105
+ min_duration: float = 300.0,
106
+ max_pressure_diff: float = 5.0,
107
+ ) -> pd.DataFrame:
108
+ """
109
+ Identify bottle stop periods from CTD pressure data or bottle file.
110
+
111
+ Parameters
112
+ ----------
113
+ ctd_data : xarray.Dataset
114
+ CTD data with time and pressure
115
+ bottle_file : str or Path, optional
116
+ Path to bottle file (SeaBird .btl format or NetCDF)
117
+ bottle_data : pandas.DataFrame, optional
118
+ Pre-loaded bottle stop data
119
+ pressure_var : str, optional
120
+ Name of pressure variable in CTD data, default 'pressure'
121
+ min_duration : float, optional
122
+ Minimum bottle stop duration in seconds, default 300.0 (5 minutes)
123
+ max_pressure_diff : float, optional
124
+ Maximum pressure difference to consider as stable stop, default 5.0 dbar
125
+
126
+ Returns
127
+ -------
128
+ pandas.DataFrame
129
+ DataFrame with columns: 'start_time', 'end_time', 'pressure', 'duration'
130
+
131
+ Notes
132
+ -----
133
+ If bottle_file is provided, reads bottle stop times from file.
134
+ Otherwise, automatically detects stops from pressure stability.
135
+ Extended bottle stops (5-10 minutes) are expected for calibration dips.
136
+ """
137
+ if bottle_data is not None:
138
+ # Use provided bottle data
139
+ stops = bottle_data.copy()
140
+ elif bottle_file is not None:
141
+ # Read bottle file
142
+ stops = _read_bottle_file(bottle_file)
143
+ else:
144
+ # Auto-detect from pressure data
145
+ stops = _detect_pressure_stops(
146
+ ctd_data, pressure_var, min_duration, max_pressure_diff
147
+ )
148
+
149
+ # Filter for minimum duration (calibration dips should be long)
150
+ stops = stops[stops["duration"] >= min_duration].copy()
151
+
152
+ # Sort by time
153
+ stops = stops.sort_values("start_time").reset_index(drop=True)
154
+
155
+ return stops
156
+
157
+
158
+ def _read_bottle_file(bottle_file: Union[str, Path]) -> pd.DataFrame:
159
+ """Read bottle stop data from file."""
160
+ bottle_file = Path(bottle_file)
161
+
162
+ if bottle_file.suffix.lower() == ".nc":
163
+ # NetCDF format
164
+ bottle_ds = xr.open_dataset(bottle_file)
165
+ # Convert to DataFrame - implementation depends on NetCDF structure
166
+ # This is a placeholder - actual implementation would depend on file format
167
+ raise NotImplementedError("NetCDF bottle file reading not yet implemented")
168
+
169
+ elif bottle_file.suffix.lower() in [".btl", ".ros"]:
170
+ # SeaBird bottle file format
171
+ # This would require parsing SeaBird text format
172
+ # For now, suggest using seabird-scientific package or manual conversion
173
+ raise NotImplementedError(
174
+ "SeaBird bottle file reading not yet implemented. "
175
+ "Consider using seabird-scientific package or convert to NetCDF"
176
+ )
177
+ else:
178
+ raise ValueError(f"Unsupported bottle file format: {bottle_file.suffix}")
179
+
180
+
181
+ def _detect_pressure_stops(
182
+ ctd_data: xr.Dataset,
183
+ pressure_var: str,
184
+ min_duration: float,
185
+ max_pressure_diff: float,
186
+ ) -> pd.DataFrame:
187
+ """Auto-detect bottle stops from pressure stability."""
188
+ pressure = ctd_data[pressure_var].dropna(dim="time")
189
+ time = pressure.time
190
+
191
+ # Calculate rolling pressure standard deviation
192
+ window_size = max(10, int(min_duration / 10)) # ~10-second windows
193
+ pressure_std = pressure.rolling(time=window_size, center=True).std()
194
+
195
+ # Find stable periods (low pressure variability)
196
+ stable_mask = pressure_std < max_pressure_diff
197
+
198
+ # Find continuous stable periods
199
+ stable_periods = []
200
+ in_stop = False
201
+ start_idx = None
202
+
203
+ for i, is_stable in enumerate(stable_mask.values):
204
+ if is_stable and not in_stop:
205
+ # Start of stable period
206
+ in_stop = True
207
+ start_idx = i
208
+ elif not is_stable and in_stop:
209
+ # End of stable period
210
+ in_stop = False
211
+ if start_idx is not None:
212
+ duration = (
213
+ time.isel(time=i).values - time.isel(time=start_idx).values
214
+ ) / np.timedelta64(1, "s")
215
+
216
+ if duration >= min_duration:
217
+ stable_periods.append(
218
+ {
219
+ "start_time": pd.Timestamp(time.isel(time=start_idx).values),
220
+ "end_time": pd.Timestamp(time.isel(time=i).values),
221
+ "pressure": float(
222
+ pressure.isel(time=slice(start_idx, i)).mean()
223
+ ),
224
+ "duration": duration,
225
+ }
226
+ )
227
+
228
+ return pd.DataFrame(stable_periods)
229
+
230
+
231
+ def extract_bottle_stop_data(
232
+ ctd_data: xr.Dataset,
233
+ sensor_data: xr.Dataset,
234
+ bottle_stops: pd.DataFrame,
235
+ comparison_window: float = 180.0,
236
+ exclude_end: float = 30.0,
237
+ ctd_pressure_var: str = "pressure",
238
+ ctd_temp_var: str = "temperature",
239
+ ctd_cond_var: str = "conductivity",
240
+ sensor_pressure_var: str = "pressure",
241
+ sensor_temp_var: str = "temperature",
242
+ sensor_cond_var: str = "conductivity",
243
+ ) -> Dict[str, xr.Dataset]:
244
+ """
245
+ Extract data from bottle stops for CTD-sensor comparison.
246
+
247
+ Parameters
248
+ ----------
249
+ ctd_data : xarray.Dataset
250
+ CTD data with high-frequency sampling (24 Hz)
251
+ sensor_data : xarray.Dataset
252
+ Sensor data with lower-frequency sampling (0.1 Hz)
253
+ bottle_stops : pandas.DataFrame
254
+ Bottle stop information from identify_bottle_stops()
255
+ comparison_window : float, optional
256
+ Length of comparison window in seconds, default 180.0 (3 minutes)
257
+ exclude_end : float, optional
258
+ Exclude final N seconds from each stop, default 30.0
259
+ ctd_pressure_var : str, optional
260
+ CTD pressure variable name, default 'pressure'
261
+ ctd_temp_var : str, optional
262
+ CTD temperature variable name, default 'temperature'
263
+ ctd_cond_var : str, optional
264
+ CTD conductivity variable name, default 'conductivity'
265
+ sensor_pressure_var : str, optional
266
+ Sensor pressure variable name, default 'pressure'
267
+ sensor_temp_var : str, optional
268
+ Sensor temperature variable name, default 'temperature'
269
+ sensor_cond_var : str, optional
270
+ Sensor conductivity variable name, default 'conductivity'
271
+
272
+ Returns
273
+ -------
274
+ dict
275
+ Dictionary with keys 'ctd' and 'sensor', each containing xarray.Dataset
276
+ with extracted data for each bottle stop
277
+
278
+ Notes
279
+ -----
280
+ For each bottle stop:
281
+ 1. Identify stable pressure period (first 1 minute average)
282
+ 2. Extract final 3 minutes excluding last 30 seconds
283
+ 3. Filter CTD data to pressure within 5m of bottle stop pressure
284
+ 4. Return averaged values and standard deviations
285
+ """
286
+ extracted_data = {"ctd": [], "sensor": []}
287
+
288
+ for idx, stop in bottle_stops.iterrows():
289
+ stop_start = pd.Timestamp(stop["start_time"])
290
+ stop_end = pd.Timestamp(stop["end_time"])
291
+
292
+ # Define comparison window (final 3 minutes excluding last 30 seconds)
293
+ comparison_start = stop_end - pd.Timedelta(seconds=comparison_window + exclude_end)
294
+ comparison_end = stop_end - pd.Timedelta(seconds=exclude_end)
295
+
296
+ # Extract CTD data for this stop
297
+ ctd_stop_mask = (
298
+ (ctd_data.time >= comparison_start) &
299
+ (ctd_data.time <= comparison_end)
300
+ )
301
+ ctd_stop_data = ctd_data.sel(time=ctd_stop_mask)
302
+
303
+ # Filter by pressure stability (within 5m of stop pressure)
304
+ if len(ctd_stop_data.time) > 0:
305
+ pressure_mask = (
306
+ np.abs(ctd_stop_data[ctd_pressure_var] - stop["pressure"]) <= 5.0
307
+ )
308
+ ctd_stop_data = ctd_stop_data.where(pressure_mask, drop=True)
309
+
310
+ # Extract sensor data for this stop
311
+ sensor_stop_mask = (
312
+ (sensor_data.time >= comparison_start) &
313
+ (sensor_data.time <= comparison_end)
314
+ )
315
+ sensor_stop_data = sensor_data.sel(time=sensor_stop_mask)
316
+
317
+ # Calculate statistics if sufficient data
318
+ if len(ctd_stop_data.time) > 10 and len(sensor_stop_data.time) > 3:
319
+ # CTD statistics
320
+ ctd_stats = xr.Dataset({
321
+ f"{ctd_pressure_var}_mean": ctd_stop_data[ctd_pressure_var].mean(),
322
+ f"{ctd_pressure_var}_std": ctd_stop_data[ctd_pressure_var].std(),
323
+ f"{ctd_temp_var}_mean": ctd_stop_data[ctd_temp_var].mean(),
324
+ f"{ctd_temp_var}_std": ctd_stop_data[ctd_temp_var].std(),
325
+ f"{ctd_cond_var}_mean": ctd_stop_data[ctd_cond_var].mean(),
326
+ f"{ctd_cond_var}_std": ctd_stop_data[ctd_cond_var].std(),
327
+ "stop_number": idx,
328
+ "stop_pressure": stop["pressure"],
329
+ })
330
+
331
+ # Sensor statistics
332
+ sensor_stats = xr.Dataset({
333
+ f"{sensor_temp_var}_mean": sensor_stop_data[sensor_temp_var].mean(),
334
+ f"{sensor_temp_var}_std": sensor_stop_data[sensor_temp_var].std(),
335
+ f"{sensor_cond_var}_mean": sensor_stop_data[sensor_cond_var].mean(),
336
+ f"{sensor_cond_var}_std": sensor_stop_data[sensor_cond_var].std(),
337
+ "stop_number": idx,
338
+ "stop_pressure": stop["pressure"],
339
+ })
340
+
341
+ # Add pressure stats if available
342
+ if sensor_pressure_var in sensor_stop_data.variables:
343
+ sensor_stats[f"{sensor_pressure_var}_mean"] = sensor_stop_data[sensor_pressure_var].mean()
344
+ sensor_stats[f"{sensor_pressure_var}_std"] = sensor_stop_data[sensor_pressure_var].std()
345
+
346
+ extracted_data["ctd"].append(ctd_stats)
347
+ extracted_data["sensor"].append(sensor_stats)
348
+
349
+ # Combine into datasets
350
+ if extracted_data["ctd"]:
351
+ extracted_data["ctd"] = xr.concat(extracted_data["ctd"], dim="stop")
352
+ extracted_data["sensor"] = xr.concat(extracted_data["sensor"], dim="stop")
353
+ else:
354
+ # Return empty datasets if no valid stops found
355
+ extracted_data["ctd"] = xr.Dataset()
356
+ extracted_data["sensor"] = xr.Dataset()
357
+
358
+ return extracted_data
359
+
360
+
361
+ def calculate_sensor_offsets(
362
+ comparison_data: Dict[str, xr.Dataset],
363
+ apply_pressure_correction: bool = True,
364
+ reference_pressure: Optional[float] = None,
365
+ ) -> xr.Dataset:
366
+ """
367
+ Calculate sensor offsets relative to CTD reference.
368
+
369
+ Parameters
370
+ ----------
371
+ comparison_data : dict
372
+ Output from extract_bottle_stop_data()
373
+ apply_pressure_correction : bool, optional
374
+ Apply pressure correction to conductivity for sensors without pressure
375
+ Default True
376
+ reference_pressure : float, optional
377
+ Reference pressure for conductivity correction
378
+ If None, uses CTD pressure for each stop
379
+
380
+ Returns
381
+ -------
382
+ xarray.Dataset
383
+ Dataset containing offset calculations:
384
+ - dt: temperature difference (sensor - CTD) [°C]
385
+ - dc: conductivity difference (sensor - CTD) [mS/cm]
386
+ - dp: pressure difference (sensor - CTD) [dbar]
387
+ - ctd_std_*: CTD standard deviations
388
+ - sensor_std_*: sensor standard deviations
389
+
390
+ Notes
391
+ -----
392
+ For sensors without pressure, conductivity is corrected using CTD pressure
393
+ before calculating differences. This accounts for pressure effects on
394
+ conductivity cell geometry.
395
+ """
396
+ ctd_data = comparison_data["ctd"]
397
+ sensor_data = comparison_data["sensor"]
398
+
399
+ if len(ctd_data.stop) == 0:
400
+ warnings.warn("No valid bottle stop data found")
401
+ return xr.Dataset()
402
+
403
+ # Calculate temperature offset
404
+ dt = sensor_data["temperature_mean"] - ctd_data["temperature_mean"]
405
+
406
+ # Calculate conductivity offset
407
+ sensor_cond = sensor_data["conductivity_mean"].copy()
408
+
409
+ # Apply pressure correction to conductivity if needed
410
+ has_sensor_pressure = "pressure_mean" in sensor_data.variables
411
+
412
+ if apply_pressure_correction and not has_sensor_pressure:
413
+ # Correct sensor conductivity for pressure effects
414
+ if reference_pressure is None:
415
+ ref_pressure = ctd_data["pressure_mean"]
416
+ else:
417
+ ref_pressure = xr.full_like(ctd_data["pressure_mean"], reference_pressure)
418
+
419
+ # Apply pressure correction (simplified linear model)
420
+ # More sophisticated correction would use actual sensor calibration coefficients
421
+ pressure_correction_factor = 2.2e-6 # Typical value for SeaBird sensors
422
+ pressure_diff = ctd_data["pressure_mean"] - ref_pressure
423
+ sensor_cond = sensor_cond * (1 + pressure_correction_factor * pressure_diff)
424
+
425
+ dc = sensor_cond - ctd_data["conductivity_mean"]
426
+
427
+ # Create output dataset
428
+ offsets = xr.Dataset({
429
+ "dt": dt,
430
+ "dc": dc,
431
+ "pressure_stop": ctd_data["stop_pressure"],
432
+ "ctd_temperature_std": ctd_data["temperature_std"],
433
+ "ctd_conductivity_std": ctd_data["conductivity_std"],
434
+ "ctd_pressure_std": ctd_data["pressure_std"],
435
+ "sensor_temperature_std": sensor_data["temperature_std"],
436
+ "sensor_conductivity_std": sensor_data["conductivity_std"],
437
+ })
438
+
439
+ # Add pressure offset if available
440
+ if has_sensor_pressure:
441
+ dp = sensor_data["pressure_mean"] - ctd_data["pressure_mean"]
442
+ offsets["dp"] = dp
443
+ offsets["sensor_pressure_std"] = sensor_data["pressure_std"]
444
+
445
+ # Add metadata
446
+ offsets.attrs.update({
447
+ "title": "Sensor calibration offsets relative to CTD",
448
+ "description": "Temperature, conductivity, and pressure differences calculated from bottle stop comparisons",
449
+ "pressure_correction_applied": apply_pressure_correction and not has_sensor_pressure,
450
+ })
451
+
452
+ return offsets
453
+
454
+
455
+ def plot_calibration_results(
456
+ ctd_data: xr.Dataset,
457
+ sensor_data: xr.Dataset,
458
+ bottle_stops: pd.DataFrame,
459
+ offsets: xr.Dataset,
460
+ sensor_serial: Optional[str] = None,
461
+ figsize: Tuple[float, float] = (12, 10),
462
+ ) -> plt.Figure:
463
+ """
464
+ Plot calibration dip results showing CTD vs sensor data.
465
+
466
+ Parameters
467
+ ----------
468
+ ctd_data : xarray.Dataset
469
+ Full CTD dataset
470
+ sensor_data : xarray.Dataset
471
+ Full sensor dataset
472
+ bottle_stops : pandas.DataFrame
473
+ Bottle stop information
474
+ offsets : xarray.Dataset
475
+ Calculated offsets from calculate_sensor_offsets()
476
+ sensor_serial : str, optional
477
+ Sensor serial number for plot title
478
+ figsize : tuple, optional
479
+ Figure size (width, height), default (12, 10)
480
+
481
+ Returns
482
+ -------
483
+ matplotlib.Figure
484
+ Figure with three subplots showing pressure, temperature, and conductivity
485
+
486
+ Notes
487
+ -----
488
+ Creates three-panel plot:
489
+ - Top: CTD pressure (thick black) and sensor pressure (thin colored)
490
+ - Middle: CTD and sensor temperature with bottle stop markers
491
+ - Bottom: CTD and sensor conductivity with bottle stop markers
492
+
493
+ Bottle stops are marked with vertical lines and pressure annotations.
494
+ """
495
+ fig, axes = plt.subplots(3, 1, figsize=figsize, sharex=True)
496
+
497
+ # Convert times to matplotlib format for plotting
498
+ ctd_time = pd.to_datetime(ctd_data.time.values)
499
+ sensor_time = pd.to_datetime(sensor_data.time.values)
500
+
501
+ # Top panel: Pressure
502
+ axes[0].plot(ctd_time, ctd_data.pressure, "k-", linewidth=2, label="CTD 911")
503
+
504
+ if "pressure" in sensor_data.variables:
505
+ axes[0].plot(
506
+ sensor_time,
507
+ sensor_data.pressure,
508
+ "r-",
509
+ linewidth=1,
510
+ label=f"Sensor {sensor_serial or 'XXXX'}"
511
+ )
512
+
513
+ axes[0].set_ylabel("Pressure [dbar]")
514
+ axes[0].legend()
515
+ axes[0].grid(True, alpha=0.3)
516
+ axes[0].invert_yaxis() # Pressure increases downward
517
+
518
+ # Middle panel: Temperature
519
+ axes[1].plot(ctd_time, ctd_data.temperature, "k-", linewidth=2, label="CTD 911")
520
+ axes[1].plot(
521
+ sensor_time,
522
+ sensor_data.temperature,
523
+ "r-",
524
+ linewidth=1,
525
+ label=f"Sensor {sensor_serial or 'XXXX'}"
526
+ )
527
+ axes[1].set_ylabel("Temperature [°C]")
528
+ axes[1].legend()
529
+ axes[1].grid(True, alpha=0.3)
530
+
531
+ # Bottom panel: Conductivity
532
+ axes[2].plot(ctd_time, ctd_data.conductivity, "k-", linewidth=2, label="CTD 911")
533
+ axes[2].plot(
534
+ sensor_time,
535
+ sensor_data.conductivity,
536
+ "r-",
537
+ linewidth=1,
538
+ label=f"Sensor {sensor_serial or 'XXXX'}"
539
+ )
540
+ axes[2].set_ylabel("Conductivity [mS/cm]")
541
+ axes[2].set_xlabel("Time")
542
+ axes[2].legend()
543
+ axes[2].grid(True, alpha=0.3)
544
+
545
+ # Mark bottle stops
546
+ for _, stop in bottle_stops.iterrows():
547
+ stop_time = pd.Timestamp(stop["start_time"])
548
+ for ax in axes:
549
+ ax.axvline(stop_time, color="blue", alpha=0.5, linestyle="--")
550
+
551
+ # Add pressure annotation on top panel
552
+ axes[0].annotate(
553
+ f"{stop['pressure']:.0f}m",
554
+ xy=(stop_time, stop["pressure"]),
555
+ xytext=(10, 10),
556
+ textcoords="offset points",
557
+ fontsize=8,
558
+ color="blue",
559
+ )
560
+
561
+ # Add offset information as text
562
+ if len(offsets.stop) > 0:
563
+ offset_text = []
564
+ if "dt" in offsets:
565
+ dt_mean = float(offsets.dt.mean())
566
+ dt_std = float(offsets.dt.std())
567
+ offset_text.append(f"ΔT = {dt_mean:.4f} ± {dt_std:.4f} °C")
568
+
569
+ if "dc" in offsets:
570
+ dc_mean = float(offsets.dc.mean())
571
+ dc_std = float(offsets.dc.std())
572
+ offset_text.append(f"ΔC = {dc_mean:.4f} ± {dc_std:.4f} mS/cm")
573
+
574
+ if "dp" in offsets:
575
+ dp_mean = float(offsets.dp.mean())
576
+ dp_std = float(offsets.dp.std())
577
+ offset_text.append(f"ΔP = {dp_mean:.2f} ± {dp_std:.2f} dbar")
578
+
579
+ # Add text box with offsets
580
+ text_str = "\n".join(offset_text)
581
+ axes[1].text(
582
+ 0.02, 0.98, text_str,
583
+ transform=axes[1].transAxes,
584
+ verticalalignment="top",
585
+ bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.8),
586
+ fontsize=9,
587
+ )
588
+
589
+ # Format and finalize
590
+ plt.suptitle(
591
+ f"Calibration Dip Results - Sensor {sensor_serial or 'XXXX'}",
592
+ fontsize=14
593
+ )
594
+ plt.tight_layout()
595
+
596
+ return fig
597
+
598
+
599
+ def process_calibration_dip(
600
+ ctd_file: Union[str, Path],
601
+ sensor_file: Union[str, Path],
602
+ bottle_file: Optional[Union[str, Path]] = None,
603
+ output_file: Optional[Union[str, Path]] = None,
604
+ plot_results: bool = True,
605
+ **kwargs
606
+ ) -> Dict[str, Union[xr.Dataset, pd.DataFrame, plt.Figure]]:
607
+ """
608
+ Complete calibration dip processing workflow.
609
+
610
+ Parameters
611
+ ----------
612
+ ctd_file : str or Path
613
+ Path to CTD NetCDF file (24 Hz data)
614
+ sensor_file : str or Path
615
+ Path to sensor NetCDF file (10 second data)
616
+ bottle_file : str or Path, optional
617
+ Path to bottle stop file
618
+ output_file : str or Path, optional
619
+ Path to save results NetCDF file
620
+ plot_results : bool, optional
621
+ Generate calibration plots, default True
622
+ **kwargs
623
+ Additional arguments passed to processing functions
624
+
625
+ Returns
626
+ -------
627
+ dict
628
+ Dictionary containing:
629
+ - 'offsets': xarray.Dataset with calculated offsets
630
+ - 'bottle_stops': pandas.DataFrame with bottle stop information
631
+ - 'ctd_data': xarray.Dataset with CTD data
632
+ - 'sensor_data': xarray.Dataset with sensor data
633
+ - 'figure': matplotlib.Figure (if plot_results=True)
634
+
635
+ Examples
636
+ --------
637
+ >>> # Basic usage
638
+ >>> results = process_calibration_dip('ctd_data.nc', 'sensor_data.nc')
639
+ >>> offsets = results['offsets']
640
+ >>> print(f"Mean temperature offset: {offsets.dt.mean().values:.4f} °C")
641
+
642
+ >>> # With bottle file and custom parameters
643
+ >>> results = process_calibration_dip(
644
+ ... 'ctd_data.nc', 'sensor_data.nc',
645
+ ... bottle_file='bottle_stops.nc',
646
+ ... comparison_window=240.0, # 4 minute window
647
+ ... plot_results=True
648
+ ... )
649
+ """
650
+ # Load data
651
+ print(f"Loading CTD data from {ctd_file}")
652
+ ctd_data = xr.open_dataset(ctd_file)
653
+
654
+ print(f"Loading sensor data from {sensor_file}")
655
+ sensor_data = xr.open_dataset(sensor_file)
656
+
657
+ # Get sensor serial if available
658
+ sensor_serial = getattr(sensor_data, "serial_number", None)
659
+ if sensor_serial is None and "serial_number" in sensor_data.variables:
660
+ sensor_serial = str(sensor_data.serial_number.values)
661
+
662
+ # Detect water impacts for time synchronization
663
+ try:
664
+ ctd_impact = detect_water_impact(ctd_data, **kwargs.get("ctd_impact_kwargs", {}))
665
+ sensor_impact = detect_water_impact(sensor_data, **kwargs.get("sensor_impact_kwargs", {}))
666
+
667
+ time_offset = (ctd_impact - sensor_impact).total_seconds()
668
+ print(f"Time offset CTD-Sensor: {time_offset:.1f} seconds")
669
+
670
+ # Apply time offset to sensor data if significant
671
+ if abs(time_offset) > 5: # More than 5 seconds
672
+ print("Applying time offset to sensor data")
673
+ sensor_data["time"] = sensor_data.time + pd.Timedelta(seconds=time_offset)
674
+
675
+ except (ValueError, KeyError) as e:
676
+ print(f"Warning: Could not determine time offset: {e}")
677
+ print("Proceeding without time synchronization")
678
+
679
+ # Identify bottle stops
680
+ print("Identifying bottle stops")
681
+ bottle_stops = identify_bottle_stops(
682
+ ctd_data,
683
+ bottle_file=bottle_file,
684
+ **kwargs.get("bottle_stop_kwargs", {})
685
+ )
686
+
687
+ print(f"Found {len(bottle_stops)} bottle stops")
688
+ if len(bottle_stops) == 0:
689
+ raise ValueError("No bottle stops found - check CTD data or bottle file")
690
+
691
+ # Extract comparison data
692
+ print("Extracting bottle stop data")
693
+ comparison_data = extract_bottle_stop_data(
694
+ ctd_data,
695
+ sensor_data,
696
+ bottle_stops,
697
+ **kwargs.get("extraction_kwargs", {})
698
+ )
699
+
700
+ if len(comparison_data["ctd"].stop) == 0:
701
+ raise ValueError("No valid bottle stop data extracted")
702
+
703
+ # Calculate offsets
704
+ print("Calculating sensor offsets")
705
+ offsets = calculate_sensor_offsets(
706
+ comparison_data,
707
+ **kwargs.get("offset_kwargs", {})
708
+ )
709
+
710
+ # Create results dictionary
711
+ results = {
712
+ "offsets": offsets,
713
+ "bottle_stops": bottle_stops,
714
+ "ctd_data": ctd_data,
715
+ "sensor_data": sensor_data,
716
+ }
717
+
718
+ # Generate plots
719
+ if plot_results:
720
+ print("Generating calibration plots")
721
+ fig = plot_calibration_results(
722
+ ctd_data, sensor_data, bottle_stops, offsets,
723
+ sensor_serial=sensor_serial,
724
+ **kwargs.get("plot_kwargs", {})
725
+ )
726
+ results["figure"] = fig
727
+
728
+ # Save results
729
+ if output_file is not None:
730
+ print(f"Saving results to {output_file}")
731
+ offsets.to_netcdf(output_file)
732
+
733
+ # Print summary
734
+ if len(offsets.stop) > 0:
735
+ print("\nCalibration Results Summary:")
736
+ print(f"Number of bottle stops: {len(offsets.stop)}")
737
+
738
+ if "dt" in offsets:
739
+ dt_mean = float(offsets.dt.mean())
740
+ dt_std = float(offsets.dt.std())
741
+ print(f"Temperature offset: {dt_mean:.4f} ± {dt_std:.4f} °C")
742
+
743
+ if "dc" in offsets:
744
+ dc_mean = float(offsets.dc.mean())
745
+ dc_std = float(offsets.dc.std())
746
+ print(f"Conductivity offset: {dc_mean:.4f} ± {dc_std:.4f} mS/cm")
747
+
748
+ if "dp" in offsets:
749
+ dp_mean = float(offsets.dp.mean())
750
+ dp_std = float(offsets.dp.std())
751
+ print(f"Pressure offset: {dp_mean:.2f} ± {dp_std:.2f} dbar")
752
+
753
+ return results