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